diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a2719bc --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# HTTP API host. +# Example for local backend: http://localhost:8300 +VITE_API_BASE_URL=http://localhost:8300 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 8eefb8f..4832bff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,11 @@ +{ + "npm.autoDetect": "off", + "files.exclude": { + ".husky": true, + "build": true + } +} + { "files.exclude": { "**/__pycache__": true, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 466e1a5..3c6d252 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,46 +1,10 @@ { "version": "2.0.0", "tasks": [ - { - "label": "Backend", - "type": "npm", - "script": "backend:run", - "options": { - "cwd": "${workspaceFolder}" - }, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "shared" - }, - "group": { - "kind": "build" - }, - "isBackground": true - }, { "label": "Frontend (Web)", - "type": "npm", - "script": "frontend:dev", - "options": { - "cwd": "${workspaceFolder}" - }, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "shared" - }, - "group": { - "kind": "build" - }, - "isBackground": true - }, - { - "label": "LiveKit", - "type": "npm", - "script": "livekit:run", + "type": "shell", + "command": "npm run frontend:dev", "options": { "cwd": "${workspaceFolder}" }, @@ -57,8 +21,8 @@ }, { "label": "Frontend (Electron)", - "type": "npm", - "script": "frontend:electron:dev", + "type": "shell", + "command": "npm run frontend:electron:dev", "options": { "cwd": "${workspaceFolder}" }, @@ -76,25 +40,7 @@ { "label": "Web", - "dependsOn": ["LiveKit", "Backend", "Frontend (Web)"], - "dependsOrder": "parallel", - "group": { - "kind": "build", - "isDefault": true - }, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "shared" - }, - "runOptions": { - "runOn": "folderOpen" - } - }, - { - "label": "Electron", - "dependsOn": ["LiveKit", "Backend", "Frontend (Electron)"], + "dependsOn": ["Frontend (Web)"], "dependsOrder": "parallel", "group": { "kind": "build" @@ -107,15 +53,17 @@ } }, { - "label": "Deploy", - "type": "shell", - "command": "npm run deploy", + "label": "Electron", + "dependsOn": ["Frontend (Electron)"], + "dependsOrder": "parallel", + "group": { + "kind": "build" + }, "presentation": { "echo": true, "reveal": "always", - "focus": true, - "panel": "dedicated", - "clear": true + "focus": false, + "panel": "shared" } } ] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b5a90a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# 1. Frontend production build +FROM node:24 AS builder + +WORKDIR /app + +# 1.1. Install dependencies +COPY package.json package-lock.json ./ + +RUN --mount=type=cache,target=/root/.npm \ + npm install --ignore-scripts + +# 1.2. Copy sources and configure +COPY . . + +WORKDIR /app +ARG NODE_ENV=production +ARG VITE_API_BASE_URL=https://api.fromchat.ru +ENV NODE_ENV=production +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +# 1.3. Build production assets +RUN npm run frontend:build + + +# 2. Production web static server +FROM joseluisq/static-web-server:latest AS production + +# 2.1. Copy files to server root (/var/public -> /home/sws/public) +COPY --from=builder /app/build/normal/dist /var/public + +# 2.2. Configure (defaults serve the image's built-in landing page instead of our app) +ENV SERVER_ROOT=/var/public +ENV SERVER_FALLBACK_PAGE=/var/public/index.html + +EXPOSE 80 \ No newline at end of file diff --git a/backend/__init__.py b/backend/__init__.py deleted file mode 100644 index 4350cd0..0000000 --- a/backend/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Backend package initializer -__all__ = [] - diff --git a/backend/admin_cli.py b/backend/admin_cli.py deleted file mode 100644 index ebfab0e..0000000 --- a/backend/admin_cli.py +++ /dev/null @@ -1,417 +0,0 @@ -from __future__ import annotations - -import argparse -import base64 -import hashlib -import hmac -import os -import shlex -import sys -from getpass import getpass -from typing import Iterable, List, Optional, Tuple -import readline -import httpx -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - - -class CLIError(Exception): - """Generic CLI error with a human-readable message.""" - - -def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes: - return hmac.new(salt, ikm, hashlib.sha256).digest() - - -def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes: - blocks: list[bytes] = [] - previous = b"" - counter = 1 - while len(b"".join(blocks)) < length: - previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest() - blocks.append(previous) - counter += 1 - return b"".join(blocks)[:length] - - -def derive_auth_secret(username: str, password: str) -> str: - salt = f"fromchat.user:{username}".encode("utf-8") - prk = _hkdf_extract(salt, password.encode("utf-8")) - okm = _hkdf_expand(prk, b"auth-secret", 32) - return base64.b64encode(okm).decode("utf-8") - - -def _read_single_key() -> str: - try: # Windows - import msvcrt # type: ignore - - ch = msvcrt.getch() - return ch.decode("utf-8", errors="ignore").lower() - except ImportError: - import termios - import tty - - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - try: - tty.setraw(fd) - ch = sys.stdin.read(1) - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - return ch.lower() - - -class AdminCLI: - def __init__(self, api_url: str) -> None: - self.console = Console() - self.api_url = api_url.rstrip("/") - self.client = httpx.Client(base_url=self.api_url, timeout=30.0) - self.username: Optional[str] = None - self.token: Optional[str] = None - - # --------------------------- HTTP helpers --------------------------- # - def _auth_headers(self) -> dict: - headers: dict = {} - if self.token: - headers["Authorization"] = f"Bearer {self.token}" - return headers - - def _request(self, method: str, path: str, *, auth: bool = True, **kwargs) -> httpx.Response: - rel_path = path.lstrip("/") - headers = kwargs.pop("headers", {}) - if auth: - headers.update(self._auth_headers()) - response = self.client.request(method, rel_path, headers=headers, **kwargs) - if response.status_code >= 400: - detail = "" - try: - payload = response.json() - if isinstance(payload, dict): - detail = payload.get("detail") or payload.get("message") or "" - except Exception: - detail = response.text - message = f"{response.status_code} {response.reason_phrase}" - if detail: - message = f"{message}: {detail}" - raise CLIError(message.strip()) - return response - - # --------------------------- CLI primitives ------------------------- # - def _require_auth(self) -> None: - if not self.token: - raise CLIError("You must login before running this command.") - - def _resolve_user(self, identifier: str) -> dict: - self._require_auth() - if identifier.isdigit(): - response = self._request("GET", f"user/id/{identifier}") - else: - response = self._request("GET", f"user/{identifier.replace('@', '')}") - return response.json() - - def _confirm(self, prompt: str) -> bool: - self.console.print(f"[bold yellow]{prompt}[/] [green](y)[/] / [red](n)[/]: ", end="") - choice = _read_single_key() - self.console.print("") # move to next line - return choice == "y" - - def _render_user(self, user: dict) -> None: - table = Table(show_header=False) - table.add_row("ID", str(user.get("id"))) - table.add_row("Username", user.get("username", "")) - table.add_row("Display name", user.get("display_name", "")) - table.add_row("Verified", "✅" if user.get("verified") else "❌") - if user.get("suspended"): - table.add_row("Suspended", f"🚫 ({user.get('suspension_reason') or 'no reason'})") - else: - table.add_row("Suspended", "✅ Active") - self.console.print(table) - - # --------------------------- Commands ------------------------------- # - def cmd_login(self, args: List[str]) -> None: - if args: - username = args[0] - else: - username = self.console.input("[bold cyan]Username[/]: ").strip() - if not username: - raise CLIError("Username is required.") - - password = getpass("Password: ") - derived_password = derive_auth_secret(username, password) - payload = {"username": username, "password": derived_password} - response = self._request("POST", "login", json=payload, auth=False) - body = response.json() - token = body.get("token") - if not token: - raise CLIError("Authentication succeeded but token was not returned.") - self.token = token - self.username = username - self.console.print("[bold green]Login successful.[/]") - - def cmd_suspend(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: suspend ") - identifier = args[0] - user = self._resolve_user(identifier) - self.console.print(Panel.fit("[bold red]Suspend user[/]", style="red")) - self._render_user(user) - reason = self.console.input("[bold yellow]Reason (press Enter to leave empty)[/]: ").strip() - if not self._confirm(f"Confirm suspension of {user.get('username')}?"): - self.console.print("[yellow]Suspension cancelled.[/]") - return - payload = {"reason": reason} - self._request("POST", f"user/{user['id']}/suspend", json=payload) - log_reason = reason or "no reason provided" - self.console.print(f"[bold red]User {user['username']} suspended ({log_reason}).[/]") - - def cmd_unsuspend(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: unsuspend ") - identifier = args[0] - user = self._resolve_user(identifier) - self.console.print(Panel.fit("[bold green]Unsuspend user[/]", style="green")) - self._render_user(user) - if not self._confirm(f"Unsuspend {user.get('username')}?"): - self.console.print("[yellow]Unsuspension cancelled.[/]") - return - self._request("POST", f"user/{user['id']}/unsuspend") - self.console.print(f"[bold green]User {user['username']} unsuspended.[/]") - - def cmd_block_word(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: block-word [additional words...]") - self._require_auth() - words = args - response = self._request("POST", "moderation/blocklist", json={"words": words}) - data = response.json() - added = data.get("added", []) - current = data.get("words", []) - if added: - self.console.print(f"[bold green]Added {len(added)} entr{'y' if len(added)==1 else 'ies'} to blocklist.[/]") - else: - self.console.print("[yellow]No new words added.[/]") - self.console.print(f"Blocklist size: {len(current)}") - - def cmd_list_users(self) -> None: - self._require_auth() - payload = self._request("GET", "user/list").json() - users = payload.get("users", []) - table = Table(title="Users", show_lines=False) - table.add_column("ID") - table.add_column("Username") - table.add_column("Display name") - table.add_column("Suspended") - for user in users: - table.add_row( - str(user.get("id")), - user.get("username", ""), - user.get("display_name", ""), - "🚫" if user.get("suspended") else "✅", - ) - self.console.print(table) - - def cmd_user(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: user ") - user = self._resolve_user(args[0]) - self._render_user(user) - - def cmd_delete(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: delete ") - user = self._resolve_user(args[0]) - self.console.print(Panel.fit("[bold red]Delete user[/]", style="red")) - self._render_user(user) - if not self._confirm(f"Permanently delete {user.get('username')}?"): - self.console.print("[yellow]Deletion cancelled.[/]") - return - self._request("POST", f"user/{user['id']}/delete") - self.console.print(f"[bold red]User {user['username']} deleted.[/]") - - def cmd_unblock_word(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: unblock-word [additional words...]") - self._require_auth() - response = self._request("DELETE", "moderation/blocklist", json={"words": args}) - data = response.json() - removed = data.get("removed", []) - current = data.get("words", []) - if removed: - self.console.print(f"[bold green]Removed {len(removed)} entr{'y' if len(removed)==1 else 'ies'} from blocklist.[/]") - else: - self.console.print("[yellow]No matching words removed.[/]") - self.console.print(f"Blocklist size: {len(current)}") - - def cmd_verify(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: verify ") - user = self._resolve_user(args[0]) - if user.get("verified"): - self.console.print(f"[yellow]{user['username']} is already verified.[/]") - return - self._request("POST", f"user/{user['id']}/verify") - self.console.print(f"[bold green]{user['username']} marked as verified.[/]") - - def cmd_unverify(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: unverify ") - user = self._resolve_user(args[0]) - if not user.get("verified"): - self.console.print(f"[yellow]{user['username']} is already unverified.[/]") - return - self._request("POST", f"user/{user['id']}/verify") - self.console.print(f"[bold green]{user['username']} is now unverified.[/]") - - def cmd_list_blocklist(self) -> None: - self._require_auth() - response = self._request("GET", "moderation/blocklist") - words = response.json().get("words", []) - if not words: - self.console.print("[cyan]Blocklist is empty.[/]") - return - table = Table(title="Blocked Words", show_lines=True) - table.add_column("Word / Phrase") - for entry in words: - table.add_row(entry) - self.console.print(table) - - def cmd_unblock_ip(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: unblock-ip ") - self._require_auth() - ip = args[0].strip() - if not ip: - raise CLIError("IP address cannot be empty") - response = self._request("POST", "moderation/unblock-ip", json={"ip": ip}) - data = response.json() - message = data.get("message", "IP unblocked") - self.console.print(f"[bold green]{message}[/]") - - def cmd_clear_all_rate_limits(self) -> None: - """Clear all rate limit entries. Use with caution.""" - self._require_auth() - if not self._confirm("Clear ALL rate limit entries? This affects all IPs."): - self.console.print("[yellow]Operation cancelled.[/]") - return - response = self._request("POST", "moderation/clear-all-rate-limits") - data = response.json() - message = data.get("message", "Rate limits cleared") - self.console.print(f"[bold green]{message}[/]") - - def cmd_help(self) -> None: - cmds = { - "login [username]": "Authenticate as owner/admin.", - "suspend ": "Suspend account (alias: ban).", - "unsuspend ": "Unsuspend account (alias: unban).", - "delete ": "Permanently delete the user account.", - "verify ": "Mark user as verified.", - "unverify ": "Remove verification flag.", - "block-word ": "Add words/phrases to chat filter.", - "unblock-word ": "Remove words/phrases from filter.", - "blocklist": "Show current blocklist.", - "unblock-ip ": "Unblock an IP address from rate limiting.", - "clear-all-rate-limits": "Clear all rate limit entries (use with caution).", - "list": "List all users.", - "user ": "Show detailed user information.", - "whoami": "Display current session context.", - "help": "Show this help panel.", - "exit": "Quit the CLI.", - } - table = Table(title="Available Commands") - table.add_column("Command", style="cyan") - table.add_column("Description", style="white") - for cmd, desc in cmds.items(): - table.add_row(cmd, desc) - self.console.print(table) - - def cmd_whoami(self) -> None: - if not self.token: - self.console.print("[yellow]Not authenticated.[/]") - return - self.console.print(f"[green]Logged in as[/] [bold]{self.username}[/] ({self.api_url})") - - # --------------------------- Main loop ------------------------------ # - def run(self) -> None: - self.console.print(Panel.fit("[bold magenta]FromChat Admin CLI[/]", style="magenta")) - while True: - prompt_identity = self.username or "guest" - try: - prompt_str = f"\033[36m{prompt_identity}\033[0m \033[1m>\033[0m " - raw = input(prompt_str).strip() - except (KeyboardInterrupt, EOFError): - self.console.print("\n[red]Exiting...[/]") - break - - if not raw: - continue - - try: - parts = shlex.split(raw) - except ValueError as exc: - self.console.print(f"[red]Parse error:[/] {exc}") - continue - - command = parts[0].lstrip("/").lower() - args = parts[1:] - - if command in {"exit", "quit"}: - self.console.print("[red]Goodbye.[/]") - break - - try: - if command == "login": - self.cmd_login(args) - elif command in {"suspend", "ban"}: - self.cmd_suspend(args) - elif command in {"unsuspend", "unban"}: - self.cmd_unsuspend(args) - elif command == "block-word": - self.cmd_block_word(args) - elif command == "unblock-word": - self.cmd_unblock_word(args) - elif command == "blocklist": - self.cmd_list_blocklist() - elif command == "unblock-ip": - self.cmd_unblock_ip(args) - elif command == "clear-all-rate-limits": - self.cmd_clear_all_rate_limits() - elif command == "verify": - self.cmd_verify(args) - elif command == "unverify": - self.cmd_unverify(args) - elif command in {"delete", "remove"}: - self.cmd_delete(args) - elif command == "list": - self.cmd_list_users() - elif command == "user": - self.cmd_user(args) - elif command == "help": - self.cmd_help() - elif command == "whoami": - self.cmd_whoami() - else: - self.console.print("[yellow]Unknown command. Type /help for a list of commands.[/]") - except CLIError as err: - self.console.print(f"[red]Error:[/] {err}") - except httpx.RequestError as err: - self.console.print(f"[red]Network error:[/] {err}") - - self.client.close() - - -def main(argv: Optional[Iterable[str]] = None) -> None: - parser = argparse.ArgumentParser(description="FromChat Emergency Admin CLI") - parser.add_argument( - "--api-url", - default=os.getenv("FC_ADMIN_API_URL", "http://127.0.0.1:8300"), - help="Base API URL for the FromChat backend (default: %(default)s).", - ) - args = parser.parse_args(list(argv) if argv is not None else None) - cli = AdminCLI(args.api_url) - cli.run() - - -if __name__ == "__main__": - main() - diff --git a/backend/alembic.ini b/backend/alembic.ini deleted file mode 100644 index 6d0955f..0000000 --- a/backend/alembic.ini +++ /dev/null @@ -1,147 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts. -# this is typically a path given in POSIX (e.g. forward slashes) -# format, relative to the token %(here)s which refers to the location of this -# ini file -script_location = %(here)s/alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. for multiple paths, the path separator -# is defined by "path_separator" below. -prepend_sys_path = . - - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. -# Any required deps can installed by adding `alembic[tz]` to the pip requirements -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to /versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "path_separator" -# below. -# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions - -# path_separator; This indicates what character is used to split lists of file -# paths, including version_locations and prepend_sys_path within configparser -# files such as alembic.ini. -# The default rendered in new alembic.ini files is "os", which uses os.pathsep -# to provide os-dependent path splitting. -# -# Note that in order to support legacy alembic.ini files, this default does NOT -# take place if path_separator is not present in alembic.ini. If this -# option is omitted entirely, fallback logic is as follows: -# -# 1. Parsing of the version_locations option falls back to using the legacy -# "version_path_separator" key, which if absent then falls back to the legacy -# behavior of splitting on spaces and/or commas. -# 2. Parsing of the prepend_sys_path option falls back to the legacy -# behavior of splitting on spaces, commas, or colons. -# -# Valid values for path_separator are: -# -# path_separator = : -# path_separator = ; -# path_separator = space -# path_separator = newline -# -# Use os.pathsep. Default configuration used for new projects. -path_separator = os - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -# database URL. This is consumed by the user-maintained env.py script only. -# other means of configuring database URLs may be customized within the env.py -# file. -# Database URL is now handled by the migration script dynamically - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module -# hooks = ruff -# ruff.type = module -# ruff.module = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Alternatively, use the exec runner to execute a binary found on your PATH -# hooks = ruff -# ruff.type = exec -# ruff.executable = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Logging configuration. This is also consumed by the user-maintained -# env.py script only. -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARNING -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARNING -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py deleted file mode 100644 index 362ae24..0000000 --- a/backend/alembic/env.py +++ /dev/null @@ -1,78 +0,0 @@ -from logging.config import fileConfig -import logging - -from sqlalchemy import engine_from_config -from sqlalchemy import pool - -from alembic import context - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# add your model's MetaData object here -# for 'autogenerate' support -from services.main.models import Base -target_metadata = Base.metadata - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako deleted file mode 100644 index 1101630..0000000 --- a/backend/alembic/script.py.mako +++ /dev/null @@ -1,28 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} diff --git a/backend/main.py b/backend/main.py deleted file mode 100644 index 646e113..0000000 --- a/backend/main.py +++ /dev/null @@ -1,23 +0,0 @@ -try: - # Preferred when running from project root: `python -m backend.main` or similar. - from backend.services.main.constants import * - from backend.services.main.db import * - from backend.services.main.models import * - from backend.services.main.validation import * - from backend.services.main.utils import * - from backend.services.main.dependencies import * - from backend.services.main.main import * -except ModuleNotFoundError as exc: - # Only attempt the fallback when the missing module is the 'backend' package itself. - if exc.name and exc.name.startswith("backend"): - # Fallback when running with CWD=backend (e.g. `cd backend && uvicorn main:app`) - from services.main.constants import * - from services.main.db import * - from services.main.models import * - from services.main.validation import * - from services.main.utils import * - from services.main.dependencies import * - from services.main.main import * - else: - # Re-raise (likely a missing external dependency like sqlalchemy) - raise \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index e14deca..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,22 +0,0 @@ -python-dotenv>=1.0.1 -PyJWT>=2.8.0 -fastapi[standard]>=0.116.1 -pydantic>=2.11.7 -sqlalchemy>=2.0.43 -psycopg2-binary>=2.9.9 -bcrypt>=4.3.0 -websockets>=15.0.1 -Pillow>=10.0.0 -python-multipart>=0.0.6 -pywebpush>=1.14.0 -cryptography>=41.0.0 -alembic>=1.13.2 -better-profanity>=0.7.0 -user-agents>=2.2.0 -httpx>=0.27.2 -rich>=13.9.4 -slowapi>=0.1.9 -firebase_admin>=7.1.0 -PyNaCl>=1.5.0 -numpy -livekit-api>=1.0.0,<2 diff --git a/backend/services/__init__.py b/backend/services/__init__.py deleted file mode 100644 index 2d287c1..0000000 --- a/backend/services/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Services package initializer -__all__ = [] - diff --git a/backend/services/file_storage/__init__.py b/backend/services/file_storage/__init__.py deleted file mode 100644 index 242d913..0000000 --- a/backend/services/file_storage/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# File storage service module \ No newline at end of file diff --git a/backend/services/file_storage/main.py b/backend/services/file_storage/main.py deleted file mode 100644 index 41eac1c..0000000 --- a/backend/services/file_storage/main.py +++ /dev/null @@ -1,935 +0,0 @@ -""" -File Storage Service - Secure file storage with execution prevention. - -This service handles all file storage operations with non-executable permissions -and secure directory configuration to prevent code execution regardless of file content. -""" - -import logging -import json -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager - -logger = logging.getLogger("uvicorn.error") - -# File storage has no database access - trusts main backend for authentication - -# Lifespan context for startup/shutdown tasks (modern FastAPI pattern) -@asynccontextmanager -async def lifespan(app: FastAPI): - # Ensure directories exist and permissions are applied before serving requests - _ensure_dirs() - _load_permissions() - logger.info("File storage initialized at %s", str(FILES_DIR.resolve())) - yield - -# Initialize FastAPI app for file storage service with lifespan -app = FastAPI( - title="FromChat File Storage Service", - description="Secure file storage service with execution prevention", - version="1.0.0", - lifespan=lifespan, -) - -# Add security middleware -try: - from services.shared.middleware import add_security_middleware -except ImportError: - try: - from backend.services.shared.middleware import add_security_middleware - except ImportError: - add_security_middleware = None - -if add_security_middleware: - add_security_middleware(app) - -try: - from services.shared.inter_service_rate_limit import attach_internal_service_rate_limit -except ImportError: - from backend.services.shared.inter_service_rate_limit import attach_internal_service_rate_limit # type: ignore - -_internal_limiter = attach_internal_service_rate_limit(app, default_limit="5000/minute") - -# CORS configuration for inter-service communication -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allow all origins for inter-service communication - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/health", response_model=None) -@_internal_limiter.exempt -async def health_check(): - """Health check endpoint for file storage service.""" - return {"status": "healthy", "service": "file_storage"} - - -@app.get("/", response_model=None) -async def root(): - """Root endpoint for file storage service.""" - return {"message": "FromChat File Storage Service", "status": "operational"} - - -""" -File storage implementation -- Stores files under `files/files` -- Ensures directories and files have non-executable permissions -- Simple internal auth via X-Internal-Auth header when INTERNAL_AUTH_TOKEN is set -- Streams uploads to disk to avoid large memory usage -""" - -import os -import base64 -import uuid -import time -from pathlib import Path -from typing import Optional -from fastapi import UploadFile, File, HTTPException, Request, Depends -from fastapi.responses import FileResponse -from sqlalchemy.orm import Session - -# Base storage directories -BASE_DIR = Path("files") -# Legacy upload layout (was data/uploads/files on monolith main under /app/data). -# Keep under BASE_DIR so Docker uses the file_storage volume (/app/files), not /app/data -# (different uid / optional mount → PermissionError on prod). -FILES_BASE_DIR = BASE_DIR / "data" / "uploads" / "files" -FILES_NORMAL_DIR = FILES_BASE_DIR / "normal" -FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" -FILES_DIR = BASE_DIR / "files" -THUMBS_DIR = BASE_DIR / "thumbs" -TMP_DIR = BASE_DIR / "tmp" -RESUMABLE_DIR = TMP_DIR / "resumable" -RESUMABLE_META_DIR = RESUMABLE_DIR / "meta" -RESUMABLE_DATA_DIR = RESUMABLE_DIR / "data" - -# Maximum allowed upload size (bytes) - 5GB per plan -MAX_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024 - -# Permissions storage -PERMISSIONS_FILE = Path("files/permissions.json") -_file_permissions: dict[str, list[int]] = {} - - -def _load_permissions(): - """Load permissions from disk.""" - global _file_permissions - if PERMISSIONS_FILE.exists(): - try: - with open(PERMISSIONS_FILE, 'r') as f: - _file_permissions = json.load(f) - except Exception as e: - logger.error("Failed to load permissions file: %s", e) - _file_permissions = {} - - -def _save_permissions(): - """Save permissions to disk.""" - try: - with open(PERMISSIONS_FILE, 'w') as f: - json.dump(_file_permissions, f, indent=2) - except Exception as e: - logger.error("Failed to save permissions file: %s", e) - - -def _store_file_permissions(file_id: str, allowed_user_ids: list[int]): - """Store permission information for a file.""" - _file_permissions[file_id] = allowed_user_ids - _save_permissions() - - -def _check_file_permissions(file_id: str, user_id: int) -> bool: - """Check if user has permission to access a file.""" - allowed_users = _file_permissions.get(file_id, []) - return user_id in allowed_users - - -def _ensure_dirs() -> None: - """Create storage directories with secure permissions (owner rw, no exec for files).""" - os.makedirs(FILES_DIR, exist_ok=True) - os.makedirs(TMP_DIR, exist_ok=True) - os.makedirs(RESUMABLE_META_DIR, exist_ok=True) - os.makedirs(RESUMABLE_DATA_DIR, exist_ok=True) - # Also ensure the uploads directories exist (for backward compatibility) - os.makedirs(FILES_NORMAL_DIR, exist_ok=True) - os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True) - try: - # Directories should be accessible only by owner - os.chmod(BASE_DIR, 0o700) - os.chmod(FILES_DIR, 0o700) - os.chmod(TMP_DIR, 0o700) - os.chmod(RESUMABLE_DIR, 0o700) - os.chmod(RESUMABLE_META_DIR, 0o700) - os.chmod(RESUMABLE_DATA_DIR, 0o700) - os.chmod(FILES_BASE_DIR, 0o700) - os.chmod(FILES_NORMAL_DIR, 0o700) - os.chmod(FILES_ENCRYPTED_DIR, 0o700) - os.makedirs(THUMBS_DIR, exist_ok=True) - os.chmod(THUMBS_DIR, 0o700) - except Exception: - # Best-effort; don't fail startup if chmod not permitted - logger.debug("Could not set directory permissions for file storage (best-effort)") - - -# No internal auth enforced by design (accept all uploads). Authentication is handled by main service. - - -# startup tasks are handled by the lifespan context manager above - - -def _secure_filename(name: str) -> str: - """Return a sanitized filename (strip directories).""" - return Path(name).name - - -def _resumable_meta_path(upload_id: str) -> Path: - return RESUMABLE_META_DIR / f"{upload_id}.json" - - -def _resumable_data_path(upload_id: str) -> Path: - return RESUMABLE_DATA_DIR / f"{upload_id}.bin" - - -def _read_resumable_meta(upload_id: str) -> dict: - meta_path = _resumable_meta_path(upload_id) - if not meta_path.exists(): - raise HTTPException(status_code=404, detail="Upload session not found") - try: - return json.loads(meta_path.read_text(encoding="utf-8")) - except Exception as e: - logger.error("STORAGE: Failed to read resumable metadata for %s: %s", upload_id, e) - raise HTTPException(status_code=500, detail="Failed to read upload session") - - -def _write_resumable_meta(upload_id: str, data: dict) -> None: - meta_path = _resumable_meta_path(upload_id) - tmp_path = meta_path.with_suffix(".json.tmp") - tmp_path.write_text(json.dumps(data, ensure_ascii=True), encoding="utf-8") - os.replace(tmp_path, meta_path) - - -def _assert_resumable_access(meta: dict, user_id: int) -> None: - allowed = meta.get("allowed_user_ids", []) - if user_id == 1: - return - if user_id not in allowed: - raise HTTPException(status_code=403, detail="Access denied to this upload") - - -async def _stream_save(upload: UploadFile, dest_path: Path) -> int: - """Stream an UploadFile to disk, return total bytes written.""" - total = 0 - # write to a temp file first - tmp_name = TMP_DIR / f"{uuid.uuid4().hex}.tmp" - try: - with open(tmp_name, "wb") as out: - while True: - chunk = await upload.read(64 * 1024) - if not chunk: - break - out.write(chunk) - total += len(chunk) - if total > MAX_UPLOAD_SIZE: - raise HTTPException(status_code=400, detail="File exceeds maximum allowed size") - # Move into place - os.replace(tmp_name, dest_path) - # Ensure non-executable permissions for file (rw for owner only) - try: - os.chmod(dest_path, 0o600) - except Exception: - logger.debug("Could not chmod file %s", dest_path) - return total - finally: - # Cleanup tmp if still exists - try: - if tmp_name.exists(): - tmp_name.unlink() - except Exception: - pass - - -@app.post("/upload", response_model=None) -async def upload_file(request: Request, file: UploadFile = File(...)): - """ - Upload a file to secure storage. Returns the stored filename and path. - - """ - try: - # Ensure directories exist even when called in-process (lifespan may not run for mounted apps). - _ensure_dirs() - - original_name = _secure_filename(file.filename or "file") - uid = uuid.uuid4().hex - stored_name = f"{uid}_{original_name}" - dest = FILES_DIR / stored_name - - logger.info( - "STORAGE: Uploading file original_name=%s stored_name=%s from %s", - original_name, - stored_name, - request.client.host if request.client else "unknown", - ) - - size = await _stream_save(file, dest) - - logger.info( - "STORAGE: File upload successful, size=%d bytes, path=%s", - size, - stored_name, - ) - - return { - "status": "success", - "filename": stored_name, - "original_name": original_name, - "size": int(size), - "path": f"/files/{stored_name}", - } - except HTTPException: - raise - except Exception as e: - logger.exception("STORAGE: Failed to save upload: %s", e) - raise HTTPException(status_code=500, detail="Failed to store file") - - -async def upload_base64_internal( - filename: str, - data_b64: str, - content_type: str = "application/octet-stream", - allowed_user_ids: list[int] | None = None, -) -> dict: - """Internal implementation for base64 upload. Used by both HTTP route and in-process calls.""" - allowed_user_ids = allowed_user_ids or [] - try: - if not data_b64: - raise HTTPException(status_code=400, detail="data_b64 is required") - - _ensure_dirs() - - file_data = base64.b64decode(data_b64) - original_name = _secure_filename(filename or "file") - uid = uuid.uuid4().hex - stored_name = f"{uid}_{original_name}" - dest = FILES_DIR / stored_name - dest.parent.mkdir(parents=True, exist_ok=True) - - logger.info( - "STORAGE: Uploading base64 file original_name=%s stored_name=%s size=%d bytes", - original_name, - stored_name, - len(file_data), - ) - - # Write file data - with open(dest, "wb") as f: - f.write(file_data) - - # Apply secure permissions (no execute, owner read/write only) - dest.chmod(0o600) - - # Store permission information - _store_file_permissions(stored_name, allowed_user_ids) - - logger.info( - "STORAGE: Base64 file upload successful, size=%d bytes, path=%s, allowed_users=%s", - len(file_data), - stored_name, - allowed_user_ids, - ) - - return { - "file_id": stored_name, - "filename": original_name, - "size": len(file_data), - "path": f"/uploads/files/encrypted/{stored_name}", - } - - except Exception as e: - logger.exception("STORAGE: Base64 file upload failed: %s", e) - raise HTTPException(status_code=500, detail=f"File upload failed: {str(e)}") - - -@app.post("/upload-base64", response_model=None) -async def upload_base64_file(request: Request): - """ - Upload a base64-encoded file to secure storage. - Expects JSON payload: {"filename": str, "data_b64": str, "content_type": str?, "allowed_user_ids": [int]} - """ - payload = await request.json() - return await upload_base64_internal( - filename=payload.get("filename", "file"), - data_b64=payload.get("data_b64", ""), - content_type=payload.get("content_type", "application/octet-stream"), - allowed_user_ids=payload.get("allowed_user_ids", []), - ) - - -async def init_resumable_upload_internal( - filename: str, - total_size: int, - allowed_user_ids: list[int], - chunk_size: int | None = None, -) -> dict: - """Internal implementation for in-process calls.""" - chunk_size = chunk_size if chunk_size and chunk_size > 0 else 262_144 - if total_size <= 0: - raise HTTPException(status_code=400, detail="total_size must be > 0") - if total_size > MAX_UPLOAD_SIZE: - raise HTTPException(status_code=400, detail="File exceeds maximum allowed size") - if not allowed_user_ids: - raise HTTPException(status_code=400, detail="allowed_user_ids is required") - - _ensure_dirs() - - upload_id = uuid.uuid4().hex - meta = { - "upload_id": upload_id, - "filename": _secure_filename(filename), - "total_size": total_size, - "offset": 0, - "complete": False, - "chunk_size": chunk_size, - "allowed_user_ids": allowed_user_ids, - "created_at": time.time(), - "updated_at": time.time(), - } - _write_resumable_meta(upload_id, meta) - _resumable_data_path(upload_id).write_bytes(b"") - - logger.info( - "STORAGE: Resumable init upload_id=%s filename=%s size=%s allowed=%s", - upload_id, - meta["filename"], - total_size, - allowed_user_ids, - ) - - return { - "upload_id": upload_id, - "chunk_size": chunk_size, - "offset": 0, - } - - -@app.post("/uploads/resumable/init", response_model=None) -async def init_resumable_upload(request: Request): - """ - Initialize a resumable upload session. - Expects JSON payload: - { - "filename": str, - "total_size": int, - "allowed_user_ids": [int], - "chunk_size": int? - } - """ - payload = await request.json() - filename = payload.get("filename", "file") - total_size = int(payload.get("total_size", 0)) - allowed_user_ids = [int(x) for x in payload.get("allowed_user_ids", [])] - requested_chunk_size = int(payload.get("chunk_size") or 0) - chunk_size = requested_chunk_size if requested_chunk_size > 0 else None - return await init_resumable_upload_internal( - filename=filename, - total_size=total_size, - allowed_user_ids=allowed_user_ids, - chunk_size=chunk_size, - ) - - -async def get_resumable_upload_status_internal(upload_id: str, user_id: int) -> dict: - """Internal implementation for in-process calls.""" - meta = _read_resumable_meta(upload_id) - _assert_resumable_access(meta, user_id) - return { - "upload_id": upload_id, - "filename": meta["filename"], - "total_size": int(meta["total_size"]), - "offset": int(meta["offset"]), - "complete": bool(meta["complete"]), - } - - -@app.get("/uploads/resumable/{upload_id}", response_model=None) -async def get_resumable_upload_status(upload_id: str, request: Request): - user_id_header = request.headers.get("X-User-ID") - if not user_id_header: - raise HTTPException(status_code=401, detail="Missing user authentication") - return await get_resumable_upload_status_internal(upload_id, int(user_id_header)) - - -async def upload_resumable_chunk_internal( - upload_id: str, user_id: int, offset: int, data_b64: str -) -> dict: - """Internal implementation for in-process calls.""" - meta = _read_resumable_meta(upload_id) - _assert_resumable_access(meta, user_id) - if meta.get("complete"): - raise HTTPException(status_code=409, detail="Upload already completed") - if offset < 0: - raise HTTPException(status_code=400, detail="offset must be >= 0") - if not data_b64: - raise HTTPException(status_code=400, detail="data_b64 is required") - expected_offset = int(meta.get("offset", 0)) - if offset != expected_offset: - raise HTTPException( - status_code=409, - detail=f"Offset mismatch. expected={expected_offset} got={offset}", - ) - chunk = base64.b64decode(data_b64) - new_offset = expected_offset + len(chunk) - if new_offset > int(meta["total_size"]): - raise HTTPException(status_code=400, detail="Chunk exceeds total_size") - data_path = _resumable_data_path(upload_id) - with open(data_path, "ab") as f: - f.write(chunk) - meta["offset"] = new_offset - meta["updated_at"] = time.time() - _write_resumable_meta(upload_id, meta) - return {"offset_received": new_offset} - - -@app.patch("/uploads/resumable/{upload_id}", response_model=None) -async def upload_resumable_chunk(upload_id: str, request: Request): - """ - Upload one chunk for a resumable session. - Expects JSON body: - { - "offset": int, - "data_b64": str - } - """ - user_id_header = request.headers.get("X-User-ID") - if not user_id_header: - raise HTTPException(status_code=401, detail="Missing user authentication") - payload = await request.json() - offset = int(payload.get("offset", -1)) - data_b64 = payload.get("data_b64") - return await upload_resumable_chunk_internal( - upload_id, int(user_id_header), offset, data_b64 - ) - - -async def complete_resumable_upload_internal(upload_id: str, user_id: int) -> dict: - """Internal implementation for in-process calls.""" - meta = _read_resumable_meta(upload_id) - _assert_resumable_access(meta, user_id) - if int(meta.get("offset", 0)) != int(meta.get("total_size", 0)): - raise HTTPException( - status_code=409, - detail=f"Upload incomplete. offset={meta.get('offset')} total={meta.get('total_size')}", - ) - meta["complete"] = True - meta["updated_at"] = time.time() - _write_resumable_meta(upload_id, meta) - return {"file_id": upload_id, "upload_id": upload_id} - - -@app.post("/uploads/resumable/{upload_id}/complete", response_model=None) -async def complete_resumable_upload(upload_id: str, request: Request): - user_id_header = request.headers.get("X-User-ID") - if not user_id_header: - raise HTTPException(status_code=401, detail="Missing user authentication") - return await complete_resumable_upload_internal(upload_id, int(user_id_header)) - - -async def get_resumable_upload_blob_path_internal(upload_id: str, user_id: int) -> dict: - """Return on-disk path to completed resumable ciphertext (no base64).""" - meta = _read_resumable_meta(upload_id) - _assert_resumable_access(meta, user_id) - if not meta.get("complete"): - raise HTTPException(status_code=409, detail="Upload not completed") - data_path = _resumable_data_path(upload_id) - if not data_path.exists(): - raise HTTPException(status_code=404, detail="Upload payload not found") - return { - "upload_id": upload_id, - "filename": meta["filename"], - "file_size": int(meta.get("total_size", 0)), - "encrypted_file_path": str(data_path.resolve()), - } - - -async def upload_encrypted_file_from_path_internal( - filename: str, - source_path: Path, - content_type: str = "application/octet-stream", - allowed_user_ids: list[int] | None = None, -) -> dict: - """Store a pre-encrypted file by copying from a local path (no base64).""" - allowed_user_ids = allowed_user_ids or [] - src = Path(source_path) - if not src.is_file(): - raise HTTPException(status_code=400, detail="source_path is not a file") - _ensure_dirs() - original_name = _secure_filename(filename or "file") - uid = uuid.uuid4().hex - stored_name = f"{uid}_{original_name}" - dest = FILES_DIR / stored_name - dest.parent.mkdir(parents=True, exist_ok=True) - import shutil - - shutil.copyfile(src, dest) - dest.chmod(0o600) - _store_file_permissions(stored_name, allowed_user_ids) - size = dest.stat().st_size - return { - "file_id": stored_name, - "filename": original_name, - "size": size, - "path": f"/uploads/files/encrypted/{stored_name}", - } - - -async def get_resumable_upload_data_internal(upload_id: str, user_id: int) -> dict: - """Internal implementation for in-process calls.""" - meta = _read_resumable_meta(upload_id) - _assert_resumable_access(meta, user_id) - if not meta.get("complete"): - raise HTTPException(status_code=409, detail="Upload not completed") - data_path = _resumable_data_path(upload_id) - if not data_path.exists(): - raise HTTPException(status_code=404, detail="Upload payload not found") - payload = data_path.read_bytes() - return { - "upload_id": upload_id, - "filename": meta["filename"], - "file_size": len(payload), - "encrypted_file_data_b64": base64.b64encode(payload).decode("ascii"), - } - - -@app.get("/uploads/resumable/{upload_id}/data-b64", response_model=None) -async def get_resumable_upload_data(upload_id: str, request: Request): - """ - Retrieve completed resumable upload as base64-encoded ciphertext. - """ - user_id_header = request.headers.get("X-User-ID") - if not user_id_header: - raise HTTPException(status_code=401, detail="Missing user authentication") - return await get_resumable_upload_data_internal(upload_id, int(user_id_header)) - - -async def delete_resumable_upload_internal(upload_id: str, user_id: int) -> dict: - """Internal implementation for in-process calls.""" - meta = _read_resumable_meta(upload_id) - _assert_resumable_access(meta, user_id) - try: - _resumable_meta_path(upload_id).unlink(missing_ok=True) - _resumable_data_path(upload_id).unlink(missing_ok=True) - except Exception as e: - logger.warning("STORAGE: Failed cleaning resumable session %s: %s", upload_id, e) - return {"status": "deleted", "upload_id": upload_id} - - -@app.delete("/uploads/resumable/{upload_id}", response_model=None) -async def delete_resumable_upload(upload_id: str, request: Request): - user_id_header = request.headers.get("X-User-ID") - if not user_id_header: - raise HTTPException(status_code=401, detail="Missing user authentication") - return await delete_resumable_upload_internal(upload_id, int(user_id_header)) - - -@app.get("/files/{filename}", response_model=None) -async def get_file(filename: str, request: Request): - """ - Retrieve a stored file. Requires internal auth if configured. - """ - # Validate filename - must be simple token created by upload - if not filename or "/" in filename or "\\" in filename: - logger.warning( - "STORAGE: Invalid filename requested: %s from %s", - filename, - request.client.host if request.client else "unknown", - ) - raise HTTPException(status_code=400, detail="Invalid filename") - - path = FILES_DIR / filename - if not path.exists() or not path.is_file(): - logger.warning( - "STORAGE: File not found: %s from %s", - filename, - request.client.host if request.client else "unknown", - ) - raise HTTPException(status_code=404, detail="File not found") - - logger.info( - "STORAGE: File download: %s from %s", - filename, - request.client.host if request.client else "unknown", - ) - - return FileResponse(str(path), media_type="application/octet-stream", filename=filename) - - -@app.post("/uploads/files/normal/store", response_model=None) -async def store_normal_file(request: Request, file: UploadFile = File(...)): - """Store a plain public-chat attachment at a fixed stored name.""" - stored_name = (await request.form()).get("stored_name") - if not stored_name or not str(stored_name).strip(): - raise HTTPException(status_code=400, detail="stored_name is required") - import tempfile - - with tempfile.NamedTemporaryFile(delete=False) as tmp: - data = await file.read() - tmp.write(data) - tmp_path = Path(tmp.name) - try: - return await store_normal_file_from_path_internal(str(stored_name).strip(), tmp_path) - finally: - tmp_path.unlink(missing_ok=True) - - -# File serving routes (moved from main service) -async def store_normal_file_from_path_internal(stored_name: str, source_path: Path) -> dict: - """Copy a plain public-chat attachment into FILES_NORMAL_DIR.""" - import shutil - - _ensure_dirs() - safe_name = Path(stored_name).name - if stored_name != safe_name: - raise HTTPException(status_code=400, detail="Invalid stored name") - src = Path(source_path) - if not src.is_file(): - raise HTTPException(status_code=400, detail="source_path is not a file") - dest = FILES_NORMAL_DIR / safe_name - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(src, dest) - dest.chmod(0o600) - return { - "stored_name": safe_name, - "size": int(dest.stat().st_size), - "path": f"/uploads/files/normal/{safe_name}", - } - - -def _thumb_jpeg_path(stored_name: str) -> Path: - return THUMBS_DIR / f"{Path(stored_name).stem}.jpg" - - -def _thumb_meta_path(stored_name: str) -> Path: - return THUMBS_DIR / f"{Path(stored_name).stem}.json" - - -async def store_public_thumb_internal( - stored_name: str, - jpeg_bytes: bytes, - *, - width: int, - height: int, - file_size: int, -) -> dict: - """Persist a public-chat image thumbnail next to normal attachments.""" - _ensure_dirs() - safe_name = Path(stored_name).name - if stored_name != safe_name: - raise HTTPException(status_code=400, detail="Invalid stored name") - if not jpeg_bytes: - raise HTTPException(status_code=400, detail="Empty thumbnail") - thumb_path = _thumb_jpeg_path(safe_name) - meta_path = _thumb_meta_path(safe_name) - thumb_path.write_bytes(jpeg_bytes) - thumb_path.chmod(0o600) - meta = { - "stored_name": safe_name, - "width": int(width), - "height": int(height), - "file_size": int(file_size), - "thumb_path": f"/uploads/files/thumbs/{thumb_path.name}", - } - meta_path.write_text(json.dumps(meta), encoding="utf-8") - meta_path.chmod(0o600) - return meta - - -async def store_public_image_dimensions_internal( - stored_name: str, - *, - width: int, - height: int, - file_size: int, -) -> dict: - """Persist image dimensions for large public attachments (no JPEG thumbnail).""" - _ensure_dirs() - safe_name = Path(stored_name).name - if stored_name != safe_name: - raise HTTPException(status_code=400, detail="Invalid stored name") - if width <= 0 or height <= 0: - raise HTTPException(status_code=400, detail="Invalid image dimensions") - meta_path = _thumb_meta_path(safe_name) - meta = { - "stored_name": safe_name, - "width": int(width), - "height": int(height), - "file_size": int(file_size), - "thumb_path": "", - } - meta_path.write_text(json.dumps(meta), encoding="utf-8") - meta_path.chmod(0o600) - return meta - - -def get_public_thumb_meta_internal(stored_name: str) -> dict | None: - """Load thumbnail metadata + base64 JPEG for a normal attachment basename.""" - import base64 - - _ensure_dirs() - safe_name = Path(stored_name).name - if stored_name != safe_name: - return None - thumb_path = _thumb_jpeg_path(safe_name) - meta_path = _thumb_meta_path(safe_name) - if not meta_path.is_file(): - return None - width, height, file_size = 1, 1, 0 - try: - meta = json.loads(meta_path.read_text(encoding="utf-8")) - width = int(meta.get("width") or 1) - height = int(meta.get("height") or 1) - file_size = int(meta.get("file_size") or 0) - except Exception: - pass - thumbnail_b64 = "" - if thumb_path.is_file(): - jpeg = thumb_path.read_bytes() - thumbnail_b64 = base64.b64encode(jpeg).decode("ascii") - return { - "stored_name": safe_name, - "width": width, - "height": height, - "file_size": file_size, - "thumbnail_b64": thumbnail_b64, - "thumb_path": f"/uploads/files/thumbs/{thumb_path.name}" if thumb_path.is_file() else "", - } - - -async def get_file_thumb_internal(filename: str): - """Internal: serve public-chat thumbnail JPEGs.""" - safe_name = Path(filename).name - if filename != safe_name: - raise HTTPException(status_code=400, detail="Invalid file name") - # Accept either "{stem}.jpg" or a normal attachment basename. - path = THUMBS_DIR / safe_name - if not path.exists() and not safe_name.lower().endswith(".jpg"): - path = _thumb_jpeg_path(safe_name) - if not path.exists(): - raise HTTPException(status_code=404, detail="Thumbnail not found") - return FileResponse(str(path), media_type="image/jpeg") - - -async def get_file_normal_internal(filename: str): - """Internal: serve normal (unencrypted) files. Used by proxy when in-process.""" - safe_name = Path(filename).name - if filename != safe_name: - raise HTTPException(status_code=400, detail="Invalid file name") - path = FILES_NORMAL_DIR / safe_name - if not path.exists(): - raise HTTPException(status_code=404, detail="File not found") - return FileResponse(str(path), media_type="application/octet-stream") - - -def get_normal_file_path_internal(stored_name: str) -> Path | None: - """Resolve a stored public attachment basename to its on-disk path.""" - safe_name = Path(stored_name).name - if stored_name != safe_name: - return None - path = FILES_NORMAL_DIR / safe_name - return path if path.is_file() else None - - -def read_image_dimensions_from_path(path: Path) -> list[int] | None: - try: - from ..main.public_image_dimensions import read_image_dimensions_from_path as read_dims - except ImportError: - try: - from backend.services.main.public_image_dimensions import ( - read_image_dimensions_from_path as read_dims, - ) - except ImportError: - from services.main.public_image_dimensions import ( - read_image_dimensions_from_path as read_dims, - ) - return read_dims(path) - - -@app.get("/uploads/files/normal/{filename}", response_model=None) -async def get_file_normal(filename: str): - """Serve normal (unencrypted) files.""" - return await get_file_normal_internal(filename) - - -@app.get("/uploads/files/thumbs/{filename}", response_model=None) -async def get_file_thumb(filename: str): - """Serve public-chat thumbnail JPEGs from THUMBS_DIR.""" - return await get_file_thumb_internal(filename) - - -@app.post("/uploads/files/thumbs/store", response_model=None) -async def store_public_thumb(request: Request, file: UploadFile = File(...)): - """HTTP entry for storing a public-chat thumbnail (used when not in-process).""" - form = await request.form() - stored_name = str(form.get("stored_name") or "").strip() - width = int(form.get("width") or 1) - height = int(form.get("height") or 1) - file_size = int(form.get("file_size") or 0) - jpeg_bytes = await file.read() - return await store_public_thumb_internal( - stored_name, - jpeg_bytes, - width=width, - height=height, - file_size=file_size, - ) - - -@app.post("/uploads/files/thumbs/dimensions", response_model=None) -async def store_public_image_dimensions(request: Request): - """HTTP entry for storing image dimensions without a JPEG thumbnail.""" - form = await request.form() - stored_name = str(form.get("stored_name") or "").strip() - width = int(form.get("width") or 1) - height = int(form.get("height") or 1) - file_size = int(form.get("file_size") or 0) - return await store_public_image_dimensions_internal( - stored_name, - width=width, - height=height, - file_size=file_size, - ) - - -async def get_file_encrypted_internal(filename: str, user_id: int): - """Internal: serve encrypted files with permission checking. Used by proxy when in-process.""" - safe_name = Path(filename).name - if filename != safe_name: - raise HTTPException(status_code=400, detail="Invalid file name") - path = FILES_DIR / safe_name - if not path.exists(): - raise HTTPException(status_code=404, detail="File not found") - if not _check_file_permissions(safe_name, user_id): - if user_id != 1: - raise HTTPException(403, "Access denied to this file") - return FileResponse(str(path), media_type="application/octet-stream", filename=filename) - - -@app.get("/uploads/files/encrypted/{filename}", response_model=None) -async def get_file_encrypted(filename: str, request: Request): - """Serve encrypted files with permission checking.""" - user_id_header = request.headers.get("X-User-ID") - if not user_id_header: - raise HTTPException(status_code=401, detail="Missing user authentication") - try: - user_id = int(user_id_header) - except ValueError: - raise HTTPException(status_code=401, detail="Invalid user authentication") - return await get_file_encrypted_internal(filename, user_id) - - -if __name__ == "__main__": - import uvicorn - port = int(os.getenv("PORT", "8302")) - uvicorn.run(app, host="0.0.0.0", port=port) \ No newline at end of file diff --git a/backend/services/main/__init__.py b/backend/services/main/__init__.py deleted file mode 100644 index 524fb17..0000000 --- a/backend/services/main/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Main service module \ No newline at end of file diff --git a/backend/services/main/constants.py b/backend/services/main/constants.py deleted file mode 100644 index 6f8f8c1..0000000 --- a/backend/services/main/constants.py +++ /dev/null @@ -1,24 +0,0 @@ -import os - -# Database URL from environment (Docker) or fallback to SQLite (development) -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///" + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "database.db")) -JWT_ALGORITHM = "HS256" -# 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") - -if not JWT_SECRET_KEY: - raise ValueError("JWT secret key empty") -JWT_ALGORITHM = "HS256" -# 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") - -if not JWT_SECRET_KEY: - raise ValueError("JWT secret key empty") \ No newline at end of file diff --git a/backend/services/main/db.py b/backend/services/main/db.py deleted file mode 100644 index c57fd13..0000000 --- a/backend/services/main/db.py +++ /dev/null @@ -1,154 +0,0 @@ -import os -from typing import Generator, Optional - -import time -import logging -from sqlalchemy import create_engine, event, text -from sqlalchemy.engine import Engine -from sqlalchemy.orm import sessionmaker, Session -from sqlalchemy.pool import StaticPool -from sqlalchemy.exc import OperationalError - -from .constants import DATABASE_URL - -logger = logging.getLogger(__name__) - -""" -Universal database interface that provides identical behavior for PostgreSQL and SQLite. - -Features: -- Auto-creates parent directory for SQLite files. -- Applies SQLite pragmas (foreign_keys=ON, journal_mode=WAL) for improved compatibility. -- Uses StaticPool for in-memory or file-based SQLite when appropriate. -- Exposes `engine`, `SessionLocal`, `get_db` dependency, and `POOL_CONFIG`. -""" - -# Ensure parent directory exists for SQLite file DBs -def _ensure_sqlite_parent_dir(url: str) -> None: - if not url or not url.startswith("sqlite"): - return - # strip sqlite:/// prefix - path = url.replace("sqlite:///", "", 1) - parent = os.path.dirname(path) - if parent: - os.makedirs(parent, exist_ok=True) - - -# Pool and engine configuration (tunable via env) -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, -} - - -def get_engine(database_url: Optional[str] = None) -> Engine: - """ - Create and return a SQLAlchemy Engine configured for the given database URL. - This function ensures SQLite-specific pragmas and connection args are applied. - Includes retry logic for database connection failures during startup. - """ - url = database_url or DATABASE_URL - _ensure_sqlite_parent_dir(url) - - # Retry database connection during startup (helps with Docker initialization timing) - if url.startswith("postgresql"): - max_retries = 15 - retry_delay = 2 - - for attempt in range(max_retries): - try: - logger.info(f"Attempting database connection (attempt {attempt + 1}/{max_retries})...") - # Test the connection by creating engine and trying to connect - test_engine = create_engine(url, pool_size=1, max_overflow=0, pool_timeout=5, future=True) - with test_engine.connect() as conn: - conn.execute(text("SELECT 1")) - test_engine.dispose() - logger.info("Database connection successful") - break - except OperationalError as e: - if attempt < max_retries - 1: - logger.warning(f"Database connection failed (attempt {attempt + 1}): {e}") - time.sleep(retry_delay) - else: - logger.error(f"Database connection failed after {max_retries} attempts: {e}") - raise - except Exception as e: - logger.error(f"Unexpected error during database connection: {e}") - raise - - if url.startswith("sqlite"): - # For SQLite file-based DBs, use standard pooling but set connection timeout and pragmas. - # Use StaticPool only for in-memory SQLite. - in_memory = url in ("sqlite:///:memory:", "sqlite://") - connect_args = {"check_same_thread": False, "timeout": int(os.getenv("SQLITE_BUSY_TIMEOUT", "5"))} - - if in_memory: - engine = create_engine(url, connect_args=connect_args, poolclass=StaticPool, future=True) - else: - engine = create_engine(url, connect_args=connect_args, future=True) - - # Apply pragmas on connect for SQLite (foreign keys, WAL, busy_timeout) - @event.listens_for(engine, "connect") - def _sqlite_on_connect(dbapi_conn, connection_record): - try: - cursor = dbapi_conn.cursor() - cursor.execute("PRAGMA foreign_keys = ON") - cursor.execute("PRAGMA journal_mode = WAL") - # busy_timeout in milliseconds - busy_ms = int(os.getenv("SQLITE_BUSY_TIMEOUT_MS", "5000")) - cursor.execute(f"PRAGMA busy_timeout = {busy_ms}") - cursor.close() - except Exception: - # Best-effort; do not fail engine creation if pragmas cannot be set - pass - - return engine - - # Default for Postgres / MySQL etc. - use pool sizing from env - engine_kwargs = { - "pool_size": POOL_SIZE, - "max_overflow": MAX_OVERFLOW, - "pool_recycle": POOL_RECYCLE, - "pool_timeout": POOL_TIMEOUT, - "future": True, - } - return create_engine(url, **engine_kwargs) - - -# Create global engine and session factory for convenient imports -engine = get_engine() -# Keep loaded attributes available after commit/close to avoid DetachedInstanceError -SessionLocal = sessionmaker(class_=Session, autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) - - -def init_db(create_tables: bool = False, base_metadata=None) -> None: - """ - Initialize the database. If `create_tables` is True and `base_metadata` is provided, - create all tables using the provided SQLAlchemy metadata. - """ - if create_tables: - if base_metadata is None: - raise ValueError("base_metadata is required to create tables") - base_metadata.create_all(bind=engine) - - -def get_db() -> Generator[Session, None, None]: - """ - FastAPI dependency that yields a SQLAlchemy Session and ensures proper close(). - """ - db = SessionLocal() - try: - yield db - finally: - try: - db.close() - except Exception: - pass \ No newline at end of file diff --git a/backend/services/main/deleted_user.py b/backend/services/main/deleted_user.py deleted file mode 100644 index c084b6c..0000000 --- a/backend/services/main/deleted_user.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Shared constants and helpers for deleted / suspended user API surface.""" - -from __future__ import annotations - -from datetime import datetime, timezone - -from .models import User -from .verification_service import VerificationStatus - -DELETED_LAST_SEEN = datetime(1970, 1, 1, tzinfo=timezone.utc) - - -def deleted_username_for(user_id: int) -> str: - """Placeholder username with an illegal character so it cannot be claimed.""" - return f"#deleted{user_id}" - - -def is_deleted_user(user: User) -> bool: - return bool(user.deleted) - - -def is_suspended_user(user: User) -> bool: - return bool(user.suspended) and not user.deleted - - -def is_deleted_or_suspended(user: User) -> bool: - return is_deleted_user(user) or is_suspended_user(user) - - -def apply_deleted_user_db_fields(user: User) -> None: - user.deleted = True - user.username = deleted_username_for(user.id) - user.display_name = "" - user.bio = None - user.password_hash = "" - user.profile_picture = None - user.last_seen = DELETED_LAST_SEEN - user.created_at = None - user.online = False - - -def deleted_user_api_fields(user_id: int) -> dict: - """Static API fields for deleted users. Ignores all DB columns except id.""" - return { - "username": deleted_username_for(user_id), - "display_name": "", - "profile_picture": None, - "bio": None, - "online": False, - "last_seen": DELETED_LAST_SEEN.isoformat(), - "created_at": None, - "verified": False, - "verification_status": VerificationStatus.NONE.value, - "suspended": False, - "suspension_reason": None, - "deleted": True, - } diff --git a/backend/services/main/dependencies.py b/backend/services/main/dependencies.py deleted file mode 100644 index 6895511..0000000 --- a/backend/services/main/dependencies.py +++ /dev/null @@ -1,139 +0,0 @@ -from datetime import datetime, timedelta -from fastapi import Depends, HTTPException, Request, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from sqlalchemy.orm import Session -from .utils import verify_token -from .models import User, DeviceSession -from .db import SessionLocal -import logging - -security = HTTPBearer() -logger = logging.getLogger("uvicorn.error") - -# Зависимость для получения сессии БД -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() - -# Internal dependency helper to reuse auth/session resolution -def _get_current_user( - request: Request, - credentials: HTTPAuthorizationCredentials, - db: Session, - allow_suspended: bool = False, -) -> User: - token = credentials.credentials - 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", - headers={"WWW-Authenticate": "Bearer"}, - ) - user = db.query(User).filter(User.id == payload["user_id"]).first() - if not user: - logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id")) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not found", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if user.id == 1 and user.suspended: - user.suspended = False - user.suspension_reason = None - db.commit() - db.refresh(user) - - # Validate device session from JWT - session_id = payload.get("session_id") - if not session_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid session", - headers={"WWW-Authenticate": "Bearer"}, - ) - - device_session = ( - db.query(DeviceSession) - .filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id) - .first() - ) - - if not device_session or device_session.revoked: - logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Session revoked or not found", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Check if session has been inactive for too long (sliding expiration) - from .constants import TOKEN_INACTIVITY_EXPIRE_HOURS - inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS) - if device_session.last_seen < inactivity_threshold: - # Session expired due to inactivity - revoke it - device_session.revoked = True - db.commit() - logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Session expired due to inactivity", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Touch last_seen on valid session (sliding expiration - extends token life) - device_session.last_seen = datetime.now() - db.commit() - - # Check if user is suspended - if user.suspended and not allow_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", - headers={"suspension_reason": user.suspension_reason or "No reason provided"}, - ) - - # Check if user is deleted - if user.deleted: - logger.info("get_current_user: account deleted for user_id=%s", user.id) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Account deleted", - ) - - request.state.current_user = user - request.state.session_id = session_id - - return user - - -# Dependency for all standard routes: suspended users are blocked -def get_current_user( - request: Request, - credentials: HTTPAuthorizationCredentials = Depends(security), - db: Session = Depends(get_db), -) -> User: - return _get_current_user(request, credentials, db, allow_suspended=False) - - -# Dependency for read/crypto endpoints that remain accessible for suspended users -def get_current_user_allow_suspended( - request: Request, - credentials: HTTPAuthorizationCredentials = Depends(security), - db: Session = Depends(get_db), -) -> User: - return _get_current_user(request, credentials, db, allow_suspended=True) \ No newline at end of file diff --git a/backend/services/main/generate_vapid_keys.py b/backend/services/main/generate_vapid_keys.py deleted file mode 100644 index 3fc101f..0000000 --- a/backend/services/main/generate_vapid_keys.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate VAPID keys for push notifications -Run this script to generate new VAPID keys for your application -""" - -import sys -import base64 -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.backends import default_backend - -def generate_vapid_keys(): - """Generate VAPID keys for push notifications""" - try: - private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) - public_key = private_key.public_key() - - # Convert to base64 for web push - private_key_b64 = base64.urlsafe_b64encode( - private_key.private_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - ) - ).decode('utf-8').rstrip('=') - - # Get the raw uncompressed public key point (65 bytes: 0x04 + 32 bytes x + 32 bytes y) - public_numbers = public_key.public_numbers() - x_bytes = public_numbers.x.to_bytes(32, 'big') - y_bytes = public_numbers.y.to_bytes(32, 'big') - public_key_raw = b'\x04' + x_bytes + y_bytes - - public_key_b64 = base64.urlsafe_b64encode(public_key_raw).decode('utf-8').rstrip('=') - - print(f"VAPID_PRIVATE_KEY=\"{private_key_b64}\"") - print(f"VAPID_PUBLIC_KEY=\"{public_key_b64}\"") - - return private_key_b64, public_key_b64 - except Exception as e: - print(f"Error generating VAPID keys: {e}", file=sys.stderr) - return None, None - -if __name__ == "__main__": - generate_vapid_keys() diff --git a/backend/services/main/key_lifecycle.py b/backend/services/main/key_lifecycle.py deleted file mode 100644 index 830a1f9..0000000 --- a/backend/services/main/key_lifecycle.py +++ /dev/null @@ -1,288 +0,0 @@ -""" -Key lifecycle: time-based removal of compliance MEK, soft-deleted DM keys, and edit history. - -Uses MESSAGE_RETENTION_DAYS from the environment (see services.shared.message_retention). -""" - -import logging -from datetime import datetime -from sqlalchemy import or_ -from sqlalchemy.orm import Session - -from .models import DMEnvelope, DMReaction, DMFile, MessageEditHistory, DMEditHistory - -logger = logging.getLogger("uvicorn.error") - - -def _is_empty_wrapped_key(value: str | None) -> bool: - return value in (None, "") - - -def _is_decryptable_keyless(dm_envelope: DMEnvelope) -> bool: - return ( - _is_empty_wrapped_key(dm_envelope.sender_wrapped_mek_b64) - and _is_empty_wrapped_key(dm_envelope.recipient_wrapped_mek_b64) - and _is_empty_wrapped_key(dm_envelope.compliance_wrapped_mek_b64) - ) - - -def _delete_dm_envelopes_and_related(db: Session, envelope_ids: list[int]) -> int: - if not envelope_ids: - return 0 - - unique_ids = list(dict.fromkeys(envelope_ids)) - deleted_reactions = db.query(DMReaction).filter( - DMReaction.dm_envelope_id.in_(unique_ids) - ).delete(synchronize_session=False) - deleted_files = db.query(DMFile).filter(DMFile.message_id.in_(unique_ids)).delete(synchronize_session=False) - deleted_dm_edits = db.query(DMEditHistory).filter( - or_( - DMEditHistory.message_id.in_(unique_ids), - DMEditHistory.dm_envelope_id.in_(unique_ids), - ) - ).delete(synchronize_session=False) - deleted_messages = db.query(DMEnvelope).filter( - DMEnvelope.id.in_(unique_ids) - ).delete(synchronize_session=False) - - logger.info( - "Purging %s keyless DM envelopes. reactions=%s files=%s edit_history_rows=%s", - deleted_messages, - deleted_reactions, - deleted_files, - deleted_dm_edits, - ) - - return deleted_messages - - -def _retention_timedelta_or_skip(): - try: - from services.shared.message_retention import get_message_retention - except ImportError: - from backend.services.shared.message_retention import get_message_retention # type: ignore - r = get_message_retention() - if not r.cleanup_enabled(): - return None - return r.retention_timedelta() - - -def destroy_compliance_keys_for_message(db: Session, message_id: int) -> int: - try: - envelopes = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).all() - - destroyed_count = 0 - for envelope in envelopes: - if envelope.compliance_wrapped_mek_b64: - envelope.compliance_wrapped_mek_b64 = None - destroyed_count += 1 - - if destroyed_count > 0: - db.commit() - logger.info( - "Destroyed compliance keys for %s DM envelopes (message_id=%s)", - destroyed_count, - message_id, - ) - - return destroyed_count - - except Exception as e: - logger.error("Failed to destroy compliance keys for message %s: %s", message_id, e) - db.rollback() - return 0 - - -def destroy_compliance_keys_for_dm_envelope(db: Session, dm_envelope_id: int) -> bool: - try: - envelope = db.query(DMEnvelope).filter(DMEnvelope.id == dm_envelope_id).first() - if envelope and envelope.compliance_wrapped_mek_b64: - envelope.compliance_wrapped_mek_b64 = None - db.commit() - logger.info("Destroyed compliance key for DM envelope %s", dm_envelope_id) - return True - return False - - except Exception as e: - logger.error("Failed to destroy compliance key for DM envelope %s: %s", dm_envelope_id, e) - db.rollback() - return False - - -def destroy_message_keys_for_user(db: Session, user_id: int, *, commit: bool = True) -> int: - try: - envelopes = db.query(DMEnvelope).filter( - (DMEnvelope.sender_id == user_id) | (DMEnvelope.recipient_id == user_id) - ).all() - - destroyed_count = 0 - for envelope in envelopes: - if envelope.sender_id == user_id and envelope.sender_wrapped_mek_b64 not in (None, ""): - envelope.sender_wrapped_mek_b64 = "" - destroyed_count += 1 - if envelope.recipient_id == user_id and envelope.recipient_wrapped_mek_b64 not in (None, ""): - envelope.recipient_wrapped_mek_b64 = "" - destroyed_count += 1 - - if destroyed_count > 0 and commit: - db.commit() - logger.info( - "Destroyed sender/recipient keys that belonged to user %s in %s DM envelopes (%s keys)", - len(envelopes), - destroyed_count, - user_id, - ) - - return destroyed_count - - except Exception as e: - logger.error("Failed to destroy sender/recipient keys for user %s: %s", user_id, e) - db.rollback() - return 0 - - -def cleanup_expired_compliance_keys(db: Session) -> int: - delta = _retention_timedelta_or_skip() - if delta is None: - return 0 - - try: - cutoff_date = datetime.now() - delta - - expired_envelopes = db.query(DMEnvelope).filter( - DMEnvelope.timestamp < cutoff_date, - DMEnvelope.compliance_wrapped_mek_b64.isnot(None), - ).all() - - destroyed_count = 0 - for envelope in expired_envelopes: - envelope.compliance_wrapped_mek_b64 = None - destroyed_count += 1 - - if destroyed_count > 0: - db.commit() - logger.info("Cleaned up %s expired compliance MEK fields", destroyed_count) - - return destroyed_count - - except Exception as e: - logger.error("Failed to cleanup expired compliance keys: %s", e) - db.rollback() - return 0 - - -def cleanup_expired_message_keys(db: Session) -> int: - delta = _retention_timedelta_or_skip() - if delta is None: - return 0 - - try: - cutoff_date = datetime.now() - delta - - expired_messages = db.query(DMEnvelope).filter( - DMEnvelope.deleted_at.is_not(None), - DMEnvelope.deleted_at < cutoff_date, - ).all() - - if not expired_messages: - return 0 - - keys_destroyed = 0 - keyless_message_ids: list[int] = [] - - for message in expired_messages: - if not _is_empty_wrapped_key(message.sender_wrapped_mek_b64): - message.sender_wrapped_mek_b64 = "" - keys_destroyed += 1 - if not _is_empty_wrapped_key(message.recipient_wrapped_mek_b64): - message.recipient_wrapped_mek_b64 = "" - keys_destroyed += 1 - - logger.debug( - "Destroyed keys for soft-deleted message id=%s (deleted %s)", - message.id, - message.deleted_at.isoformat(), - ) - - if _is_decryptable_keyless(message): - keyless_message_ids.append(message.id) - - if keyless_message_ids: - deleted_messages = _delete_dm_envelopes_and_related(db, keyless_message_ids) - else: - deleted_messages = 0 - - db.commit() - logger.info( - "Message key cleanup: destroyed %s keys across %s messages; purged %s keyless messages", - keys_destroyed, - len(expired_messages), - deleted_messages, - ) - - return keys_destroyed - - except Exception as e: - logger.error("Failed to cleanup expired message keys: %s", e) - db.rollback() - return 0 - - -def cleanup_expired_edit_history(db: Session) -> int: - delta = _retention_timedelta_or_skip() - if delta is None: - return 0 - - try: - cutoff_date = datetime.now() - delta - - public_deleted = db.query(MessageEditHistory).filter( - MessageEditHistory.edited_at < cutoff_date - ).delete(synchronize_session=False) - - dm_deleted = db.query(DMEditHistory).filter( - DMEditHistory.edited_at < cutoff_date - ).delete(synchronize_session=False) - - total_deleted = public_deleted + dm_deleted - - if total_deleted > 0: - db.commit() - logger.info("Cleaned up %s expired edit history entries", total_deleted) - - return total_deleted - - except Exception as e: - logger.error("Failed to cleanup expired edit history: %s", e) - db.rollback() - return 0 - - -def run_key_lifecycle_cleanup(db: Session) -> dict: - stats = { - "compliance_keys_destroyed": cleanup_expired_compliance_keys(db), - "message_keys_destroyed": cleanup_expired_message_keys(db), - "edit_history_entries_removed": cleanup_expired_edit_history(db), - "timestamp": datetime.now().isoformat(), - } - - if ( - stats["compliance_keys_destroyed"] - or stats["message_keys_destroyed"] - or stats["edit_history_entries_removed"] - ): - logger.info("Key lifecycle cleanup completed: %s", stats) - return stats - - -def get_key_lifecycle_config() -> dict: - try: - from services.shared.message_retention import get_message_retention - except ImportError: - from backend.services.shared.message_retention import get_message_retention # type: ignore - r = get_message_retention() - return { - "message_retention_days": r.days, - "cleanup_enabled": r.cleanup_enabled(), - "never_store_compliance_mek": r.never_store_compliance_mek(), - } diff --git a/backend/services/main/key_lifecycle_task.py b/backend/services/main/key_lifecycle_task.py deleted file mode 100644 index 2f219b8..0000000 --- a/backend/services/main/key_lifecycle_task.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Periodic key lifecycle cleanup (compliance MEK, deleted-message keys, edit history). -Poll interval is derived from MESSAGE_RETENTION_DAYS (no separate env var). -""" - -import asyncio -import logging - -from .db import SessionLocal -from .key_lifecycle import run_key_lifecycle_cleanup - -logger = logging.getLogger("uvicorn.error") - - -def key_lifecycle_poll_seconds() -> int | None: - try: - from services.shared.message_retention import get_message_retention - except ImportError: - from backend.services.shared.message_retention import get_message_retention # type: ignore - r = get_message_retention() - if not r.cleanup_enabled(): - return None - sec = r.retention_timedelta().total_seconds() - # Bound poll: responsive after cutoff without hammering the DB - return max(15, min(3600, max(1, int(sec / 1000)))) - - -async def start_key_lifecycle_cleanup_task(interval_seconds: int) -> None: - while True: - try: - with SessionLocal() as db: - run_key_lifecycle_cleanup(db) - except asyncio.CancelledError: - break - except Exception as e: - logger.error("Error in key lifecycle cleanup task: %s", e) - try: - await asyncio.sleep(60) - except asyncio.CancelledError: - break - continue - try: - await asyncio.sleep(interval_seconds) - except asyncio.CancelledError: - break diff --git a/backend/services/main/logging_config.py b/backend/services/main/logging_config.py deleted file mode 100644 index 65922ce..0000000 --- a/backend/services/main/logging_config.py +++ /dev/null @@ -1,89 +0,0 @@ -import logging -import os -from datetime import datetime -from logging.handlers import RotatingFileHandler -from pathlib import Path -from threading import RLock -from typing import Dict - -LOGS_DIR = Path(__file__).resolve().parent / "logs" -LOGS_DIR.mkdir(parents=True, exist_ok=True) - - -class HumanReadableFileHandler(RotatingFileHandler): - def __init__(self, filename: Path, level: int) -> None: - super().__init__(filename, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", delay=True) - self.level = level - self._lock = RLock() - self._last_date: str | None = None - self._previous_entry: str | None = None - - def emit(self, record: logging.LogRecord) -> None: - try: - message = record.getMessage().strip() - if not message: - return - - timestamp = datetime.fromtimestamp(record.created) - date_str = timestamp.strftime("%d.%m.%Y") - time_str = timestamp.strftime("%H:%M:%S") - lines = [line.rstrip() for line in message.splitlines() if line.strip()] - - with self._lock: - if self.stream is None: - self.stream = self._open() - - if self._last_date != date_str: - if self._last_date is not None: - self.stream.write("\n") - separator = "-" * 11 - self.stream.write(f"\n\n{separator}\n{date_str}\n{separator}\n\n") - self._last_date = date_str - - entry_lines: list[str] = [] - if lines: - entry_lines.append(f"{time_str} {lines[0]}") - for line in lines[1:]: - if line.startswith("|"): - entry_lines.append(f" {line}") - else: - entry_lines.append(f" ↳ {line}") - else: - entry_lines.append(time_str) - entry_text = "\n".join(entry_lines) - if entry_text == self._previous_entry: - return - self.stream.write(entry_text + "\n") - self._previous_entry = entry_text - self.flush() - except Exception: - self.handleError(record) - - -_HANDLED_FILES: Dict[str, Path] = {} - - -def _configure_logger(name: str, filename: str, level: int = logging.INFO) -> logging.Logger: - logger = logging.getLogger(name) - target_path = LOGS_DIR / filename - - if _HANDLED_FILES.get(name) == target_path: - return logger - - logger.handlers.clear() - - handler = HumanReadableFileHandler(target_path, level) - handler.setLevel(level) - logger.addHandler(handler) - logger.setLevel(level) - logger.propagate = False - - _HANDLED_FILES[name] = target_path - return logger - - -security_logger = _configure_logger("security", "security.log") -public_chat_logger = _configure_logger("public_chat", "public-chat.log") -dm_logger = _configure_logger("dm", "dm.log") -access_logger = _configure_logger("access", "access.log") - diff --git a/backend/services/main/main.py b/backend/services/main/main.py deleted file mode 100644 index 573af5c..0000000 --- a/backend/services/main/main.py +++ /dev/null @@ -1,349 +0,0 @@ -import asyncio -import time -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -import subprocess -import sys -import os -import logging -from sqlalchemy.orm.exc import DetachedInstanceError - -# Import from same directory -from .routes import account, messaging, profile, public_chat, push, webrtc, devices, moderation, download, keys, envelope_messaging, livekit, static as static_routes -from .routes.account import get_server_instance_id -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 -from .security.audit import log_access -from .security.rate_limit import limiter -from slowapi.middleware import SlowAPIMiddleware - -logger = logging.getLogger("uvicorn.error") - -def _running_in_docker() -> bool: - """ - Detect whether the process is running inside a Docker container. - Uses presence of /.dockerenv or checking cgroup entries for docker/kubernetes. - """ - try: - if os.path.exists("/.dockerenv"): - return True - # Check cgroup for docker/kubepods indicators - cgroup_path = "/proc/1/cgroup" - if os.path.exists(cgroup_path): - with open(cgroup_path, "rt", encoding="utf-8") as f: - data = f.read() - if "docker" in data or "kubepods" in data or "containerd" in data: - return True - except Exception: - pass - return False - - -@asynccontextmanager -async def lifespan(app: FastAPI): - cleanup_task = None - key_lifecycle_task = None - - # Startup - run migration in subprocess to avoid logging interference - try: - logger.info("Starting database migration check...") - # Run migration in a separate process - result = subprocess.run( - [ - sys.executable, - "-c", - "import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()" - ], - cwd=os.path.dirname(os.path.abspath(__file__)), - capture_output=True, - text=True, - timeout=60 - ) - if result.returncode != 0: - logger.error(f"Migration subprocess failed with code {result.returncode}") - if result.stdout: - logger.error(f"Migration stdout: {result.stdout}") - if result.stderr: - logger.error(f"Migration stderr: {result.stderr}") - else: - logger.info("Database migrations completed successfully") - 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.id == 1).first() - if owner and not owner.verified: - owner.verified = True - db.commit() - logger.info(f"Owner user '{OWNER_USERNAME}' has been verified") - elif owner and owner.verified: - 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: - # Use absolute import to avoid import errors when package context differs - from services.main.routes.messaging import messagingManager - messagingManager.start_cleanup_task() - logger.info("Messaging cleanup task started") - 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 - - try: - from .key_lifecycle_task import key_lifecycle_poll_seconds, start_key_lifecycle_cleanup_task - _poll = key_lifecycle_poll_seconds() - if _poll is not None: - key_lifecycle_task = asyncio.create_task(start_key_lifecycle_cleanup_task(_poll)) - logger.info("Key lifecycle cleanup task started (interval=%ss)", _poll) - else: - key_lifecycle_task = None - logger.info("Key lifecycle cleanup disabled (MESSAGE_RETENTION_DAYS is 0 or -1)") - except Exception as e: - logger.error("Failed to start key lifecycle cleanup task: %s", e) - key_lifecycle_task = None - - yield - - # Shutdown - cancel cleanup task if it exists - if cleanup_task: - cleanup_task.cancel() - try: - await cleanup_task - except asyncio.CancelledError: - pass - - if key_lifecycle_task: - key_lifecycle_task.cancel() - try: - await key_lifecycle_task - except asyncio.CancelledError: - pass - -INSTANCE_ID_HEADER = "X-FromChat-Instance-Id" - -# Initialize FastAPI -app = FastAPI(title="FromChat", lifespan=lifespan) - - -@app.middleware("http") -async def server_instance_id_middleware(request: Request, call_next): - response = await call_next(request) - response.headers[INSTANCE_ID_HEADER] = get_server_instance_id() - return response - -# Add rate limiting middleware -app.state.limiter = limiter -app.add_middleware(SlowAPIMiddleware) - -# In development (not running inside Docker), mount messaging and file_storage apps directly -if not _running_in_docker(): - try: - # Import sub-apps from the services package and mount them to the main app - # Try absolute import first, fall back to relative import - try: - from backend.services.messaging import main as messaging_service_module - from backend.services.file_storage import main as file_storage_service_module - except (ImportError, ModuleNotFoundError): - # Fall back to relative imports when backend is not in path - import sys - import os - current_dir = os.path.dirname(os.path.abspath(__file__)) - services_dir = os.path.dirname(current_dir) - backend_dir = os.path.dirname(services_dir) - sys.path.insert(0, backend_dir) - from services.messaging import main as messaging_service_module - from services.file_storage import main as file_storage_service_module - - # Mount as sub-applications so their routes are available in-process for development - app.mount("/internal/messaging", messaging_service_module.app) - app.mount("/internal/file_storage", file_storage_service_module.app) - logger.info("Mounted messaging and file_storage services in development mode") - except Exception as e: - import traceback - logger.warning("Failed to mount internal services for development: %s\n%s", e, traceback.format_exc()) - - -def _get_username_for_log(user) -> str | None: - """ - Safely extract username for access logs. - - If the ORM instance is detached, we transparently open a short-lived session, - reload the user by ID and read the username from that fresh instance. - Logging must never break request handling. - """ - if user is None: - return None - - # Fast path: instance is still bound to a session. - try: - return getattr(user, "username", None) - except DetachedInstanceError: - # Session is gone; try to reload user by primary key. - try: - user_id = getattr(user, "id", None) - except Exception: - user_id = None - - if not user_id: - return None - - try: - with SessionLocal() as db: - fresh = db.query(User).filter(User.id == user_id).first() - return getattr(fresh, "username", None) if fresh is not None else None - except Exception: - return None - except Exception: - # Fall back to no user information if anything else goes wrong. - return None - - -@app.middleware("http") -async def access_logging_middleware(request: Request, call_next): - # Log incoming request and Authorization header presence for debugging auth issues - # Skip logging for health check requests - if request.url.path != "/health": - try: - auth_header = request.headers.get("authorization") - if auth_header: - short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header - logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short) - else: - logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path) - except Exception: - pass - start = time.perf_counter() - try: - response = await call_next(request) - except Exception as exc: - duration = time.perf_counter() - start - user = getattr(getattr(request, "state", None), "current_user", None) - log_access( - "http_error", - method=request.method, - path=request.url.path, - status="error", - user=_get_username_for_log(user), - ip=get_client_ip(request), - duration=f"{duration:.3f}s", - error=str(exc), - ) - raise - else: - duration = time.perf_counter() - start - user = getattr(getattr(request, "state", None), "current_user", None) - log_access( - "http_request", - method=request.method, - path=request.url.path, - status=response.status_code, - user=_get_username_for_log(user), - ip=get_client_ip(request), - duration=f"{duration:.3f}s", - ) - return response - - -# Add security middleware (request size limiting and audit logging) -try: - from services.shared.middleware import add_security_middleware -except ImportError: - try: - from backend.services.shared.middleware import add_security_middleware - except ImportError: - add_security_middleware = None - -if add_security_middleware: - add_security_middleware(app) - -# CORS -_lan_ip = os.getenv("LAN_IP", "").strip() -_cors_origins = [ - "https://fromchat.ru", - "https://beta.fromchat.ru", - "https://www.fromchat.ru", - "http://127.0.0.1:8301", - "http://127.0.0.1:8300", - "http://localhost:8301", - "http://localhost:8300", -] -if _lan_ip: - _cors_origins.extend( - [ - f"http://{_lan_ip}:8301", - f"http://{_lan_ip}:8300", - ] - ) - -app.add_middleware( - CORSMiddleware, - allow_origins=_cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - expose_headers=["*", INSTANCE_ID_HEADER], -) - -# Routes -app.include_router(account.router) -app.include_router(envelope_messaging.router) -app.include_router(messaging.router) -app.include_router(public_chat.router) -app.include_router(profile.router) -app.include_router(push.router, prefix="/push") -app.include_router(webrtc.router, prefix="/webrtc") -app.include_router(livekit.router, prefix="/livekit") -app.include_router(devices.router, prefix="/devices") -app.include_router(moderation.router) -app.include_router(download.router) -app.include_router(keys.router) -app.include_router(static_routes.router) - - -@app.get("/health") -async def health_check(): - """Health check endpoint for Docker health checks.""" - return {"status": "healthy", "service": "main"} - - -if __name__ == "__main__": - import uvicorn - port = int(os.getenv("PORT", "8300")) - uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/backend/services/main/migration.py b/backend/services/main/migration.py deleted file mode 100644 index 030a56e..0000000 --- a/backend/services/main/migration.py +++ /dev/null @@ -1,731 +0,0 @@ -""" -Database migration utility using Alembic. -This module handles running database migrations on startup. -""" -import os -import time -import logging -from alembic import command -from alembic.config import Config -from alembic.runtime.migration import MigrationContext -from sqlalchemy import create_engine, text -from sqlalchemy.exc import OperationalError -import importlib.util -current_dir = os.path.dirname(os.path.abspath(__file__)) -constants_path = os.path.join(current_dir, "constants.py") -spec = importlib.util.spec_from_file_location("services_main_constants", constants_path) -constants_mod = importlib.util.module_from_spec(spec) -spec.loader.exec_module(constants_mod) -DATABASE_URL = getattr(constants_mod, "DATABASE_URL") -# Backend root (two levels up from this file): backend/ -backend_root = os.path.dirname(os.path.dirname(current_dir)) - - -def _create_engine_with_retry(database_url: str = None, max_retries: int = 10, retry_delay: float = 2.0): - """Create a database engine with retry logic for connection failures during startup.""" - url = database_url or DATABASE_URL - - for attempt in range(max_retries): - try: - engine = create_engine(url) - # Test the connection - with engine.connect() as conn: - conn.execute(text("SELECT 1")) - return engine - except (OperationalError, Exception) as e: - if attempt < max_retries - 1: - logger.warning(f"Database connection failed (attempt {attempt + 1}/{max_retries}): {e}") - time.sleep(retry_delay) - else: - logger.error(f"Database connection failed after {max_retries} attempts: {e}") - raise - - -def _load_module_by_filename(filename: str, module_name: str): - """Load a module from a file path relative to this migration.py""" - path = os.path.join(current_dir, filename) - spec = importlib.util.spec_from_file_location(module_name, path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -def _load_models_base(): - """Return the SQLAlchemy Base from models.py regardless of import context""" - mod = _load_module_by_filename("models.py", "services_main_models") - return getattr(mod, "Base") - - -def _ensure_sqlite_directory(): - """Ensure parent directory for SQLite DB exists when using sqlite:/// URLs.""" - if not DATABASE_URL or not DATABASE_URL.startswith("sqlite"): - return - # strip sqlite:/// prefix - db_path = DATABASE_URL.replace("sqlite:///", "", 1) - parent = os.path.dirname(db_path) - if parent: - os.makedirs(parent, exist_ok=True) -import logging - -logger = logging.getLogger(__name__) - - -def _ensure_all_model_tables(): - """Create any model tables that do not exist (e.g. dm_edit_history added after migrations).""" - engine = _create_engine_with_retry() - Base = _load_models_base() - Base.metadata.create_all(bind=engine) - logger.info("Ensured all model tables exist.") - - -def run_migrations(): - """ - Run database migrations using Alembic. - This function will upgrade the database to the latest migration. - Fully automated - handles all scenarios automatically. - """ - try: - # FIRST: Ensure SQLite directory exists before creating engine - _ensure_sqlite_directory() - - # Check if database has any application tables (excluding alembic_version) - engine = _create_engine_with_retry() - 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...") - Base = _load_models_base() - Base.metadata.create_all(bind=engine) - logger.info("All tables created successfully from models.") - - # Get the directory where this script is located and backend root - current_dir = os.path.dirname(os.path.abspath(__file__)) - backend_root = os.path.dirname(os.path.dirname(current_dir)) - - # Create Alembic configuration (alembic files are stored at backend/alembic) - alembic_cfg = Config(os.path.join(backend_root, "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 (use absolute path) - alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL) - # Ensure script_location is set (some alembic.ini files may omit it when running in subprocess) - try: - script_location = alembic_cfg.get_main_option("script_location") - except Exception: - script_location = None - if not script_location: - alembic_cfg.set_main_option("script_location", os.path.join(backend_root, "alembic")) - - # Check if any migration files exist (use backend/alembic/versions) - versions_dir = os.path.join(backend_root, "alembic", "versions") - - if not os.path.exists(versions_dir): - os.makedirs(versions_dir) - - migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] - - if not migration_files: - logger.info("No migration files found. Creating initial migration...") - # Check if database exists and has tables - engine = _create_engine_with_retry() - with engine.connect() as connection: - from sqlalchemy import text - # Check for PostgreSQL or SQLite - if 'postgresql' in DATABASE_URL.lower(): - result = connection.execute(text(""" - SELECT tablename as name FROM pg_tables - WHERE schemaname = 'public' AND tablename != 'alembic_version' - """)) - else: - result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'")) - existing_tables = result.fetchall() - - if existing_tables: - logger.info("Found existing database with tables. Creating migration to match current schema...") - # Create migration with autogenerate to detect differences - command.revision(alembic_cfg, autogenerate=True, message="Initial migration from existing database") - - # Check if the generated migration is empty (common with existing databases) - versions_dir = os.path.join(backend_root, "alembic", "versions") - migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] - if migration_files: - latest_migration = max(migration_files) - migration_path = os.path.join(versions_dir, latest_migration) - - # Check if migration is empty - with open(migration_path, 'r') as f: - content = f.read() - if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content: - logger.info("Generated migration is empty. Creating complete schema migration...") - # Remove the empty migration - os.remove(migration_path) - # Create a complete migration - _create_complete_migration(alembic_cfg) - else: - logger.info("No existing tables found. Creating fresh migration...") - # Create fresh migration - command.revision(alembic_cfg, autogenerate=True, message="Initial migration") - logger.info("Initial migration created successfully.") - else: - # Migration files exist, check if we need to create a new migration for schema changes - logger.info("Migration files exist. Checking for pending schema changes...") - try: - # Create a new migration to detect any schema changes - command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration for schema changes") - - # Check if the new migration is empty (no changes detected) - migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] - if migration_files: - latest_migration = max(migration_files) - migration_path = os.path.join(versions_dir, latest_migration) - - # Check if migration is empty - with open(migration_path, 'r') as f: - content = f.read() - if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content and 'op.drop_table' not in content and 'op.drop_column' not in content: - logger.info("No schema changes detected. Removing empty migration...") - # Remove the empty migration - os.remove(migration_path) - else: - logger.info("Schema changes detected. New migration created.") - - except Exception as e: - logger.info(f"No new migrations needed or error creating migration: {e}") - pass - - # Check if database is in an inconsistent state (has alembic_version but no tables) - engine = create_engine(DATABASE_URL) - with engine.connect() as connection: - from sqlalchemy import text, inspect - inspector = inspect(connection) - existing_tables = inspector.get_table_names() - - # Check if we have alembic_version but no actual tables - if 'alembic_version' in existing_tables and len(existing_tables) == 1: - logger.info("Database has alembic_version but no actual tables - resetting migration state...") - # Clear alembic_version and start fresh - connection.execute(text("DELETE FROM alembic_version")) - connection.commit() - logger.info("Reset migration state - will create fresh migration") - - # Run the upgrade command - logger.info("Running database migrations...") - try: - command.upgrade(alembic_cfg, "head") - logger.info("Database migrations completed successfully.") - _ensure_all_model_tables() - except Exception as upgrade_error: - if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error): - logger.info("Found 'direct_creation' revision - resetting migration state...") - # Clear the alembic_version table and start fresh - engine = _create_engine_with_retry() - with engine.connect() as connection: - from sqlalchemy import text - connection.execute(text("DELETE FROM alembic_version")) - connection.commit() - - # Set the correct revision in alembic_version table - versions_dir = os.path.join(backend_root, "alembic", "versions") - migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] - - if migration_files: - # Get the latest migration file and extract its revision ID - latest_migration = max(migration_files) - migration_path = os.path.join(versions_dir, latest_migration) - - with open(migration_path, 'r') as f: - content = f.read() - # Extract revision ID from the file - import re - revision_match = re.search(r"revision: str = '([^']+)'", content) - if revision_match: - revision_id = revision_match.group(1) - logger.info(f"Setting alembic_version to {revision_id}") - connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')")) - connection.commit() - - # Try upgrade again - command.upgrade(alembic_cfg, "head") - logger.info("Database migrations completed successfully after reset.") - _ensure_all_model_tables() - elif "no such table" in str(upgrade_error).lower(): - logger.info("Database tables missing - resetting migration state...") - # Clear the alembic_version table and start fresh - engine = _create_engine_with_retry() - with engine.connect() as connection: - from sqlalchemy import text - connection.execute(text("DELETE FROM alembic_version")) - connection.commit() - - # Try upgrade again - command.upgrade(alembic_cfg, "head") - logger.info("Database migrations completed successfully after reset.") - _ensure_all_model_tables() - else: - raise upgrade_error - - except Exception as e: - logger.error(f"Error running database migrations: {e}") - # Fully automated recovery - handle ALL error scenarios - logger.info("Attempting automated recovery...") - try: - # Clear the alembic_version table to reset state - engine = _create_engine_with_retry() - with engine.connect() as connection: - from sqlalchemy import text - connection.execute(text("DROP TABLE IF EXISTS alembic_version")) - connection.commit() - - # Check if we have existing migration files - versions_dir = os.path.join(backend_root, "alembic", "versions") - migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] - - if migration_files: - # We have migration files, just fix the alembic_version table - logger.info("Found existing migration files, fixing alembic_version table...") - latest_migration = max(migration_files) - migration_path = os.path.join(versions_dir, latest_migration) - - with open(migration_path, 'r') as f: - content = f.read() - import re - revision_match = re.search(r"revision: str = '([^']+)'", content) - if revision_match: - revision_id = revision_match.group(1) - logger.info(f"Setting alembic_version to {revision_id}") - connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')")) - connection.commit() - - # Try upgrade again - command.upgrade(alembic_cfg, "head") - _ensure_all_model_tables() - logger.info("Automated recovery completed successfully.") - else: - # No migration files, create fresh ones - logger.info("No migration files found, creating fresh migration...") - _create_complete_migration(alembic_cfg) - - # Run the migration - command.upgrade(alembic_cfg, "head") - _ensure_all_model_tables() - logger.info("Automated recovery completed successfully.") - - except Exception as recovery_error: - logger.error(f"Automated recovery failed: {recovery_error}") - # Last resort: create database using SQLAlchemy directly - logger.info("Using fallback: creating database directly...") - _create_database_directly() - logger.info("Database created successfully using fallback method.") - - -def _create_complete_migration(alembic_cfg): - """Create a complete migration file with all database schema.""" - # Create a new migration file - command.revision(alembic_cfg, message="Complete schema migration") - - # Get the latest migration file - versions_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "alembic", "versions") - migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] - latest_migration = max(migration_files) if migration_files else None - - if latest_migration: - migration_path = os.path.join(versions_dir, latest_migration) - _populate_migration_file(migration_path) - - -def _populate_migration_file(migration_path): - """Populate a migration file with the complete database schema from models.""" - # Generate the migration content dynamically from models - migration_content = _generate_migration_from_models() - - # Read the current migration file - with open(migration_path, 'r') as f: - content = f.read() - - # Add datetime import if needed - if "datetime.now" in migration_content and "from datetime import datetime" not in content: - # Insert the import after the existing imports - import re - content = re.sub( - r'(from alembic import op\nimport sqlalchemy as sa\n)', - r'\1from datetime import datetime\n', - content - ) - - # Replace the empty upgrade/downgrade functions - import re - # More flexible regex to match the actual content - content = re.sub( - r'def upgrade\(\) -> None:.*?pass.*?(?=\n\ndef downgrade|\n\nif __name__|\Z)', - migration_content, - content, - flags=re.DOTALL - ) - - # Write the updated content back - with open(migration_path, 'w') as f: - f.write(content) - - -def _generate_migration_from_models(): - """Generate migration content dynamically from SQLAlchemy models.""" - from .models import Base - import sqlalchemy as sa - from datetime import datetime - - # Generate migration content using Alembic's op functions - upgrade_statements = [] - downgrade_statements = [] - - # Get all tables from Base metadata - for table_name, table in Base.metadata.tables.items(): - if table_name != 'alembic_version': # Skip alembic_version table - # Check if table exists and compare schema - schema_diff = _detect_schema_differences(table_name, table) - - if schema_diff['table_exists']: - if schema_diff['needs_update']: - # Generate ALTER TABLE statements for existing table - upgrade_statements.append(f" # Update {table_name} table schema") - for statement in schema_diff['alter_statements']: - upgrade_statements.append(f" {statement}") - else: - # Table exists and is up to date - skip creating it - upgrade_statements.append(f" # Table {table_name} already exists and is up to date") - else: - # Generate CREATE TABLE for new table - table_code = _generate_table_creation_code(table_name, table) - upgrade_statements.append(f" # Create {table_name} table") - upgrade_statements.append(table_code) - - # Only add to downgrade if table actually exists - if schema_diff['table_exists']: - downgrade_statements.append(f" # op.drop_table('{table_name}') # Skipped - table exists") - else: - downgrade_statements.append(f" op.drop_table('{table_name}')") - - # Combine all statements - upgrade_content = "def upgrade() -> None:\n \"\"\"Upgrade schema.\"\"\"\n" + "\n".join(upgrade_statements) - downgrade_content = "def downgrade() -> None:\n \"\"\"Downgrade schema.\"\"\"\n" + "\n".join(downgrade_statements) - - return upgrade_content + "\n\n" + downgrade_content - - -def _detect_schema_differences(table_name, expected_table): - """Detect differences between existing table and expected schema.""" - engine = create_engine(DATABASE_URL) - - with engine.connect() as connection: - from sqlalchemy import text, inspect - - # Check if table exists - inspector = inspect(connection) - if table_name not in inspector.get_table_names(): - return { - 'table_exists': False, - 'needs_update': False, - 'alter_statements': [] - } - - # Get existing columns - existing_columns = inspector.get_columns(table_name) - existing_column_names = {col['name'] for col in existing_columns} - - # Get expected columns - expected_column_names = {col.name for col in expected_table.columns} - - # Check for missing columns - missing_columns = expected_column_names - existing_column_names - extra_columns = existing_column_names - expected_column_names - - alter_statements = [] - - # Add missing columns - for column in expected_table.columns: - if column.name in missing_columns: - column_def = _generate_column_definition(column) - alter_statements.append(f"op.add_column('{table_name}', {column_def})") - - # Add missing indexes - for index in expected_table.indexes: - if not index.unique: - cols = "', '".join([col.name for col in index.columns]) - alter_statements.append(f"op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)") - - return { - 'table_exists': True, - 'needs_update': len(alter_statements) > 0, - 'alter_statements': alter_statements - } - - -def _generate_column_definition(column): - """Generate column definition for ALTER TABLE.""" - type_def = _get_column_type(column) - nullable = "nullable=True" if column.nullable else "nullable=False" - - definition = f"sa.Column('{column.name}', {type_def}, {nullable}" - - # Handle default values properly - if column.default is not None: - if hasattr(column.default, 'arg'): - # Handle callable defaults - if callable(column.default.arg): - definition += f", default=datetime.now" - else: - definition += f", default={repr(column.default.arg)}" - else: - definition += f", default={repr(column.default)}" - - definition += ")" - return definition - - -def _generate_table_creation_code(table_name, table): - """Generate op.create_table code for a SQLAlchemy table.""" - lines = [f" op.create_table('{table_name}',"] - - # Collect all table items (columns + constraints) - all_items = [] - - # Add columns - for column in table.columns: - column_def = f" sa.Column('{column.name}', {_get_column_type(column)}, nullable={column.nullable}" - if column.default is not None: - # Handle callable defaults properly - if hasattr(column.default, 'arg') and callable(column.default.arg): - column_def += f", default=datetime.now" - else: - column_def += f", default={repr(column.default)}" - column_def += ")" - all_items.append(column_def) - - # Add constraints - for constraint in table.constraints: - if hasattr(constraint, 'columns'): - if constraint.__class__.__name__ == 'PrimaryKeyConstraint': - all_items.append(f" sa.PrimaryKeyConstraint('{constraint.columns.keys()[0]}')") - elif constraint.__class__.__name__ == 'UniqueConstraint': - cols = "', '".join(constraint.columns.keys()) - all_items.append(f" sa.UniqueConstraint('{cols}')") - - # Add foreign key constraints - for fk in table.foreign_keys: - all_items.append(f" sa.ForeignKeyConstraint(['{fk.parent.name}'], ['{fk.column.table.name}.{fk.column.name}'], )") - - # Add all items with commas (except the last one) - for i, item in enumerate(all_items): - if i < len(all_items) - 1: - item += "," - lines.append(item) - - lines.append(" )") - - # Add indexes with IF NOT EXISTS equivalent using try/except - for index in table.indexes: - if not index.unique: - cols = "', '".join([col.name for col in index.columns]) - lines.append(f" # Create index for {table_name}") - lines.append(f" try:") - lines.append(f" op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)") - lines.append(f" except Exception:") - lines.append(f" pass # Index may already exist") - - return "\n".join(lines) - - -def _get_column_type(column): - """Get SQLAlchemy column type string.""" - type_name = column.type.__class__.__name__ - - if type_name == 'String': - return f"sa.String(length={column.type.length})" - elif type_name == 'Integer': - return "sa.Integer()" - elif type_name == 'Text': - return "sa.Text()" - elif type_name == 'Boolean': - return "sa.Boolean()" - elif type_name == 'DateTime': - return "sa.DateTime()" - else: - return f"sa.{type_name}()" - - -def _create_database_directly(): - """Fallback method: create database directly using SQLAlchemy.""" - # Load Base and engine in a robust way (work when run as script or package) - _ensure_sqlite_directory() - Base = _load_models_base() - from sqlalchemy import text, inspect - engine = create_engine(DATABASE_URL) - - # Check existing tables and update schema - with engine.connect() as connection: - inspector = inspect(connection) - existing_tables = inspector.get_table_names() - - # For each model table, check if it needs updates - for table_name, table in Base.metadata.tables.items(): - if table_name != 'alembic_version': - if table_name in existing_tables: - # Table exists, check for missing columns - existing_columns = {col['name'] for col in inspector.get_columns(table_name)} - expected_columns = {col.name for col in table.columns} - missing_columns = expected_columns - existing_columns - - # Add missing columns - for column in table.columns: - if column.name in missing_columns: - # Convert to raw SQL for direct execution - sql_type = _get_sql_type(column) - nullable = "NULL" if column.nullable else "NOT NULL" - - # Handle datetime columns without default (SQLite limitation) - if column.type.__class__.__name__ == 'DateTime': - # Add column without default, then update existing rows - alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}" - try: - connection.execute(text(alter_sql)) - logger.info(f"Added column {column.name} to {table_name}") - - # Update existing rows with current timestamp - update_sql = f"UPDATE {table_name} SET {column.name} = CURRENT_TIMESTAMP WHERE {column.name} IS NULL" - connection.execute(text(update_sql)) - logger.info(f"Updated {column.name} with current timestamp") - except Exception as e: - logger.error(f"Could not add column {column.name}: {e}") - else: - # Handle other column types with defaults - default_clause = "" - if column.default is not None: - if hasattr(column.default, 'arg') and callable(column.default.arg): - # Skip callable defaults for SQLite compatibility - pass - elif hasattr(column.default, 'arg'): - default_clause = f" DEFAULT {repr(column.default.arg)}" - - alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}{default_clause}" - try: - connection.execute(text(alter_sql)) - logger.info(f"Added column {column.name} to {table_name}") - except Exception as e: - logger.error(f"Could not add column {column.name}: {e}") - else: - # Table doesn't exist, create it - logger.info(f"Creating table {table_name}") - from sqlalchemy.schema import CreateTable - connection.execute(CreateTable(Base.metadata.tables[table_name])) - - # Create alembic_version table manually - connection.execute(text(""" - CREATE TABLE IF NOT EXISTS alembic_version ( - version_num VARCHAR(32) NOT NULL, - CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) - ) - """)) - - # Get the correct revision ID from existing migration files - versions_dir = os.path.join(backend_root, "alembic", "versions") - migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] - - if migration_files: - latest_migration = max(migration_files) - migration_path = os.path.join(versions_dir, latest_migration) - - with open(migration_path, 'r') as f: - content = f.read() - import re - revision_match = re.search(r"revision: str = '([^']+)'", content) - if revision_match: - revision_id = revision_match.group(1) - if 'postgresql' in DATABASE_URL.lower(): - connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING")) - else: - connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')")) - else: - if 'postgresql' in DATABASE_URL.lower(): - connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING")) - else: - connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) - else: - if 'postgresql' in DATABASE_URL.lower(): - connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING")) - else: - connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) - - connection.commit() - - -def _get_sql_type(column): - """Get SQL type for direct SQL execution.""" - from sqlalchemy import String, Integer, Text, Boolean, DateTime - import os - - # Check if we're using PostgreSQL - is_postgres = 'postgresql' in os.getenv('DATABASE_URL', '').lower() - - if isinstance(column.type, String): - if column.type.length: - return f"VARCHAR({column.type.length})" - else: - return "TEXT" - elif isinstance(column.type, Integer): - return "INTEGER" - elif isinstance(column.type, Text): - return "TEXT" - elif isinstance(column.type, Boolean): - return "BOOLEAN" - elif isinstance(column.type, DateTime): - return "TIMESTAMP" if is_postgres else "DATETIME" - else: - return "TEXT" # fallback - - -def check_migration_status(): - """ - Check if the database needs migrations. - Returns True if migrations are needed, False otherwise. - """ - try: - # Get engine for checking migration status - engine = create_engine(DATABASE_URL) - - # Check if alembic_version table exists - with engine.connect() as connection: - # Check if alembic_version table exists - from sqlalchemy import text - result = connection.execute( - text("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'") - ) - alembic_table_exists = result.fetchone() is not None - - if not alembic_table_exists: - return True - - # Get current migration context - context = MigrationContext.configure(connection) - current_rev = context.get_current_revision() - - # Get the latest revision from alembic (use backend/alembic) - alembic_cfg = Config(os.path.join(backend_root, "alembic.ini")) - script_dir = command.ScriptDirectory.from_config(alembic_cfg) - head_rev = script_dir.get_current_head() - - return current_rev != head_rev - - except Exception as e: - logger.error(f"Error checking migration status: {e}") - return True # Assume migrations are needed if we can't check - - -if __name__ == "__main__": - # This allows running migrations directly - run_migrations() diff --git a/backend/services/main/models.py b/backend/services/main/models.py deleted file mode 100644 index 2f6d3ef..0000000 --- a/backend/services/main/models.py +++ /dev/null @@ -1,421 +0,0 @@ -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text, UniqueConstraint -from sqlalchemy.orm import relationship -from datetime import datetime -from pydantic import BaseModel - -Base = declarative_base() - - -# Модели базы данных -class User(Base): - __tablename__ = "user" - - id = Column(Integer, primary_key=True, index=True) - username = Column(String(50), unique=True, nullable=False, index=True) - display_name = Column(String(64), nullable=False) - password_hash = Column(String(200), nullable=False) - profile_picture = Column(String(255), nullable=True) - bio = Column(Text, nullable=True) - online = Column(Boolean, default=False) - last_seen = Column(DateTime, default=datetime.now) - created_at = Column(DateTime, default=datetime.now) - verified = Column(Boolean, default=False) - suspended = Column(Boolean, default=False) - suspension_reason = Column(Text, nullable=True) - deleted = Column(Boolean, default=False) - messages = relationship("Message", back_populates="author", lazy="select") - - -class Message(Base): - __tablename__ = "message" - - id = Column(Integer, primary_key=True, index=True) - content = Column(Text, nullable=False) - timestamp = Column(DateTime, default=datetime.now) - user_id = Column(Integer, ForeignKey("user.id"), nullable=False) - is_read = Column(Boolean, default=False) - reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True) - is_edited = Column(Boolean, default=False) - - author = relationship("User", back_populates="messages") - reply_to = relationship("Message", remote_side=[id]) - files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select") - reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select") - - -class MessageFile(Base): - __tablename__ = "message_file" - - id = Column(Integer, primary_key=True, index=True) - message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) - path = Column(Text, nullable=False) - name = Column(Text, nullable=False) - - message = relationship("Message", back_populates="files") - - -class CryptoPublicKey(Base): - __tablename__ = "crypto_public_key" - - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True) - public_key_b64 = Column(Text, nullable=False) - - -class CryptoBackup(Base): - __tablename__ = "crypto_backup" - - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True) - blob_json = Column(Text, nullable=False) - - -class DMEnvelope(Base): - __tablename__ = "dm_envelope" - - id = Column(Integer, primary_key=True, index=True) - sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) - recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False) - iv_b64 = Column(Text, nullable=False) - ciphertext_b64 = Column(Text, nullable=False) - sender_wrapped_mek_b64 = Column(Text, nullable=False) - recipient_wrapped_mek_b64 = Column(Text, nullable=False) - compliance_wrapped_mek_b64 = Column(Text, nullable=True) - reply_to_id = Column(Integer, nullable=True) - timestamp = Column(DateTime, default=datetime.now) - is_edited = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.now) - deleted_at = Column(DateTime, nullable=True) # Soft delete timestamp - files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select") - reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select") - - -class DMFile(Base): - __tablename__ = "dm_file" - - id = Column(Integer, primary_key=True, index=True) - message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True) - sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) - recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False) - name = Column(Text, nullable=False) - path = Column(Text, nullable=False) - nonce_b64 = Column(Text, nullable=True) # Nonce for this file's decryption - - message = relationship("DMEnvelope", back_populates="files") - - -class PushSubscription(Base): - __tablename__ = "push_subscription" - - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("user.id"), nullable=False) - endpoint = Column(Text, nullable=False) - p256dh_key = Column(Text, nullable=False) - auth_key = Column(Text, nullable=False) - created_at = Column(DateTime, default=datetime.now) - 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" - - id = Column(Integer, primary_key=True, index=True) - message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) - user_id = Column(Integer, ForeignKey("user.id"), nullable=False) - emoji = Column(String(10), nullable=False) # Store emoji as string - timestamp = Column(DateTime, default=datetime.now) - - # Relationships - user = relationship("User") - - # Ensure unique combination of message, user, and emoji - __table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),) - - -class DMReaction(Base): - __tablename__ = "dm_reaction" - - id = Column(Integer, primary_key=True, index=True) - dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True) - user_id = Column(Integer, ForeignKey("user.id"), nullable=False) - emoji = Column(String(10), nullable=False) # Store emoji as string - timestamp = Column(DateTime, default=datetime.now) - - # Relationships - user = relationship("User") - dm_envelope = relationship("DMEnvelope", overlaps="reactions") - - # Ensure unique combination of dm_envelope, user, and emoji - __table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),) - - -class DmConversationPreference(Base): - """Per-user DM list preferences (archive state, read cursor).""" - - __tablename__ = "dm_conversation_preference" - - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) - other_user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) - archived = Column(Boolean, default=False, nullable=False) - last_read_envelope_id = Column(Integer, default=0, nullable=False) - - __table_args__ = ( - UniqueConstraint("user_id", "other_user_id", name="unique_dm_conversation_preference"), - ) - - -# Tracks authenticated device sessions per user -class DeviceSession(Base): - __tablename__ = "device_session" - - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) - - # Raw User-Agent for reference/debugging - raw_user_agent = Column(Text, nullable=True) - - # Parsed fields - device_name = Column(String(128), nullable=True) - device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown - os_name = Column(String(64), nullable=True) - os_version = Column(String(64), nullable=True) - browser_name = Column(String(64), nullable=True) - browser_version = Column(String(64), nullable=True) - brand = Column(String(64), nullable=True) - model = Column(String(64), nullable=True) - - # Session identity embedded into JWTs - session_id = Column(String(64), unique=True, nullable=False, index=True) - - # Lifecycle - created_at = Column(DateTime, default=datetime.now) - last_seen = Column(DateTime, default=datetime.now) - revoked = Column(Boolean, default=False) - - # Relationship back to user (optional lazy to avoid heavy loads) - user = relationship("User", lazy="select") - -# Pydantic модели -class LoginRequest(BaseModel): - username: str - password: str - - -class RegisterRequest(BaseModel): - username: str - display_name: str - password: str - confirm_password: str - bio: str | None = None - - -class ChangePasswordRequest(BaseModel): - currentPasswordDerived: str - newPasswordDerived: str - logoutAllExceptCurrent: bool = False - - -class VerifyPasswordRequest(BaseModel): - passwordDerived: str - - -class DeleteAccountRequest(BaseModel): - passwordDerived: str - - -class SendMessageRequest(BaseModel): - content: str - reply_to_id: int | None = None - client_message_id: str | None = None - uploaded_file_ids: list[str] | None = None - - -class EditMessageRequest(BaseModel): - content: str - - -class DeleteMessageRequest(BaseModel): - message_id: int - - -class MessageEditHistoryResponse(BaseModel): - """Response model for message edit history (compliance access only).""" - id: int - message_id: int - previous_content: str - edited_at: datetime - edited_by_username: str - edited_by_user_id: int - - class Config: - from_attributes = True - - -class DMEditHistoryResponse(BaseModel): - """Response model for DM edit history (compliance access only).""" - id: int - dm_envelope_id: int - previous_ciphertext_b64: str - previous_iv_b64: str - previous_compliance_wrapped_mek_b64: str - edited_at: str - edited_by_username: str - edited_by_user_id: int - - class Config: - from_attributes = True - - -class UpdateBioRequest(BaseModel): - bio: str - - -class PushSubscriptionRequest(BaseModel): - endpoint: str - keys: dict - - -class UserProfileResponse(BaseModel): - id: int - username: str - display_name: str - profile_picture: str | None - bio: str | None - online: bool - last_seen: datetime | None - created_at: datetime | None - verified: bool - verification_status: str - suspended: bool - suspension_reason: str | None - deleted: bool - - class Config: - from_attributes = True - - -class PublicChatProfileResponse(BaseModel): - id: str - title: str - bio: str | None - member_count: int - - -class MessageResponse(BaseModel): - id: int - content: str - timestamp: datetime - is_author: bool - is_read: bool - username: str - profile_picture: str | None - - class Config: - from_attributes = True - - -class ReactionRequest(BaseModel): - message_id: int - emoji: str - - -class ReactionResponse(BaseModel): - id: int - message_id: int - user_id: int - emoji: str - timestamp: datetime - username: str - - class Config: - from_attributes = True - - -class DMReactionRequest(BaseModel): - dm_envelope_id: int - emoji: str - - -class DMReactionResponse(BaseModel): - id: int - dm_envelope_id: int - user_id: int - emoji: str - timestamp: datetime - username: str - - class Config: - 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"), - ) - - -class MessageEditHistory(Base): - """Stores complete edit history for public messages in compliance storage only. - - This table maintains the full history of all edits made to public messages. - Regular users never see this data - they only see the latest version with - an edit indicator. Compliance officers can access the full history. - """ - __tablename__ = "message_edit_history" - - id = Column(Integer, primary_key=True, index=True) - message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) - previous_content = Column(Text, nullable=False) # Content before this edit - edited_at = Column(DateTime, default=datetime.now, nullable=False, index=True) - edited_by_user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) - - # Relationships - message = relationship("Message") - - -class DMEditHistory(Base): - """Stores complete edit history for DM messages in compliance storage only. - - This table maintains the full history of all edits made to DM messages. - Regular users never see this data - they only see the latest version with - an edit indicator. Compliance officers can access the full history. - """ - __tablename__ = "dm_edit_history" - - id = Column(Integer, primary_key=True, index=True) - message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True) - dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False) # Match existing DB schema - previous_ciphertext_b64 = Column(Text, nullable=False) # Encrypted content before this edit - previous_iv_b64 = Column(Text, nullable=False) # IV for previous content - previous_compliance_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for compliance before edit - edited_at = Column(DateTime, default=datetime.now, nullable=False, index=True) - edited_by = Column(Integer, ForeignKey("user.id"), nullable=False) # Match existing DB schema - edited_by_user_id = Column(Integer, ForeignKey("user.id"), nullable=False) # Match existing DB schema - - # Relationships - dm_envelope = relationship("DMEnvelope", foreign_keys=[message_id]) - - -# Tables are now created through Alembic migrations -# Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/backend/services/main/presence_service.py b/backend/services/main/presence_service.py deleted file mode 100644 index ea66023..0000000 --- a/backend/services/main/presence_service.py +++ /dev/null @@ -1,63 +0,0 @@ -"""In-memory user presence derived from WebSocket connections only.""" -from __future__ import annotations - -from datetime import datetime - -from fastapi import WebSocket - - -class PresenceService: - def __init__(self) -> None: - self._connections: dict[int, set[WebSocket]] = {} - self._last_seen: dict[int, datetime] = {} - - def register_connection(self, user_id: int, websocket: WebSocket) -> bool: - """Track a live connection. Returns True if the user became online.""" - connections = self._connections.setdefault(user_id, set()) - was_online = bool(connections) - connections.add(websocket) - return not was_online - - def unregister_connection(self, user_id: int, websocket: WebSocket) -> tuple[bool, datetime | None]: - """Remove a connection. Returns (became_offline, last_seen) when the last conn drops.""" - connections = self._connections.get(user_id) - if not connections: - return False, self._last_seen.get(user_id) - - connections.discard(websocket) - if connections: - return False, None - - del self._connections[user_id] - last_seen = datetime.now() - self._last_seen[user_id] = last_seen - return True, last_seen - - def touch(self, user_id: int) -> None: - """Refresh activity timestamp while online.""" - if self.is_online(user_id): - self._last_seen[user_id] = datetime.now() - - def is_online(self, user_id: int) -> bool: - connections = self._connections.get(user_id) - return bool(connections) - - def get_last_seen(self, user_id: int) -> datetime | None: - if self.is_online(user_id): - return self._last_seen.get(user_id) or datetime.now() - return self._last_seen.get(user_id) - - def get_presence(self, user_id: int) -> tuple[bool, datetime | None]: - online = self.is_online(user_id) - if online: - return True, self.get_last_seen(user_id) - last_seen = self._last_seen.get(user_id) - return False, last_seen - - def remove_user(self, user_id: int) -> None: - """Drop all presence state for a deleted user.""" - self._connections.pop(user_id, None) - self._last_seen.pop(user_id, None) - - -presence_service = PresenceService() diff --git a/backend/services/main/public_chat_config.py b/backend/services/main/public_chat_config.py deleted file mode 100644 index 8a0d30b..0000000 --- a/backend/services/main/public_chat_config.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Server-side metadata for the instance public chat (title, bio). -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import TypedDict - -_STATIC_PROFILE_PATH = Path(__file__).resolve().parent / "static" / "public_chat_profile.json" - - -class PublicChatStaticProfile(TypedDict): - id: str - title: str - bio: str - - -def load_public_chat_static_profile() -> PublicChatStaticProfile: - if not _STATIC_PROFILE_PATH.is_file(): - raise FileNotFoundError(f"public chat profile config missing: {_STATIC_PROFILE_PATH}") - with _STATIC_PROFILE_PATH.open("r", encoding="utf-8") as f: - data = json.load(f) - chat_id = str(data.get("id", "")).strip() - title = str(data.get("title", "")).strip() - bio = str(data.get("bio", "")).strip() - if not chat_id or not title: - raise ValueError("public chat profile config must include non-empty id and title") - return PublicChatStaticProfile(id=chat_id, title=title, bio=bio) diff --git a/backend/services/main/public_image_dimensions.py b/backend/services/main/public_image_dimensions.py deleted file mode 100644 index e57b89f..0000000 --- a/backend/services/main/public_image_dimensions.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Header-only image dimension reads for very large public-chat attachments.""" - -from __future__ import annotations - -import logging -import struct -from pathlib import Path - -logger = logging.getLogger("uvicorn.error") - -_HEADER_READ_BYTES = 4 * 1024 * 1024 -_HEADER_READ_MAX_BYTES = 16 * 1024 * 1024 -_JPEG_SOF_MARKERS = frozenset( - {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF} -) - - -def is_placeholder_dimensions(width: int, height: int) -> bool: - return width <= 1 and height <= 1 - - -def read_image_dimensions_from_path(path: Path) -> list[int] | None: - """Read pixel width/height without decoding multi-hundred-MP images.""" - try: - with path.open("rb") as handle: - header = handle.read(_HEADER_READ_BYTES) - result = read_image_dimensions_from_bytes(header, path.suffix) - if result is not None: - return result - while len(header) < _HEADER_READ_MAX_BYTES: - extra = handle.read(_HEADER_READ_BYTES) - if not extra: - break - header += extra - result = read_image_dimensions_from_bytes(header, path.suffix) - if result is not None: - return result - return None - except Exception as error: - logger.warning("PUBLIC THUMB: header read failed for %s: %s", path, error) - return None - - -def read_image_dimensions_from_bytes(data: bytes, suffix: str = "") -> list[int] | None: - if not data: - return None - ext = suffix.lower() - wh: tuple[int, int] | None = None - if data.startswith(b"\xff\xd8"): - wh = _jpeg_dimensions(data) - elif data.startswith(b"\x89PNG\r\n\x1a\n"): - wh = _png_dimensions(data) - elif data.startswith(b"GIF87a") or data.startswith(b"GIF89a"): - wh = _gif_dimensions(data) - elif data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP": - wh = _webp_dimensions(data) - elif ext in {".jpg", ".jpeg"}: - wh = _jpeg_dimensions(data) - elif ext == ".png": - wh = _png_dimensions(data) - elif ext == ".gif": - wh = _gif_dimensions(data) - elif ext == ".webp": - wh = _webp_dimensions(data) - if wh is None: - wh = _pil_dimensions_fallback(data) - if wh is None: - return None - width, height = wh - if is_placeholder_dimensions(width, height): - return None - return [width, height] - - -def _apply_exif_orientation(width: int, height: int, orientation: int) -> tuple[int, int]: - if orientation in {5, 6, 7, 8}: - return height, width - return width, height - - -def _parse_exif_orientation(exif_bytes: bytes) -> int | None: - try: - if len(exif_bytes) < 8: - return None - endian = exif_bytes[0:2] - if endian == b"II": - endianness = "<" - elif endian == b"MM": - endianness = ">" - else: - return None - ifd_offset = struct.unpack(endianness + "I", exif_bytes[4:8])[0] - if ifd_offset + 2 > len(exif_bytes): - return None - count = struct.unpack(endianness + "H", exif_bytes[ifd_offset : ifd_offset + 2])[0] - cursor = ifd_offset + 2 - for _ in range(count): - if cursor + 12 > len(exif_bytes): - break - tag, field_type, value_count = struct.unpack(endianness + "HHI", exif_bytes[cursor : cursor + 8]) - value_offset = struct.unpack(endianness + "I", exif_bytes[cursor + 8 : cursor + 12])[0] - if tag == 0x0112: - if field_type == 3 and value_count == 1: - if value_offset <= 0xFFFF: - return value_offset & 0xFFFF - if value_offset + 2 <= len(exif_bytes): - return struct.unpack(endianness + "H", exif_bytes[value_offset : value_offset + 2])[0] - cursor += 12 - except Exception: - return None - return None - - -def _jpeg_dimensions(data: bytes) -> tuple[int, int] | None: - """Read JPEG SOF dimensions and apply EXIF orientation when present. - - EXIF APP1 may appear after the SOF segment; scan the full header before returning. - """ - if len(data) < 4 or data[0:2] != b"\xff\xd8": - return None - orientation = 1 - sof_width: int | None = None - sof_height: int | None = None - index = 2 - while index + 4 < len(data): - if data[index] != 0xFF: - index += 1 - continue - while index < len(data) and data[index] == 0xFF: - index += 1 - if index >= len(data): - break - marker = data[index] - index += 1 - if marker in {0xD8, 0xD9}: - continue - if index + 2 > len(data): - break - segment_length = struct.unpack(">H", data[index : index + 2])[0] - if segment_length < 2: - break - segment_start = index + 2 - segment_end = index + segment_length - if segment_end > len(data): - break - if marker == 0xE1 and segment_end - segment_start > 8: - exif = data[segment_start:segment_end] - if exif[:6] == b"Exif\x00\x00": - parsed = _parse_exif_orientation(exif[6:]) - if parsed is not None: - orientation = parsed - if ( - sof_width is None - and marker in _JPEG_SOF_MARKERS - and segment_end - segment_start >= 7 - ): - sof_height = struct.unpack(">H", data[segment_start + 3 : segment_start + 5])[0] - sof_width = struct.unpack(">H", data[segment_start + 5 : segment_start + 7])[0] - index = segment_end - if sof_width is None or sof_height is None: - return None - return _apply_exif_orientation(sof_width, sof_height, orientation) - - -def _png_dimensions(data: bytes) -> tuple[int, int] | None: - if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": - return None - width = struct.unpack(">I", data[16:20])[0] - height = struct.unpack(">I", data[20:24])[0] - if width <= 0 or height <= 0: - return None - return width, height - - -def _gif_dimensions(data: bytes) -> tuple[int, int] | None: - if len(data) < 10: - return None - width = struct.unpack(" tuple[int, int] | None: - if len(data) < 30 or data[8:12] != b"WEBP": - return None - chunk = data[12:16] - if chunk == b"VP8 " and len(data) >= 30: - width = struct.unpack(" 0 and height > 0: - return width, height - if chunk == b"VP8L" and len(data) >= 25: - bits = struct.unpack("> 14) & 0x3FFF) + 1 - if width > 0 and height > 0: - return width, height - if chunk == b"VP8X" and len(data) >= 30: - width = 1 + (data[24] | (data[25] << 8) | (data[26] << 16)) - height = 1 + (data[27] | (data[28] << 8) | (data[29] << 16)) - if width > 1 and height > 1: - return width, height - return None - - -def _pil_dimensions_fallback(data: bytes) -> tuple[int, int] | None: - try: - from PIL import Image, ImageOps - - with Image.open(__import__("io").BytesIO(data)) as image: - image = ImageOps.exif_transpose(image) - width, height = image.size - if width <= 0 or height <= 0: - return None - return width, height - except Exception as error: - logger.warning("PUBLIC THUMB: PIL fallback failed: %s", error) - return None diff --git a/backend/services/main/push_service.py b/backend/services/main/push_service.py deleted file mode 100644 index ae69cff..0000000 --- a/backend/services/main/push_service.py +++ /dev/null @@ -1,374 +0,0 @@ -import json -import logging -import os -from pathlib import Path -from typing import List, Optional -from sqlalchemy.orm import Session -from pywebpush import webpush, WebPushException -from .models import PushSubscription, User, Message, DMEnvelope, FcmToken -import firebase_admin -from firebase_admin import credentials as firebase_credentials -from firebase_admin import messaging as firebase_messaging - -logger = logging.getLogger("uvicorn.error") - -# backend/firebase-cert.json — fixed path; Docker bind-mounts this file to /app/firebase-cert.json -_FIREBASE_CERT_PATH = Path(__file__).resolve().parents[2] / "firebase-cert.json" - - -def _load_firebase_service_account_dict(cert_path: Path) -> dict: - """Load Firebase service account JSON from ``cert_path`` (must exist).""" - cert_path = cert_path.resolve() - if not cert_path.is_file(): - raise FileNotFoundError( - f"Firebase credentials file missing or not a file: {cert_path} (expected backend/firebase-cert.json)" - ) - - with cert_path.open(encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, dict) or data.get("type") != "service_account": - raise ValueError("Firebase credentials file must be a service account JSON object") - return data - - -class PushNotificationService: - def _short_token(self, token: str) -> str: - value = (token or "").strip() - if len(value) <= 14: - return value - return f"...{value[-8:]}" - - def __init__(self): - self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY") - self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY") - # Firebase Admin is required for main (FCM); cert path is backend/firebase-cert.json. - self.firebase_initialized = False - try: - sa_dict = _load_firebase_service_account_dict(_FIREBASE_CERT_PATH) - - cred = firebase_credentials.Certificate(sa_dict) - firebase_admin.initialize_app(cred) - self.firebase_initialized = True - logger.info("Firebase Admin SDK initialized (%s)", _FIREBASE_CERT_PATH) - except Exception as e: - logger.error("Failed to initialize Firebase Admin SDK from %s: %s", _FIREBASE_CERT_PATH, e) - raise - - if (not self.vapid_public_key) or (not self.vapid_private_key): - raise ValueError("VAPID public or private key is None") - - self.vapid_claims = { - "sub": "mailto:support@fromchat.ru", - "aud": "https://fcm.googleapis.com" - } - - async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool: - """Subscribe a user to push notifications""" - try: - # Check if user already has a subscription - existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first() - - if existing_sub: - # Update existing subscription - existing_sub.endpoint = endpoint - existing_sub.p256dh_key = p256dh_key - existing_sub.auth_key = auth_key - else: - # Create new subscription - new_sub = PushSubscription( - user_id=user_id, - endpoint=endpoint, - p256dh_key=p256dh_key, - auth_key=auth_key - ) - db.add(new_sub) - - db.commit() - logger.info(f"Push subscription saved for user {user_id}") - return True - except Exception as e: - logger.error(f"Failed to save push subscription for user {user_id}: {e}") - db.rollback() - return False - - async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None): - """Send push notification for a new public chat message""" - logger.info( - "send_public_message_notification start: message_id=%s sender_id=%s exclude_user=%s", - message.id, - message.user_id, - exclude_user_id, - ) - try: - # Get all users except the sender - users = db.query(User).filter(User.id != message.user_id) - if exclude_user_id: - users = users.filter(User.id != exclude_user_id) - user_list = users.all() - logger.debug( - "send_public_message_notification targets=%s", - [user.id for user in user_list], - ) - logger.info( - "send_public_message_notification user_count=%s for message_id=%s", - len(user_list), - message.id, - ) - - for user in user_list: - # 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() - if not fcm_rows: - logger.debug("No FCM tokens for user %s for message %s", user.id, message.id) - 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 "") - logger.debug( - "send_public_message_notification: user=%s fcm_tokens=%d", - user.id, - len(fcm_rows), - ) - - if fcm_rows and self.firebase_initialized: - for fcm in fcm_rows: - try: - response = self._send_fcm_to_token( - fcm.token, - title, - body, - payload_data, - ) - logger.info( - "FCM public push sent user=%s token=%s response=%s", - user.id, - self._short_token(fcm.token), - response, - ) - except Exception as e: - logger.error( - "Failed to send FCM to user %s token %s: %s", - user.id, - self._short_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)) - if fcm_rows and not self.firebase_initialized: - logger.warning( - "Firebase SDK not initialized, skipped FCM pushes for message %s", - message.id, - ) - - subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first() - 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""" - logger.info( - "send_dm_notification start: dm_id=%s sender_id=%s recipient=%s", - dm_envelope.id, - sender.id, - dm_envelope.recipient_id, - ) - 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() - logger.debug( - "send_dm_notification: recipient=%s fcm_tokens=%d", - dm_envelope.recipient_id, - len(fcm_rows), - ) - if fcm_rows and self.firebase_initialized: - for fcm in fcm_rows: - try: - response = self._send_fcm_to_token( - fcm.token, - title, - body, - payload_data, - include_notification=False, - ) - logger.info( - "FCM dm push sent recipient=%s token=%s response=%s", - dm_envelope.recipient_id, - self._short_token(fcm.token), - response, - ) - except Exception as e: - logger.error( - "Failed to send FCM to user %s token %s: %s", - dm_envelope.recipient_id, - self._short_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)) - if fcm_rows and not self.firebase_initialized: - logger.warning( - "Firebase SDK not initialized, skipped FCM DM push for dm %s", - dm_envelope.id, - ) - - await self._send_notification_to_user( - 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}") - - async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict): - """Send a push notification to a specific user""" - try: - subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first() - if not subscription: - logger.debug("No web push subscription for user %s", user_id) - if not subscription: - return - - payload = { - "title": title, - "body": body, - "icon": icon or "about:blank", - "tag": f"message_{user_id}", - "data": data - } - - subscription_info = { - "endpoint": subscription.endpoint, - "keys": { - "p256dh": subscription.p256dh_key, - "auth": subscription.auth_key - } - } - - webpush( - subscription_info=subscription_info, - data=json.dumps(payload), - vapid_private_key=self.vapid_private_key, - vapid_claims=self.vapid_claims - ) - logger.info("WebPush sent to user=%s", user_id) - - except WebPushException as e: - logger.error(f"WebPush error for user {user_id}: {e}") - # If the subscription is invalid, remove it - if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]: - db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete() - db.commit() - 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, include_notification: bool = True): - """Send an FCM push to a single device token using Firebase Admin SDK. - - By default this sends both notification + data payloads, but callers can disable - the notification payload for custom client-side rendering. - """ - if not self.firebase_initialized: - raise RuntimeError("Firebase Admin SDK not initialized") - - try: - payload = { - "title": title, - "body": body, - **{k: str(v) for k, v in (data or {}).items()} - } - - if include_notification: - # Send notification + data payload: - # notification ensures visibility in system tray when app is background, - # data keeps app-level handling usable when app is foreground. - msg = firebase_messaging.Message( - token=token, - notification=firebase_messaging.Notification( - title=title, - body=body, - ), - data=payload, - android=firebase_messaging.AndroidConfig(priority="high"), - apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"}) - ) - else: - # Data-only push for custom client-side rendering. - msg = firebase_messaging.Message( - token=token, - data=payload, - android=firebase_messaging.AndroidConfig(priority="high"), - apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"}) - ) - resp = firebase_messaging.send(msg) - logger.debug("Firebase message queued token=%s", self._short_token(token)) - return resp - except Exception as e: - logger.error("Firebase Admin send failed for token %s: %s", self._short_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( - "Removing permanently failed FCM token for user %s: %s", - fcm_token_entry.user_id, - self._short_token(fcm_token_entry.token), - ) - db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete() - db.commit() - else: - logger.debug( - "Temporary FCM failure for token %s, keeping token: %s", - self._short_token(fcm_token_entry.token), - error_message, - ) - except Exception as e: - logger.error( - "Failed to cleanup FCM token for user %s: %s", - fcm_token_entry.user_id, - 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: - db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete() - db.commit() - logger.info(f"Push subscription removed for user {user_id}") - return True - except Exception as e: - logger.error(f"Failed to remove push subscription for user {user_id}: {e}") - db.rollback() - return False - -# Global instance -push_service = PushNotificationService() diff --git a/backend/services/main/routes/account.py b/backend/services/main/routes/account.py deleted file mode 100644 index df0848a..0000000 --- a/backend/services/main/routes/account.py +++ /dev/null @@ -1,802 +0,0 @@ -from datetime import datetime -from collections import defaultdict, deque -import logging -import time -from pathlib import Path -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status, Request -from sqlalchemy.orm import Session -from sqlalchemy import inspect, text -import uuid -import secrets -from user_agents import parse as parse_ua - -from ..constants import OWNER_USERNAME -from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db -from ..models import ( - LoginRequest, - RegisterRequest, - ChangePasswordRequest, - VerifyPasswordRequest, - DeleteAccountRequest, - User, - CryptoPublicKey, - CryptoBackup, - DeviceSession, -) -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 -from ..deleted_user import ( - apply_deleted_user_db_fields, - deleted_user_api_fields, - is_deleted_user, - is_suspended_user, -) -import os - -from ..security.audit import log_security -from ..security.profanity import contains_profanity -from ..security.rate_limit import rate_limit_per_ip -from ..key_lifecycle import destroy_message_keys_for_user -router = APIRouter() -_logger = logging.getLogger(__name__) - -_SERVER_INSTANCE_ID: str | None = None -_INSTANCE_ID_FILE = Path(__file__).resolve().parent.parent / ".fromchat_instance_id" - - -def allocate_user_id(db: Session) -> int: - """First registered user gets id 1; subsequent users get random unique ids.""" - if db.query(User).count() == 0: - return 1 - while True: - candidate = secrets.randbelow(2_147_483_646) + 2 - if db.query(User).filter(User.id == candidate).first() is None: - return candidate - - -def get_server_instance_id() -> str: - """Stable server fingerprint; UUID generated once and persisted next to the main service package.""" - global _SERVER_INSTANCE_ID - if _SERVER_INSTANCE_ID is not None: - return _SERVER_INSTANCE_ID - path = _INSTANCE_ID_FILE - try: - if path.is_file(): - text = path.read_text(encoding="utf-8").strip() - if text: - _SERVER_INSTANCE_ID = text - return _SERVER_INSTANCE_ID - except OSError as exc: - _logger.warning("Could not read instance id from %s: %s", path, exc) - iid = str(uuid.uuid4()) - try: - path.write_text(iid + "\n", encoding="utf-8") - except OSError as exc: - _logger.warning( - "Could not persist instance id to %s (%s); using in-process id only.", - path, - exc, - ) - _SERVER_INSTANCE_ID = iid - return iid - - -_FAILED_ATTEMPT_WINDOW_SECONDS = 300 -_FAILED_ATTEMPT_THRESHOLD = 5 -_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque) - - -async def _broadcast_registered_user_count_task(): - from ..db import SessionLocal - from .messaging import messagingManager - - db = SessionLocal() - try: - await messagingManager.broadcast_registered_user_count(db) - finally: - db.close() - - -def _record_failed_login(identifier: str) -> bool: - now = time.time() - attempts = _failed_login_attempts[identifier] - attempts.append(now) - - while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS: - attempts.popleft() - - return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD - - -def _reset_failed_logins(identifier: str) -> None: - _failed_login_attempts.pop(identifier, None) - -def _is_admin(user: User) -> bool: - return user.id == 1 - -def convert_user(user: User, db: Session) -> dict: - from ..presence_service import presence_service - from ..verification_service import compute_verification_status, get_verified_users_data - - if is_deleted_user(user): - return { - "id": user.id, - "admin": _is_admin(user), - **deleted_user_api_fields(user.id), - } - - online, last_seen = presence_service.get_presence(user.id) - verified_users_data = get_verified_users_data(db) - verification_status = compute_verification_status(user, verified_users_data) - effective_last_seen = last_seen or user.last_seen or user.created_at - return { - "id": user.id, - "created_at": user.created_at.isoformat(), - "last_seen": effective_last_seen.isoformat(), - "online": online, - "username": user.username, - "display_name": user.display_name, - "profile_picture": user.profile_picture, - "bio": user.bio, - "admin": _is_admin(user), - "verified": user.verified, - "verification_status": verification_status.value, - "suspended": user.suspended or False, - "suspension_reason": user.suspension_reason, - "deleted": False, - } - - -def convert_user_for_dm_conversation(user: User, db: Session) -> dict: - """Minimal user payload for DM conversation list entries.""" - from ..presence_service import presence_service - from ..verification_service import compute_verification_status, get_verified_users_data - - if is_deleted_user(user): - return { - "id": user.id, - **deleted_user_api_fields(user.id), - } - - online, last_seen = presence_service.get_presence(user.id) - verified_users_data = get_verified_users_data(db) - verification_status = compute_verification_status(user, verified_users_data) - effective_last_seen = last_seen or user.last_seen or user.created_at - payload = { - "id": user.id, - "username": user.username, - "display_name": user.display_name, - "profile_picture": user.profile_picture, - "deleted": False, - "verification_status": verification_status.value, - "online": online, - "last_seen": effective_last_seen.isoformat(), - } - if is_suspended_user(user): - payload["suspended"] = True - payload["suspension_reason"] = user.suspension_reason - return payload - - -@router.get("/instance_id") -def get_instance_id_public(): - """Public deploy fingerprint (used when the client changes server host/port).""" - return {"instance_id": get_server_instance_id()} - - -@router.get("/check_auth") -def check_auth(current_user: User = Depends(get_current_user)): - return { - "authenticated": True, - "username": current_user.username, - "admin": _is_admin(current_user) - } - - -@router.get("/check_username") -@rate_limit_per_ip("30/minute") -def check_username(request: Request, username: str, db: Session = Depends(get_db)): - u = username.strip() - if not is_valid_username(u): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Username must be 3 to 20 characters and contain only English letters, digits, hyphens, and underscores", - ) - exists = db.query(User).filter(User.username == u).first() is not None - return {"exists": exists} - - -@router.post("/login") -@rate_limit_per_ip("5/minute") -def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)): - username = login_request.username.strip() - client_ip = get_client_ip(request) - raw_ua = request.headers.get("user-agent") - import logging - logging.getLogger("uvicorn.error").info("Login attempt start for username=%s ip=%s", username, client_ip) - - user = db.query(User).filter(User.username == username).first() - logging.getLogger("uvicorn.error").info("Queried user from DB for username=%s -> %s", username, "FOUND" if user else "NOT FOUND") - - if not user or not verify_password(login_request.password.strip(), user.password_hash): - log_security( - "login_failed", - severity="warning", - username=username, - ip=client_ip, - reason="invalid_credentials", - ) - identifiers = [f"user:{username}"] - if client_ip: - identifiers.append(f"ip:{client_ip}") - - suspicious = False - for identifier in identifiers: - if _record_failed_login(identifier): - suspicious = True - - if suspicious: - total_failures = { - identifier: len(_failed_login_attempts.get(identifier, [])) - for identifier in identifiers - } - log_security( - "auth_bruteforce_detected", - severity="warning", - username=username, - ip=client_ip, - failures=total_failures, - window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS, - ) - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Too many login attempts. Try again in a few minutes.", - ) - raise HTTPException( - status_code=401, - detail="Неверное имя пользователя или пароль" - ) - - # Create device session and embed into JWT - 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( - user_id=user.id, - raw_user_agent=raw_ua, - device_name=device_name, - device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"), - os_name=(ua.os.family or None), - os_version=(ua.os.version_string or None), - browser_name=(ua.browser.family or None), - browser_version=(ua.browser.version_string or None), - brand=(ua.device.brand or None), - model=(ua.device.model or None), - session_id=session_id, - created_at=datetime.now(), - last_seen=datetime.now(), - revoked=False, - ) - db.add(device) - db.commit() - logging.getLogger("uvicorn.error").info("Login DB commit complete for user_id=%s", user.id) - - 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", - "token": token, - "user": convert_user(user, db) - } - - -@router.post("/register") -@rate_limit_per_ip("3/hour") -def register( - request: Request, - register_request: RegisterRequest, - background_tasks: BackgroundTasks, - db: Session = Depends(get_db), -): - username = register_request.username.strip() - display_name = register_request.display_name.strip() - password = register_request.password.strip() - confirm_password = register_request.confirm_password.strip() - client_ip = get_client_ip(request) - raw_ua = request.headers.get("user-agent") - - # Determine if owner already exists - owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None - - # Validate input - if not is_valid_username(username): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" - ) - if contains_profanity(username): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Имя пользователя содержит запрещённые слова" - ) - - if not is_valid_display_name(display_name): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" - ) - if contains_profanity(display_name): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Отображаемое имя содержит запрещённые слова" - ) - - if not is_valid_password(password): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Пароль должен быть от 5 до 50 символов и не содержать пробелов" - ) - - if password != confirm_password: - 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( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Это имя пользователя уже занято" - ) - - bio_text = (register_request.bio or "").strip() or None - if bio_text and len(bio_text) > 500: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Описание должно быть не длиннее 500 символов", - ) - if bio_text and contains_profanity(bio_text): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Описание содержит запрещённые слова", - ) - - hashed_password = get_password_hash(password) - - # Set verified=True for the owner (first user to register) - is_owner = not owner_exists and username == OWNER_USERNAME - - new_user = User( - id=allocate_user_id(db), - username=username, - display_name=display_name, - password_hash=hashed_password, - bio=bio_text, - verified=is_owner - ) - - db.add(new_user) - db.commit() - db.refresh(new_user) - - # Create initial device session - 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( - user_id=new_user.id, - raw_user_agent=raw_ua, - device_name=device_name, - device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"), - os_name=(ua.os.family or None), - os_version=(ua.os.version_string or None), - browser_name=(ua.browser.family or None), - browser_version=(ua.browser.version_string or None), - brand=(ua.device.brand or None), - model=(ua.device.model or None), - session_id=session_id, - created_at=datetime.now(), - last_seen=datetime.now(), - revoked=False, - ) - db.add(device) - db.commit() - - 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, - ) - - background_tasks.add_task(_broadcast_registered_user_count_task) - - return { - "status": "success", - "message": "Регистрация прошла успешно", - "token": token, - "user": convert_user(new_user, db) - } - -@router.get("/crypto/public-key") -def get_public_key(current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): - row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() - return {"publicKey": row.public_key_b64 if row else None} - - -@router.post("/crypto/public-key") -def set_public_key(payload: dict, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): - 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 - else: - row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk) - db.add(row) - db.commit() - return {"status": "ok"} - - -@router.get("/crypto/backup") -def get_backup(current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): - row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() - return {"blob": row.blob_json if row else None} - - -@router.post("/crypto/backup") -def set_backup(payload: dict, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_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 - else: - row = CryptoBackup(user_id=current_user.id, blob_json=blob) - db.add(row) - db.commit() - return {"status": "ok"} - - -@router.delete("/admin/user/{user_id}") -def delete_user_as_owner( - user_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - # Only owner can delete users - 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() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Prevent deleting the owner account via API - if _is_admin(user): - raise HTTPException(status_code=400, detail="Cannot delete owner account") - - # Manually delete user's messages to satisfy FK constraints - from models import Message # local import to avoid circular - db.query(Message).filter(Message.user_id == user.id).delete() - - 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} - -def _revoke_device_session(db: Session, user_id: int, session_id: str) -> int: - """Mark a device session revoked. Returns the number of rows updated.""" - return ( - db.query(DeviceSession) - .filter( - DeviceSession.user_id == user_id, - DeviceSession.session_id == session_id, - ) - .update({DeviceSession.revoked: True}, synchronize_session=False) - ) - - -@router.get("/logout") -def logout( - request: Request, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - session_id = getattr(request.state, "session_id", None) - if session_id: - updated = _revoke_device_session(db, current_user.id, session_id) - db.commit() - if updated == 0: - _logger.warning( - "logout: session_id=%s not found for user_id=%s", - session_id, - current_user.id, - ) - else: - _logger.warning("logout: missing session_id for user_id=%s", current_user.id) - - client_ip = get_client_ip(request) - log_security( - "logout", - username=current_user.username, - user_id=current_user.id, - ip=client_ip, - session_id=session_id, - ) - - return { - "status": "success", - "message": "Logged out successfully", - } - - -@router.post("/change-password") -@rate_limit_per_ip("5/hour") -def change_password( - request: Request, - password_request: ChangePasswordRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - # Verify current derived password against stored hash - if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash): - # 400 (not 401): mobile client treats 401 as global auth failure and clears the session. - raise HTTPException(status_code=400, detail="Текущий пароль неверный") - - # Update password hash to hash of new derived password - current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip()) - db.commit() - - # Optionally revoke all other sessions, keeping the current one - if password_request.logoutAllExceptCurrent: - current_session_id = getattr(request.state, "session_id", None) - if not current_session_id: - raise HTTPException(status_code=401, detail="Invalid session") - db.query(DeviceSession).filter( - DeviceSession.user_id == current_user.id, - DeviceSession.session_id != current_session_id, - ).update({DeviceSession.revoked: True}, synchronize_session=False) - db.commit() - - client_ip = get_client_ip(request) - log_security( - "password_changed", - username=current_user.username, - user_id=current_user.id, - ip=client_ip, - logout_others=bool(password_request.logoutAllExceptCurrent), - ) - - return {"status": "success"} - - -def _verify_derived_password(user: User, password_derived: str) -> None: - if not verify_password(password_derived.strip(), user.password_hash): - raise HTTPException(status_code=400, detail="Wrong password") - - -@router.post("/verify-password") -@rate_limit_per_ip("10/minute") -def verify_password_endpoint( - request: Request, - body: VerifyPasswordRequest, - current_user: User = Depends(get_current_user), -): - # 400 (not 401): mobile client treats 401 as global auth failure and clears the session. - _verify_derived_password(current_user, body.passwordDerived) - client_ip = get_client_ip(request) - log_security( - "password_verified", - username=current_user.username, - user_id=current_user.id, - ip=client_ip, - ) - return {"status": "success"} - - -@router.get("/users") -@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse -def list_users(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): - users = db.query(User).order_by(User.username.asc()).all() - return { - "users": [ - convert_user(u, db) for u in users if u.id != current_user.id - ] - } - - -@router.get("/crypto/public-key/of/{user_id}") -@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_allow_suspended), - 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") -@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_allow_suspended), db: Session = Depends(get_db)): - if len(q.strip()) < 2: - return {"users": []} - - # Case-insensitive partial match on username - users = db.query(User).filter( - User.username.ilike(f"%{q.strip()}%"), - User.id != current_user.id # Exclude current user - ).order_by(User.username.asc()).limit(20).all() - - return { - "users": [convert_user(u, db) for u in users] - } - - -async def _delete_user_data(user: User, db: Session): - """ - Helper function to delete user data - marks user as deleted, clears sensitive data, - deletes profile picture, removes non-whitelist user data, and sends WebSocket message. - """ - user_id = user.id - - from ..presence_service import presence_service - - apply_deleted_user_db_fields(user) - presence_service.remove_user(user_id) - - # Delete profile picture file if exists - if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"): - try: - filename = user.profile_picture.split("/")[-1] - filepath = os.path.join("data/uploads/pfp", filename) - if os.path.exists(filepath): - os.remove(filepath) - except Exception as e: - # Log error but don't fail the request - pass - - # Dynamic deletion of all non-whitelist data - WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"} - - try: - inspector = inspect(db.bind) - all_tables = inspector.get_table_names() - - for table_name in all_tables: - if table_name in WHITELIST_TABLES or table_name == "user": - continue - - # Check if table has user_id column - columns = inspector.get_columns(table_name) - has_user_id = any(col['name'] == 'user_id' for col in columns) - - if has_user_id: - # Delete all records for this user - db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id}) - - destroy_message_keys_for_user(db, user_id, commit=False) - - db.commit() - except Exception as e: - # Log error and rollback - db.rollback() - raise HTTPException(status_code=500, detail="Failed to delete user data") - - # Send WebSocket deletion message - try: - from .messaging import messagingManager - await messagingManager.send_deletion_to_user(user_id) - except Exception as e: - # Log error but don't fail the request - pass - - try: - from .profile import broadcast_profile_update - await broadcast_profile_update(user, db) - except Exception: - pass - - try: - from .messaging import messagingManager - await messagingManager.broadcast_registered_user_count(db) - except Exception: - pass - - -async def _delete_account_impl( - body: DeleteAccountRequest, - current_user: User, - db: Session, -) -> dict: - """ - Delete the current user's own account - preserves messages/DMs/reactions/files - """ - if _is_admin(current_user): - raise HTTPException(status_code=400, detail="Cannot delete admin/owner account") - - _verify_derived_password(current_user, body.passwordDerived) - - 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", - } - - -@router.post("/delete") -async def delete_account( - body: DeleteAccountRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - return await _delete_account_impl(body, current_user, db) - - -@router.post("/account/delete") -async def delete_account_alias( - body: DeleteAccountRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - return await _delete_account_impl(body, current_user, db) \ No newline at end of file diff --git a/backend/services/main/routes/devices.py b/backend/services/main/routes/devices.py deleted file mode 100644 index 5a8dead..0000000 --- a/backend/services/main/routes/devices.py +++ /dev/null @@ -1,92 +0,0 @@ -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session - -from ..dependencies import get_current_user, get_db -from ..models import User, DeviceSession -from ..utils import verify_token -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - -router = APIRouter() -security = HTTPBearer() - - -def _get_current_session_id(credentials: HTTPAuthorizationCredentials) -> str: - token = credentials.credentials - payload = verify_token(token) - if not payload or "session_id" not in payload: - raise HTTPException(status_code=401, detail="Invalid session") - return payload["session_id"] - - -@router.get("") -def list_devices( - credentials: HTTPAuthorizationCredentials = Depends(security), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - current_session_id = _get_current_session_id(credentials) - sessions = ( - db.query(DeviceSession) - .filter(DeviceSession.user_id == current_user.id, DeviceSession.revoked == False) - .order_by(DeviceSession.last_seen.desc()) - .all() - ) - return { - "devices": [ - { - "session_id": s.session_id, - "device_type": s.device_type, - "device_name": s.device_name, - "os_name": s.os_name, - "os_version": s.os_version, - "browser_name": s.browser_name, - "browser_version": s.browser_version, - "brand": s.brand, - "model": s.model, - "created_at": s.created_at.isoformat() if s.created_at else None, - "last_seen": s.last_seen.isoformat() if s.last_seen else None, - "revoked": s.revoked, - "current": s.session_id == current_session_id, - } - for s in sessions - ] - } - - -@router.delete("/{session_id}") -def revoke_device( - session_id: str, - 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) - .first() - ) - if not s: - raise HTTPException(status_code=404, detail="Device session not found") - s.revoked = True - db.commit() - return {"status": "success"} - - -@router.post("/logout-all") -def logout_all_except_current( - credentials: HTTPAuthorizationCredentials = Depends(security), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - current_session_id = _get_current_session_id(credentials) - db.query(DeviceSession).filter( - DeviceSession.user_id == current_user.id, - DeviceSession.session_id != current_session_id, - ).update({DeviceSession.revoked: True}) - db.commit() - return {"status": "success"} - - diff --git a/backend/services/main/routes/download.py b/backend/services/main/routes/download.py deleted file mode 100644 index a13f6d1..0000000 --- a/backend/services/main/routes/download.py +++ /dev/null @@ -1,381 +0,0 @@ -""" -Download routes for FromChat desktop and mobile builds. -Fetches from GitHub Actions (PC) and GitHub Releases (mobile), with disk caching. -""" - -import asyncio -import logging -import os -from pathlib import Path - -import httpx -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import FileResponse, Response, StreamingResponse - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/download", tags=["download"]) - -GITHUB_API = "https://api.github.com" -WEB_OWNER, WEB_REPO = "fromchat-messenger", "web" -APP_OWNER, APP_REPO = "fromchat-messenger", "app" -WORKFLOW_FILE = "build.yml" -TIMEOUT = 10.0 - -ARTIFACT_NAMES = { - "windows": "FromChat-windows", - "linux": "FromChat-linux", - "macos": "FromChat-macOS", -} - -CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "downloads" -CACHE_DIR.mkdir(parents=True, exist_ok=True) - - -def _headers() -> dict[str, str]: - token = os.environ.get("RELEASES_TOKEN") - if not token: - raise HTTPException(status_code=503, detail="RELEASES_TOKEN not configured") - return { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } - - -def _etag_path(os_name: str) -> Path: - return CACHE_DIR / f"{os_name}.etag" - - -def _cached_file_path(os_name: str) -> Path: - ext = ".zip" if os_name in ARTIFACT_NAMES else (".apk" if os_name == "android" else ".ipa") - return CACHE_DIR / f"{os_name}{ext}" - - -async def _fetch_pc_artifact_url(os_name: str) -> tuple[str, int]: - """Fetch workflow runs, get latest run, find artifact. Returns (download_url, artifact_id).""" - artifact_name = ARTIFACT_NAMES[os_name] - logger.info("[download] Fetching PC artifact for %s: workflow=%s/%s/%s", os_name, WEB_OWNER, WEB_REPO, WORKFLOW_FILE) - async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client: - runs_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/workflows/{WORKFLOW_FILE}/runs" - logger.info("[download] GitHub API: GET %s (per_page=1, status=success)", runs_url) - runs_resp = await client.get( - runs_url, - headers=_headers(), - params={"per_page": 1, "status": "success"}, - ) - logger.info("[download] GitHub workflow runs response: status=%s", runs_resp.status_code) - runs_resp.raise_for_status() - runs = runs_resp.json() - workflow_runs = runs.get("workflow_runs", []) - if not workflow_runs: - logger.warning("[download] No successful workflow runs for %s", artifact_name) - raise HTTPException(status_code=404, detail=f"No successful workflow run for {artifact_name}") - - run_id = workflow_runs[0]["id"] - logger.info("[download] Latest run_id=%s, fetching artifacts", run_id) - artifacts_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/runs/{run_id}/artifacts" - artifacts_resp = await client.get(artifacts_url, headers=_headers()) - logger.info("[download] GitHub artifacts response: status=%s", artifacts_resp.status_code) - artifacts_resp.raise_for_status() - data = artifacts_resp.json() - for artifact in data.get("artifacts", []): - if artifact["name"] == artifact_name: - url = artifact["archive_download_url"] - aid = artifact["id"] - logger.info("[download] Found artifact %s id=%s, download_url=%s", artifact_name, aid, url[:80] + "..." if len(url) > 80 else url) - return url, aid - logger.warning("[download] Artifact %s not found in run %s", artifact_name, run_id) - raise HTTPException(status_code=404, detail=f"Artifact {artifact_name} not found") - - -async def _fetch_mobile_asset_url(os_name: str) -> str: - """Fetch latest release, find asset by name. Returns browser_download_url.""" - keyword = "android" if os_name == "android" else "ios" - logger.info("[download] Fetching mobile asset for %s: releases %s/%s", os_name, APP_OWNER, APP_REPO) - async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client: - releases_url = f"{GITHUB_API}/repos/{APP_OWNER}/{APP_REPO}/releases" - logger.info("[download] GitHub API: GET %s (per_page=10)", releases_url) - resp = await client.get( - releases_url, - headers=_headers(), - params={"per_page": 10}, - ) - logger.info("[download] GitHub releases response: status=%s", resp.status_code) - resp.raise_for_status() - releases = resp.json() - for release in releases: - if release.get("draft"): - continue - for asset in release.get("assets", []): - if keyword.lower() in asset.get("name", "").lower(): - url = asset["browser_download_url"] - logger.info("[download] Found %s asset: %s (release: %s)", os_name, asset.get("name"), release.get("tag_name")) - return url - logger.warning("[download] No %s asset in releases", os_name) - raise HTTPException(status_code=404, detail=f"No {os_name} asset found in releases") - - -async def _download_and_stream( - url: str, - os_name: str, - stored_etag: str | None, -) -> StreamingResponse | FileResponse: - """Stream from GitHub to client and save to disk. If 304, serve from disk.""" - etag_path = _etag_path(os_name) - cache_path = _cached_file_path(os_name) - cache_path.parent.mkdir(parents=True, exist_ok=True) - - headers = {**_headers(), "Accept": "*/*"} - if stored_etag: - headers["If-None-Match"] = stored_etag - - logger.info("[download] Mobile %s: GET %s (etag=%s)", os_name, url[:100] + "..." if len(url) > 100 else url, stored_etag or "none") - - async def stream_and_save(): - total = 0 - tmp_path = cache_path.with_name(cache_path.name + ".tmp") - new_etag: str | None = None - try: - async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: - async with client.stream("GET", url, headers=headers) as resp: - if resp.status_code == 304 and cache_path.exists(): - yield None - return - if resp.status_code != 200: - if resp.status_code in (404, 410): - raise HTTPException( - status_code=404, - detail="Release asset not found on GitHub", - ) - raise HTTPException( - status_code=503, - detail="GitHub returned an error while downloading asset", - ) - new_etag = resp.headers.get("etag") - logger.info("[download] Mobile %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown") - with open(tmp_path, "wb") as f: - async for chunk in resp.aiter_bytes(chunk_size=65536): - f.write(chunk) - total += len(chunk) - yield chunk - tmp_path.rename(cache_path) - if new_etag: - etag_path.write_text(new_etag) - logger.info("[download] Mobile %s: completed, saved %d bytes", os_name, total) - except httpx.StreamClosed: - logger.info("[download] Mobile %s: client disconnected after %d bytes", os_name, total) - tmp_path.unlink(missing_ok=True) - except httpx.TimeoutException: - tmp_path.unlink(missing_ok=True) - if cache_path.exists(): - raise _CacheFallback() - raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file") - except HTTPException: - tmp_path.unlink(missing_ok=True) - raise - - class _CacheFallback(Exception): - pass - - gen = stream_and_save() - try: - first = await gen.__anext__() - except StopAsyncIteration: - first = None - except _CacheFallback: - await gen.aclose() - return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name) - if first is None: - await gen.aclose() - logger.info("[download] Mobile %s: serving from cache (304)", os_name) - return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name) - - async def body(): - yield first - async for chunk in gen: - yield chunk - - return StreamingResponse( - body(), - media_type="application/octet-stream", - headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'}, - ) - - -async def _resolve_artifact_download_url(url: str) -> str: - """Resolve artifact URL: GitHub 302 redirects to Azure; Azure rejects Authorization. Get Location without following.""" - headers = {**_headers(), "Accept": "application/vnd.github+json"} - async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client: - resp = await client.get(url, headers=headers) - if resp.status_code in (404, 410): - raise HTTPException(status_code=404, detail="Artifact not found on GitHub") - if resp.status_code != 302: - raise HTTPException(status_code=503, detail="GitHub returned an error while resolving artifact URL") - location = resp.headers.get("location") - if not location: - raise HTTPException(status_code=502, detail="No redirect location from GitHub") - return location - - -async def _download_artifact_and_stream( - url: str, - os_name: str, - artifact_id: int, -) -> StreamingResponse | FileResponse: - """Download artifact (zip). GitHub redirects to Azure; Azure must be called WITHOUT Authorization.""" - etag_path = _etag_path(os_name) - cache_path = _cached_file_path(os_name) - stored_id = etag_path.read_text().strip() if etag_path.exists() else None - if stored_id == str(artifact_id) and cache_path.exists(): - logger.info("[download] PC %s: serving from cache (artifact_id=%s)", os_name, artifact_id) - return FileResponse( - str(cache_path), - media_type="application/zip", - filename=cache_path.name, - ) - - try: - download_url = await _resolve_artifact_download_url(url) - except HTTPException: - if cache_path.exists(): - logger.info("[download] PC %s: GitHub error, serving from cache", os_name) - return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name) - raise - - logger.info("[download] PC %s: streaming from Azure URL (no auth)", os_name) - - async def stream_and_save(): - total = 0 - tmp_path = cache_path.with_name(cache_path.name + ".tmp") - try: - async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: - async with client.stream("GET", download_url) as resp: - if resp.status_code != 200: - if resp.status_code in (404, 410): - raise HTTPException(status_code=404, detail="Artifact file not found on GitHub") - raise HTTPException( - status_code=503, - detail="GitHub returned an error while downloading artifact file", - ) - logger.info("[download] PC %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown") - with open(tmp_path, "wb") as f: - async for chunk in resp.aiter_bytes(chunk_size=65536): - f.write(chunk) - total += len(chunk) - yield chunk - tmp_path.rename(cache_path) - etag_path.write_text(str(artifact_id)) - logger.info("[download] PC %s: completed, saved %d bytes", os_name, total) - except httpx.StreamClosed: - logger.info("[download] PC %s: client disconnected after %d bytes", os_name, total) - tmp_path.unlink(missing_ok=True) - except HTTPException: - tmp_path.unlink(missing_ok=True) - raise - - gen = stream_and_save() - try: - first = await gen.__anext__() - except StopAsyncIteration: - first = None - except HTTPException: - if cache_path.exists(): - return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name) - raise - - if first is None: - await gen.aclose() - raise HTTPException(status_code=502, detail="Empty response from download") - - async def body(): - yield first - async for chunk in gen: - yield chunk - - return StreamingResponse( - body(), - media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'}, - ) - - -def _head_response(filename: str, content_length: int | None = None) -> Response: - headers = {"Content-Disposition": f'attachment; filename="{filename}"'} - if content_length is not None: - headers["Content-Length"] = str(content_length) - return Response(status_code=200, headers=headers) - - -@router.api_route("/{os_name}", methods=["GET", "HEAD"]) -async def download(request: Request, os_name: str): - """Download app for the given OS: windows, linux, macos, android, ios.""" - is_head = request.method == "HEAD" - os_name = os_name.lower() - logger.info("[download] %s /download/%s", request.method, os_name) - - if os_name not in ("windows", "linux", "macos", "android", "ios"): - raise HTTPException(status_code=400, detail="Invalid os. Use: windows, linux, macos, android, ios") - - try: - if os_name in ARTIFACT_NAMES: - try: - url, artifact_id = await asyncio.wait_for( - _fetch_pc_artifact_url(os_name), - timeout=TIMEOUT, - ) - except asyncio.TimeoutError: - logger.warning("[download] PC %s: GitHub API timeout", os_name) - cache_path = _cached_file_path(os_name) - if cache_path.exists(): - if is_head: - return _head_response(cache_path.name, cache_path.stat().st_size) - return FileResponse( - str(cache_path), - media_type="application/zip", - filename=cache_path.name, - ) - raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file") - cache_path = _cached_file_path(os_name) - result = await _download_artifact_and_stream(url, os_name, artifact_id) - if is_head: - fn = getattr(result, "filename", None) or cache_path.name - size = cache_path.stat().st_size if cache_path.exists() else None - return _head_response(fn, size) - return result - else: - stored_etag = None - etag_path = _etag_path(os_name) - cache_path = _cached_file_path(os_name) - if etag_path.exists(): - stored_etag = etag_path.read_text().strip() or None - - try: - url = await asyncio.wait_for( - _fetch_mobile_asset_url(os_name), - timeout=TIMEOUT, - ) - except asyncio.TimeoutError: - logger.warning("[download] Mobile %s: GitHub API timeout", os_name) - if cache_path.exists(): - if is_head: - return _head_response(cache_path.name, cache_path.stat().st_size) - return FileResponse( - str(cache_path), - media_type="application/octet-stream", - filename=cache_path.name, - ) - raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file") - - result = await _download_and_stream(url, os_name, stored_etag) - if is_head: - fn = getattr(result, "filename", None) or cache_path.name - size = cache_path.stat().st_size if cache_path.exists() else None - return _head_response(fn, size) - return result - except HTTPException as exc: - if exc.status_code in (404, 410): - raise HTTPException(status_code=404, detail=exc.detail) - if exc.status_code in (502, 503, 504): - raise HTTPException(status_code=503, detail=exc.detail) - raise diff --git a/backend/services/main/routes/envelope_messaging.py b/backend/services/main/routes/envelope_messaging.py deleted file mode 100644 index 1a0d53a..0000000 --- a/backend/services/main/routes/envelope_messaging.py +++ /dev/null @@ -1,1054 +0,0 @@ -""" -Envelope encryption API endpoints for private messaging. - -Handles: -- Sending encrypted private messages (proxies to messaging service) -- Retrieving encrypted conversations -- Decrypting messages with proper MEK unwrapping -- Managing transport public key distribution -""" - -import logging -import json -import time -from datetime import datetime -from pathlib import Path -from typing import Optional - -import httpx -from nacl.exceptions import CryptoError -from fastapi import APIRouter, Depends, HTTPException, status, Request -from sqlalchemy.orm import Session -from pydantic import BaseModel, Field - -from ..db import get_db -from ..models import User, DMEnvelope, DMFile, DMEditHistory, EditMessageRequest -from ..dependencies import get_current_user, get_current_user_allow_suspended -from ..security.audit import log_security -from ..service_calls import ( - get_messaging_transport_public_key, - get_compliance_public_key, - process_message_with_files_in_messaging_service, - store_encrypted_file, - init_resumable_upload_in_storage, - get_resumable_upload_status_in_storage, - upload_resumable_chunk_in_storage, - complete_resumable_upload_in_storage, - get_resumable_upload_blob_path_in_storage, - store_encrypted_file_from_path, - delete_resumable_upload_in_storage, -) -from .messaging import messagingManager, convert_dm_envelope, convert_dm_envelope_for_user -from ..push_service import push_service - -logger = logging.getLogger("uvicorn.error") - - -def _compliance_public_key_required() -> bool: - try: - from services.shared.message_retention import get_message_retention - except ImportError: - from backend.services.shared.message_retention import get_message_retention # type: ignore - return not get_message_retention().never_store_compliance_mek() - - -router = APIRouter(prefix="/dm", tags=["Direct Messages"]) - - -# ============================================================================ -# Pydantic Models -# ============================================================================ - -class FileModel(BaseModel): - encrypted_file_data_b64: str - filename: str - file_size: int - - -class SendEncryptedMessageRequest(BaseModel): - """Request to send an encrypted message.""" - recipient_id: int - client_public_key_b64: str - transport_nonce_b64: str - transport_ciphertext_b64: str - sender_public_key_b64: str - recipient_public_key_b64: str - client_message_id: Optional[str] = None - reply_to_id: Optional[int] = None - files: list[FileModel] = Field(default_factory=list, alias="transport_files") - uploaded_file_ids: list[str] = Field(default_factory=list, alias="uploaded_file_ids") - - class Config: - allow_population_by_field_name = True - - -class EditEncryptedMessageRequest(BaseModel): - """Request to edit an encrypted message.""" - client_public_key_b64: str - transport_nonce_b64: str - transport_ciphertext_b64: str - sender_public_key_b64: str - recipient_public_key_b64: str - - -class InitResumableUploadRequest(BaseModel): - filename: str - total_size: int - recipient_id: int - chunk_size: Optional[int] = None - - -class UploadChunkRequest(BaseModel): - offset: int - data_b64: str - - -# ============================================================================ -# Key Management Endpoint -# ============================================================================ - -@router.get("/key/transport/public") -async def get_transport_public_key_endpoint(request: Request): - """ - Get the current messaging service ephemeral transport public key. - - Clients use this key to encrypt their messages with X25519 + ChaCha20-Poly1305. - - Returns: - { - "key_id": "key-identifier", - "public_key_b64": "base64-encoded-key", - "created_at": "unix-timestamp" - } - """ - client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown' - - try: - result = await get_messaging_transport_public_key() - return result - except Exception as e: - logger.error("Failed to fetch transport public key: %s", e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to fetch encryption key" - ) - - -@router.post("/upload/init") -async def init_resumable_upload( - request: InitResumableUploadRequest, - current_user: User = Depends(get_current_user), -): - if request.total_size <= 0: - raise HTTPException(status_code=400, detail="total_size must be > 0") - - if current_user.id == request.recipient_id: - raise HTTPException(status_code=400, detail="Cannot send files to yourself") - - payload = await init_resumable_upload_in_storage( - filename=request.filename, - total_size=request.total_size, - allowed_user_ids=[current_user.id, request.recipient_id], - chunk_size=request.chunk_size, - ) - return payload - - -@router.get("/upload/{upload_id}") -async def get_resumable_upload_status( - upload_id: str, - current_user: User = Depends(get_current_user), -): - return await get_resumable_upload_status_in_storage(upload_id, current_user.id) - - -@router.patch("/upload/{upload_id}") -async def upload_resumable_chunk( - upload_id: str, - request: UploadChunkRequest, - current_user: User = Depends(get_current_user), -): - return await upload_resumable_chunk_in_storage( - upload_id=upload_id, - user_id=current_user.id, - offset=request.offset, - data_b64=request.data_b64, - ) - - -@router.post("/upload/{upload_id}/complete") -async def complete_resumable_upload( - upload_id: str, - current_user: User = Depends(get_current_user), -): - return await complete_resumable_upload_in_storage(upload_id, current_user.id) - - -@router.delete("/upload/{upload_id}") -async def delete_resumable_upload( - upload_id: str, - current_user: User = Depends(get_current_user), -): - return await delete_resumable_upload_in_storage(upload_id, current_user.id) - - - - -# ============================================================================ -# Message Sending Endpoint -# ============================================================================ - -@router.post("/send") -async def send_encrypted_message( - request: SendEncryptedMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """ - Send an encrypted private message using envelope encryption. - - Flow: - 1. Client encrypts plaintext with transport public key (X25519 + ChaCha20) - 2. Sends encrypted message to this endpoint with public keys - 3. Main backend forwards to messaging service for envelope encryption processing - 4. Messaging service returns encrypted message + 3 wrapped MEKs - 5. Main backend stores in database - - Args: - request: SendEncryptedMessageRequest - current_user: Current authenticated user - db: Database session - - Returns: - { - "id": message-id, - "sender_id": sender-user-id, - "recipient_id": recipient-user-id, - "timestamp": iso-timestamp, - "reply_to_id": optional-reply-id - } - """ - try: - # Verify recipient exists - recipient = db.query(User).filter(User.id == request.recipient_id).first() - if not recipient: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Recipient not found" - ) - - # Verify not sending to self - if current_user.id == request.recipient_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot send messages to yourself" - ) - - # Fetch compliance public key and process through messaging service - compliance_key_response = await get_compliance_public_key() - compliance_public_key_b64 = compliance_key_response.get("public_key_b64") or "" - if _compliance_public_key_required() and not compliance_public_key_b64: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve compliance key" - ) - - all_transport_files: list[dict[str, object]] = [ - { - "encrypted_file_data_b64": f.encrypted_file_data_b64, - "filename": f.filename, - "file_size": f.file_size, - } - for f in request.files - ] - - for upload_id in request.uploaded_file_ids: - uploaded_payload = await get_resumable_upload_blob_path_in_storage( - upload_id, current_user.id - ) - all_transport_files.append( - { - "encrypted_file_path": uploaded_payload["encrypted_file_path"], - "filename": uploaded_payload["filename"], - "file_size": uploaded_payload["file_size"], - "upload_id": upload_id, - } - ) - - processed = await process_message_with_files_in_messaging_service( - client_public_key_b64=request.client_public_key_b64, - transport_nonce_b64=request.transport_nonce_b64, - transport_ciphertext_b64=request.transport_ciphertext_b64, - compliance_public_key_b64=compliance_public_key_b64, - sender_public_key_b64=request.sender_public_key_b64, - recipient_public_key_b64=request.recipient_public_key_b64, - transport_files=[ - ( - { - "encrypted_file_path": str(f["encrypted_file_path"]), - "filename": str(f.get("filename", "file")), - } - if f.get("encrypted_file_path") - else { - "encrypted_file_data_b64": str(f["encrypted_file_data_b64"]), - "filename": str(f.get("filename", "file")), - } - ) - for f in all_transport_files - ], - ) - - logger.info( - "Processed encrypted message, storing in database sender_id=%s recipient_id=%s", - current_user.id, - request.recipient_id, - ) - - msg = processed["message"] - dm_envelope = DMEnvelope( - sender_id=current_user.id, - recipient_id=request.recipient_id, - iv_b64=msg["nonce"], - ciphertext_b64=msg["ciphertext"], - sender_wrapped_mek_b64=processed["sender_wrapped_mek"], - recipient_wrapped_mek_b64=processed["recipient_wrapped_mek"], - compliance_wrapped_mek_b64=processed["compliance_wrapped_mek"], - reply_to_id=request.reply_to_id, - ) - - db.add(dm_envelope) - db.commit() - db.refresh(dm_envelope) - - # Store files encrypted with the SAME MEK as the message. - # We persist per-file nonce (for AES-GCM) but do not persist per-file wrapped MEKs. - try: - file_results: list[dict] = processed.get("files", []) or [] - if len(file_results) != len(all_transport_files): - raise HTTPException(status_code=500, detail="File processing count mismatch") - - for i, tf in enumerate(all_transport_files): - fr = file_results[i] - ciphertext_path = fr.get("ciphertext_path") - if ciphertext_path: - file_storage_result = await store_encrypted_file_from_path( - source_path=str(ciphertext_path), - filename=str(tf["filename"]), - content_type="application/octet-stream", - sender_id=current_user.id, - recipient_id=request.recipient_id, - ) - try: - Path(ciphertext_path).unlink(missing_ok=True) - except Exception: - pass - else: - file_storage_result = await store_encrypted_file( - encrypted_file_data_b64=fr["ciphertext"], - filename=str(tf["filename"]), - content_type="application/octet-stream", - sender_id=current_user.id, - recipient_id=request.recipient_id, - ) - - df = DMFile( - message_id=dm_envelope.id, - sender_id=current_user.id, - recipient_id=dm_envelope.recipient_id, - path=file_storage_result.get("path") or f"/uploads/files/encrypted/{file_storage_result['file_id']}", - name=Path(str(tf["filename"])).name, - nonce_b64=fr["nonce"], - ) - db.add(df) - db.commit() - - for upload_id in request.uploaded_file_ids: - try: - await delete_resumable_upload_in_storage(upload_id, current_user.id) - except Exception as cleanup_error: - logger.warning("Failed to cleanup resumable upload %s: %s", upload_id, cleanup_error) - - except HTTPException: - raise - except Exception: - try: - db.rollback() - except Exception: - pass - raise - logger.info( - "Stored encrypted message msg_id=%s from user_id=%s to user_id=%s", - dm_envelope.id, - current_user.id, - request.recipient_id, - ) - - # Send user-specific WebSocket updates (each user gets only their MEK and files metadata) - recipient_payload = convert_dm_envelope_for_user( - db, dm_envelope, dm_envelope.recipient_id, - ) - await messagingManager.send_update_to_user(dm_envelope.recipient_id, "dmNew", recipient_payload, db) - - sender_payload = convert_dm_envelope_for_user( - db, - dm_envelope, - dm_envelope.sender_id, - sender_client_message_id=request.client_message_id, - ) - await messagingManager.send_update_to_user(dm_envelope.sender_id, "dmNew", sender_payload, db) - - try: - await push_service.send_dm_notification(db, dm_envelope, current_user) - except Exception as e: - logger.error("Failed to send push notification for DM %s: %s", dm_envelope.id, e) - - return { - "id": dm_envelope.id, - "sender_id": dm_envelope.sender_id, - "recipient_id": dm_envelope.recipient_id, - "timestamp": dm_envelope.timestamp.isoformat(), - "client_message_id": request.client_message_id, - "reply_to_id": dm_envelope.reply_to_id, - } - - except HTTPException: - raise - except CryptoError as e: - logger.warning( - "DM send: transport NaCl decrypt failed (message/file key mismatch or corrupt ciphertext): %s", - e, - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=( - "Transport decryption failed: the encrypted message and each attachment must be " - "encrypted with the same client ephemeral keypair. For resumable uploads, the " - "ciphertext bytes on the server must match the transport fields in this request—" - "re-encrypt on the client or abort the upload session and start over." - ), - ) from e - except Exception as e: - logger.exception("Error sending encrypted message: %s", e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to send message" - ) - - -# ============================================================================ -# Compliance Endpoint (User ID 1 Only) -# ============================================================================ - -@router.get("/compliance/extract/{message_id}") -async def extract_message_for_compliance( - message_id: int, - request: Request, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """ - Extract message data for compliance review. - - RESTRICTED: Only accessible by user ID 1 (compliance officer). - This endpoint extracts encrypted message data that can be transferred - to an air-gapped machine for decryption using the compliance private key. - """ - # Log compliance access attempt - client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown' - log_security("compliance_access_attempt", "warning", - username=current_user.username, user_id=current_user.id, - message_id=message_id, ip=client_ip) - - # Security check: only user ID 1 can access this - if current_user.id != 1: - log_security("compliance_access_denied", "error", - username=current_user.username, user_id=current_user.id, - message_id=message_id, ip=client_ip, - reason="Unauthorized user (compliance officer access required)") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. This endpoint is restricted to compliance officers." - ) - - # Find the message - envelope = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first() - if not envelope: - log_security("compliance_access_failed", "warning", - username=current_user.username, user_id=current_user.id, - message_id=message_id, ip=client_ip, - reason="Message not found") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Message not found" - ) - - # Get sender and recipient usernames for logging - sender = db.query(User).filter(User.id == envelope.sender_id).first() - recipient = db.query(User).filter(User.id == envelope.recipient_id).first() - sender_username = sender.username if sender else f"user_{envelope.sender_id}" - recipient_username = recipient.username if recipient else f"user_{envelope.recipient_id}" - - # Extract compliance-relevant data (excluding sensitive server-only fields) - files = [] - try: - for f in (envelope.files or []): - wrapped = envelope.compliance_wrapped_mek_b64 - - files.append( - { - "id": f.id, - "name": f.name, - "path": f.path, - "wrapped_mek_b64": wrapped, - "nonce_b64": getattr(f, "nonce_b64", None), - } - ) - except Exception: - files = [] - - # Get complete edit history for compliance - edit_history = db.query(DMEditHistory).filter( - DMEditHistory.message_id == message_id - ).order_by(DMEditHistory.edited_at).all() - - edit_history_data = [] - for edit_entry in edit_history: - edited_by_user = db.query(User).filter(User.id == edit_entry.edited_by).first() - edit_history_data.append({ - "edit_id": edit_entry.id, - "edited_at": edit_entry.edited_at.isoformat(), - "edited_by_user_id": edit_entry.edited_by, - "edited_by_username": edited_by_user.username if edited_by_user else "unknown", - "previous_ciphertext_b64": edit_entry.previous_ciphertext_b64, - "previous_iv_b64": edit_entry.previous_iv_b64, - "previous_compliance_wrapped_mek_b64": edit_entry.previous_compliance_wrapped_mek_b64, - }) - - compliance_data = { - "message_id": envelope.id, - "sender_id": envelope.sender_id, - "recipient_id": envelope.recipient_id, - "timestamp": envelope.timestamp.isoformat(), - "iv_b64": envelope.iv_b64, - "ciphertext_b64": envelope.ciphertext_b64, - "compliance_wrapped_mek_b64": envelope.compliance_wrapped_mek_b64, - "files": files, - "edit_history": edit_history_data, - "total_edits": len(edit_history_data), - "extraction_timestamp": datetime.now().isoformat(), - "extracted_by_user_id": current_user.id, - "compliance_system_ready": envelope.compliance_wrapped_mek_b64 is not None - } - - log_security("compliance_extraction_success", "info", - username=current_user.username, user_id=current_user.id, - message_id=message_id, sender_id=envelope.sender_id, - recipient_id=envelope.recipient_id, sender_username=sender_username, - recipient_username=recipient_username, ip=client_ip) - - return { - "status": "success", - "message": "Message data extracted for compliance review", - "data": compliance_data, - "instructions": [ - "Transfer this data to an air-gapped machine", - "Use scripts/compliance/decryption/main.py decrypt --input-file ", - "Keep the compliance private key offline at all times" - ] - } - - -# ============================================================================ -# Conversation Retrieval Endpoint -# ============================================================================ - -@router.get("/conversation/{other_user_id}") -async def get_encrypted_conversation( - other_user_id: int, - limit: int = 50, - offset: int = 0, - current_user: User = Depends(get_current_user_allow_suspended), - db: Session = Depends(get_db), -): - """ - Retrieve encrypted conversation with another user. - - Returns messages with the wrapped MEK that the current user can unwrap. - Each user receives only their own wrapped MEK version. - - Args: - other_user_id: ID of the other user in conversation - limit: Max messages to return (default 50) - offset: Pagination offset (default 0) - current_user: Current authenticated user - db: Database session - - Returns: - List of encrypted messages with metadata: - [ - { - "id": message-id, - "sender_id": sender-id, - "recipient_id": recipient-id, - "nonce": base64-encoded-nonce, - "ciphertext": base64-encoded-ciphertext, - "wrapped_mek": wrapped-mek-for-current-user, - "timestamp": iso-timestamp, - "reply_to_id": optional-id, - "is_edited": boolean - }, - ... - ] - """ - try: - # Verify other user exists - other_user = db.query(User).filter(User.id == other_user_id).first() - if not other_user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found" - ) - - # Fetch messages in both directions, sorted by timestamp (exclude deleted) - messages = ( - db.query(DMEnvelope) - .filter( - ( - (DMEnvelope.sender_id == current_user.id) - & (DMEnvelope.recipient_id == other_user_id) - ) - | ( - (DMEnvelope.sender_id == other_user_id) - & (DMEnvelope.recipient_id == current_user.id) - ), - DMEnvelope.deleted_at.is_(None) # Exclude soft-deleted messages - ) - .order_by(DMEnvelope.timestamp.desc()) - .limit(limit) - .offset(offset) - .all() - ) - - result = [] - for msg in reversed(messages): - # Select wrapped MEK appropriate for current user - if msg.sender_id == current_user.id: - wrapped_mek = msg.sender_wrapped_mek_b64 - else: - wrapped_mek = msg.recipient_wrapped_mek_b64 - - result.append( - { - "id": msg.id, - "sender_id": msg.sender_id, - "recipient_id": msg.recipient_id, - "nonce": msg.iv_b64, - "ciphertext": msg.ciphertext_b64, - "wrapped_mek": wrapped_mek, - "timestamp": msg.timestamp.isoformat(), - "reply_to_id": msg.reply_to_id, - "is_edited": msg.is_edited, - } - ) - - logger.info( - "Retrieved %d messages for conversation between user_id=%s and user_id=%s", - len(result), - current_user.id, - other_user_id, - ) - - return result - - except HTTPException: - raise - except Exception as e: - logger.exception("Error fetching conversation: %s", e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to fetch conversation" - ) - - -# ============================================================================ -# Message Deletion Endpoint -# ============================================================================ - -@router.get("/owner/compliance-view") -async def get_owner_compliance_view( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """ - Get all encrypted messages accessible to the owner (user_id 1) for compliance. - - This endpoint returns all DM envelopes with their compliance-wrapped MEKs. - Only accessible to the system owner for audit/compliance purposes. - - Returns: - List of all encrypted messages with compliance_wrapped_mek: - [ - { - "id": message-id, - "sender_id": sender-id, - "recipient_id": recipient-id, - "nonce": base64-encoded-nonce, - "ciphertext": base64-encoded-ciphertext, - "compliance_wrapped_mek": wrapped-mek-for-compliance, - "timestamp": iso-timestamp, - }, - ... - ] - """ - if current_user.id != 1: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only owner (user_id 1) can access compliance view" - ) - - try: - # Fetch all non-deleted messages - messages = ( - db.query(DMEnvelope) - .filter(DMEnvelope.deleted_at.is_(None)) # Exclude soft-deleted messages - .order_by(DMEnvelope.timestamp.desc()) - .all() - ) - - result = [] - for msg in messages: - result.append( - { - "id": msg.id, - "sender_id": msg.sender_id, - "recipient_id": msg.recipient_id, - "nonce": msg.iv_b64, - "ciphertext": msg.ciphertext_b64, - "compliance_wrapped_mek": msg.compliance_wrapped_mek_b64, - "timestamp": msg.timestamp.isoformat(), - } - ) - - logger.info( - "Owner retrieved %d messages for compliance view", - len(result), - ) - - return result - - except Exception as e: - logger.exception("Error retrieving compliance view: %s", e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve compliance view" - ) - - -@router.get("/compliance/edit-history/dm/{message_id}") -async def get_dm_edit_history_for_compliance( - message_id: int, - request: Request, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """ - Get complete edit history for a DM message (compliance access only). - - RESTRICTED: Only accessible by user ID 1 (compliance officer). - This endpoint returns the full edit history for a DM message, - including all previous encrypted versions. - - Args: - message_id: ID of the DM message - current_user: Current authenticated user (must be user_id 1) - db: Database session - - Returns: - Complete edit history for the message - """ - client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown' - - # Log compliance access attempt - log_security("dm_edit_history_access_attempt", "warning", - user_id=current_user.id, - username=current_user.username, - ip=client_ip, - message_id=message_id) - - # Only user_id 1 (compliance officer) can access - if current_user.id != 1: - log_security("dm_edit_history_access_denied", "error", - user_id=current_user.id, - username=current_user.username, - ip=client_ip, - reason="Unauthorized user (compliance officer access required)") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. This endpoint is restricted to compliance officers." - ) - - try: - # Get the original message - message = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first() - if not message: - log_security("dm_edit_history_access_failed", "warning", - user_id=current_user.id, - ip=client_ip, - message_id=message_id, - reason="Message not found") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Message not found" - ) - - # Get edit history - edit_history = db.query(DMEditHistory).filter( - DMEditHistory.message_id == message_id - ).order_by(DMEditHistory.edited_at).all() - - # Convert to response format - history_entries = [] - for entry in edit_history: - edited_by_user = db.query(User).filter(User.id == entry.edited_by).first() - history_entries.append({ - "id": entry.id, - "dm_envelope_id": entry.message_id, - "previous_ciphertext_b64": entry.previous_ciphertext_b64, - "previous_iv_b64": entry.previous_iv_b64, - "previous_compliance_wrapped_mek_b64": entry.previous_compliance_wrapped_mek_b64, - "edited_at": entry.edited_at.isoformat(), - "edited_by_username": edited_by_user.username if edited_by_user else "unknown", - "edited_by_user_id": entry.edited_by - }) - - # Current message data - current_data = { - "id": message.id, - "sender_id": message.sender_id, - "recipient_id": message.recipient_id, - "ciphertext_b64": message.ciphertext_b64, - "iv_b64": message.iv_b64, - "sender_wrapped_mek_b64": message.sender_wrapped_mek_b64, - "recipient_wrapped_mek_b64": message.recipient_wrapped_mek_b64, - "compliance_wrapped_mek_b64": message.compliance_wrapped_mek_b64, - "timestamp": message.timestamp.isoformat(), - "is_edited": message.is_edited - } - - result = { - "message_id": message_id, - "current_version": current_data, - "edit_history": history_entries, - "total_edits": len(history_entries) - } - - log_security("dm_edit_history_access_success", "info", - user_id=current_user.id, - username=current_user.username, - ip=client_ip, - message_id=message_id, - edit_count=len(history_entries)) - - return result - - except HTTPException: - raise - except Exception as e: - logger.exception("Error retrieving DM edit history: %s", e) - log_security("dm_edit_history_access_error", "error", - user_id=current_user.id, - ip=client_ip, - message_id=message_id, - error=str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve edit history" - ) - - -@router.put("/edit/{message_id}") -async def edit_encrypted_message( - message_id: int, - request: EditEncryptedMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """ - Edit an encrypted private message. - - This endpoint allows users to edit their own DM messages. The edit history - is stored in compliance storage, but users only see the latest version. - The message goes through the same envelope encryption process as sending. - - Args: - message_id: ID of the message to edit - request: Edit request with transport-encrypted content - current_user: Current authenticated user - db: Database session - - Returns: - Updated message info - """ - try: - # Find the message - msg = db.query(DMEnvelope).filter( - DMEnvelope.id == message_id, - DMEnvelope.deleted_at.is_(None) # Can't edit deleted messages - ).first() - if not msg: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Message not found" - ) - - # Verify ownership - if msg.sender_id != current_user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Cannot edit others' messages" - ) - - # Fetch compliance public key and process through messaging service - compliance_key_response = await get_compliance_public_key() - compliance_public_key_b64 = compliance_key_response.get("public_key_b64") or "" - if _compliance_public_key_required() and not compliance_public_key_b64: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve compliance key" - ) - - # Process the transport-encrypted message through envelope encryption - processed = await process_message_with_files_in_messaging_service( - client_public_key_b64=request.client_public_key_b64, - transport_nonce_b64=request.transport_nonce_b64, - transport_ciphertext_b64=request.transport_ciphertext_b64, - compliance_public_key_b64=compliance_public_key_b64, - sender_public_key_b64=request.sender_public_key_b64, - recipient_public_key_b64=request.recipient_public_key_b64, - transport_files=[], # No file support for edits currently - ) - - # Update the message with new processed content (commit first so edit always succeeds) - processed_msg = processed["message"] - prev_ciphertext = msg.ciphertext_b64 - prev_iv = msg.iv_b64 - prev_wrapped_mek = msg.compliance_wrapped_mek_b64 or "" - msg.ciphertext_b64 = processed_msg["ciphertext"] - msg.iv_b64 = processed_msg["nonce"] - msg.sender_wrapped_mek_b64 = processed["sender_wrapped_mek"] - msg.recipient_wrapped_mek_b64 = processed["recipient_wrapped_mek"] - msg.compliance_wrapped_mek_b64 = processed["compliance_wrapped_mek"] - msg.is_edited = True - - db.commit() - db.refresh(msg) - - # Best-effort: store edit history for compliance (table may not exist yet) - try: - edit_history = DMEditHistory( - message_id=msg.id, - dm_envelope_id=msg.id, - previous_ciphertext_b64=prev_ciphertext, - previous_iv_b64=prev_iv, - previous_compliance_wrapped_mek_b64=prev_wrapped_mek, - edited_by=current_user.id, - edited_by_user_id=current_user.id, - ) - db.add(edit_history) - db.commit() - except Exception as history_err: - db.rollback() - logger.warning( - "Could not store DM edit history (table dm_edit_history may not exist): %s", - history_err, - ) - - logger.info( - "Edited encrypted message msg_id=%s by user_id=%s", - message_id, - current_user.id - ) - - # Send WebSocket updates to both sender and recipient - recipient_payload = convert_dm_envelope(db, msg, msg.recipient_id) - await messagingManager.send_update_to_user(msg.recipient_id, "dmEdited", recipient_payload, db) - - sender_payload = convert_dm_envelope(db, msg, msg.sender_id) - await messagingManager.send_update_to_user(msg.sender_id, "dmEdited", sender_payload, db) - - return { - "id": msg.id, - "sender_id": msg.sender_id, - "recipient_id": msg.recipient_id, - "timestamp": msg.timestamp.isoformat(), - "is_edited": msg.is_edited - } - - except HTTPException: - raise - except Exception as e: - logger.exception("Error editing encrypted message: %s", e) - db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to edit message" - ) - - -@router.delete("/{message_id}") -async def delete_encrypted_message( - message_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """ - Delete an encrypted message (soft delete). - - Only the sender can delete their own messages. - In the compliance system, keys are automatically destroyed after deletion. - - Args: - message_id: ID of message to delete - current_user: Current authenticated user - db: Database session - - Returns: - {"status": "deleted", "message_id": message-id} - """ - try: - msg = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first() - if not msg: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Message not found" - ) - - # Only sender can delete - if msg.sender_id != current_user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Cannot delete others' messages" - ) - - # Soft delete: set deleted_at timestamp instead of hard delete - from datetime import datetime - msg.deleted_at = datetime.now() - db.commit() - - logger.info( - "Deleted encrypted message msg_id=%s by user_id=%s", - message_id, - current_user.id - ) - - return {"status": "deleted", "message_id": message_id} - - except HTTPException: - raise - except Exception as e: - logger.exception("Error deleting message: %s", e) - db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete message" - ) diff --git a/backend/services/main/routes/keys.py b/backend/services/main/routes/keys.py deleted file mode 100644 index ccdfbbb..0000000 --- a/backend/services/main/routes/keys.py +++ /dev/null @@ -1,58 +0,0 @@ -from fastapi import APIRouter, HTTPException -import os -import logging - -router = APIRouter() -logger = logging.getLogger("uvicorn.error") - - -def _get_messaging_module(): - """Try to import in-process messaging module; return None if unavailable.""" - try: - from backend.services.messaging import main as messaging_module - return messaging_module - except Exception: - try: - # Fallback to package import when running with CWD=backend - from services.messaging import main as messaging_module # type: ignore - return messaging_module - except Exception: - return None - - -@router.get("/key/public") -async def get_public_key(): - """ - Return the current messaging service ephemeral public key. - If messaging service is in-process, call its function directly; otherwise, perform HTTP request to configured service URL. - """ - messaging_module = _get_messaging_module() - if messaging_module: - try: - data = await messaging_module.get_public_key() # type: ignore - return data - except Exception as e: - logger.error(f"Failed to get public key from in-process messaging module: {e}") - raise HTTPException(status_code=500, detail="Failed to retrieve messaging public key") - - # Out-of-process: call messaging service over HTTP - messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301") - url = f"{messaging_url.rstrip('/')}/key/public" - try: - # Prefer httpx if available - try: - import httpx - resp = httpx.get(url, timeout=5.0) - resp.raise_for_status() - return resp.json() - except Exception: - # Fallback to urllib - from urllib import request, error - import json - with request.urlopen(url, timeout=5) as r: - body = r.read() - return json.loads(body) - except Exception as e: - logger.error(f"Failed to fetch messaging public key via HTTP: {e}") - raise HTTPException(status_code=502, detail="Failed to contact messaging service") - diff --git a/backend/services/main/routes/livekit.py b/backend/services/main/routes/livekit.py deleted file mode 100644 index a4dedd3..0000000 --- a/backend/services/main/routes/livekit.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Mint LiveKit participant JWTs for DM calls. Requires LIVEKIT_API_KEY, LIVEKIT_API_SECRET, -and LIVEKIT_URL (WebSocket URL for clients, e.g. wss://livekit.example.com or ws://host:7880). -""" -from __future__ import annotations - -import logging -import os -import uuid -from datetime import timedelta - -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from ..dependencies import get_current_user, get_db -from ..models import User - -logger = logging.getLogger("uvicorn.error") - -router = APIRouter() - - -class LiveKitTokenRequest(BaseModel): - peer_user_id: int = Field(..., description="The other participant (DM peer)") - room_name: str | None = Field( - None, - description="Existing room from an invite; omit to create a new room", - ) - - -class LiveKitTokenResponse(BaseModel): - server_url: str - token: str - room_name: str - - -def _livekit_env() -> tuple[str, str, str]: - api_key = os.getenv("LIVEKIT_API_KEY", "").strip() - api_secret = os.getenv("LIVEKIT_API_SECRET", "").strip() - server_url = os.getenv("LIVEKIT_URL", "").strip() - if not api_key or not api_secret or not server_url: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="LiveKit is not configured (LIVEKIT_API_KEY / LIVEKIT_API_SECRET / LIVEKIT_URL)", - ) - return api_key, api_secret, server_url - - -@router.post("/token", response_model=LiveKitTokenResponse) -async def create_livekit_token( - body: LiveKitTokenRequest, - db: Session = Depends(get_db), - user: User = Depends(get_current_user), -): - """ - Issue a short-lived JWT for joining a 1:1 call room with peer_user_id. - """ - if body.peer_user_id == user.id: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="peer_user_id must differ from caller") - - peer = db.query(User).filter(User.id == body.peer_user_id).first() - if not peer: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Peer user not found") - - api_key, api_secret, server_url = _livekit_env() - - if body.room_name and body.room_name.strip(): - room_name = body.room_name.strip() - else: - room_name = f"call-{uuid.uuid4().hex}" - - try: - from livekit.api import AccessToken, VideoGrants - except ImportError as e: - logger.exception("livekit-api not installed") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="LiveKit SDK unavailable on server", - ) from e - - grants = VideoGrants( - room_join=True, - room=room_name, - can_publish=True, - can_subscribe=True, - can_publish_data=True, - ) - - token = ( - AccessToken(api_key, api_secret) - .with_identity(str(user.id)) - .with_name(user.username or str(user.id)) - .with_ttl(timedelta(hours=1)) - .with_grants(grants) - ) - - jwt_token = token.to_jwt() - - return LiveKitTokenResponse(server_url=server_url, token=jwt_token, room_name=room_name) diff --git a/backend/services/main/routes/messaging.py b/backend/services/main/routes/messaging.py deleted file mode 100644 index 5e6630b..0000000 --- a/backend/services/main/routes/messaging.py +++ /dev/null @@ -1,2732 +0,0 @@ -from datetime import datetime -import html -import logging -from pathlib import Path -import os -import re -import uuid -import asyncio -import time -import unicodedata -from collections import defaultdict, deque -from difflib import SequenceMatcher -from typing import Any -import json -import httpx -from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request, status -from sqlalchemy.orm import Session -from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db -from .account import convert_user_for_dm_conversation -from ..deleted_user import deleted_username_for, is_deleted_user, is_suspended_user -from ..constants import OWNER_USERNAME -from ..models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, DmConversationPreference, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog, MessageEditHistory, MessageEditHistoryResponse -from ..presence_service import presence_service -from ..push_service import push_service -from ..public_image_dimensions import ( - is_placeholder_dimensions, - read_image_dimensions_from_bytes, - read_image_dimensions_from_path, -) -from PIL import Image, ImageOps -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 ..verification_service import ( - VerificationStatus, - compute_verification_status, - get_verified_users_data, -) -from ..websocket.utils import authenticate_user - -from ..models import FcmToken -from .. import service_calls - -router = APIRouter() -logger = logging.getLogger("uvicorn.error") - -MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB - -# region agent log -_DEBUG_LOG_PATH = Path("/Volumes/Data/Projects/Programming/FromChat/Android/.cursor/debug-72e992.log") - - -def _agent_debug_log(hypothesis_id: str, location: str, message: str, data: dict) -> None: - """ - Append a structured NDJSON log line for pagination debugging. - Never raises: logging must not affect request handling. - """ - try: - payload = { - "sessionId": "72e992", - "runId": "backend-pagination", - "hypothesisId": hypothesis_id, - "location": location, - "message": message, - "data": data, - "timestamp": int(time.time() * 1000), - } - _DEBUG_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) - with _DEBUG_LOG_PATH.open("a", encoding="utf-8") as f: - f.write(json.dumps(payload, ensure_ascii=False) + "\n") - except Exception: - # Swallow all errors – debug-only path. - pass - -# endregion agent log - -# Legacy local fallback only — canonical public attachments live in file_storage: -# files/data/uploads/files/normal/{name} (served via /api/uploads/files/normal/...). -FILES_BASE_DIR = Path("data/uploads/files") -FILES_NORMAL_DIR = FILES_BASE_DIR / "normal" -FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" - -os.makedirs(FILES_NORMAL_DIR, exist_ok=True) -os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True) - -_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) -_THUMB_SIZE = 80 -_LARGE_FILE_THUMB_BYTES = 32 * 1024 * 1024 - - -def _generate_public_thumbnail(image_bytes: bytes) -> tuple[bytes | None, list[int]]: - """Tiny JPEG thumbnail for public chat. Returns (jpeg_bytes, [w, h]) or (None, [1, 1]).""" - try: - img = ImageOps.exif_transpose(Image.open(io.BytesIO(image_bytes))) - img = img.convert("RGB") - if hasattr(img, "info") and img.info: - img.info.pop("icc_profile", None) - w, h = img.size - aspect_wh = [w, h] - if w > _THUMB_SIZE or h > _THUMB_SIZE: - scale = min(_THUMB_SIZE / w, _THUMB_SIZE / h) - new_w = max(1, int(w * scale)) - new_h = max(1, int(h * scale)) - img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=85, optimize=True) - return buf.getvalue(), aspect_wh - except Exception as e: - logger.warning("PUBLIC THUMB: Generation failed: %s", e) - return None, [1, 1] - - -def _resolve_public_file_media(mod, stored_name: str, original_name: str) -> tuple[str, list[int], int]: - """Return (thumbnail_b64, [width, height], file_size). Never emits placeholder [1, 1].""" - thumb_b64 = "" - size = 0 - dimensions: list[int] | None = None - - if mod is not None: - try: - meta = mod.get_public_thumb_meta_internal(stored_name) - except Exception as error: - logger.warning("PUBLIC THUMB: meta load failed for %s: %s", stored_name, error) - meta = None - if meta: - thumb_b64 = str(meta.get("thumbnail_b64") or "") - size = int(meta.get("file_size") or 0) - - path = mod.get_normal_file_path_internal(stored_name) - if path is not None: - fresh = mod.read_image_dimensions_from_path(path) - if fresh is not None and not is_placeholder_dimensions(fresh[0], fresh[1]): - dimensions = fresh - if size <= 0: - size = int(path.stat().st_size) - - if dimensions is None and Path(original_name).suffix.lower() in _IMAGE_EXTENSIONS: - logger.error("PUBLIC THUMB: could not resolve dimensions for %s", stored_name) - - if dimensions is None: - dimensions = [1, 1] - - return thumb_b64, dimensions, size - - -def _public_attachment_media_fields_sync(msg: Message) -> dict: - """Build fileThumbnails / fileAspectRatios / fileSizes for public messages.""" - files = list(msg.files or []) - if not files: - return {} - mod = service_calls._get_file_storage_module() - thumbnails: list[str] = [] - aspect_ratios: list[list[int]] = [] - sizes: list[int] = [] - for f in files: - stored_name = Path(f.path).name - thumb_b64, dimensions, size = _resolve_public_file_media(mod, stored_name, f.name) - thumbnails.append(thumb_b64) - aspect_ratios.append(dimensions) - sizes.append(size) - return { - "fileThumbnails": thumbnails, - "fileAspectRatios": aspect_ratios, - "fileSizes": sizes, - } - - -def _get_file_storage_url() -> str: - lan = os.getenv("LAN_IP", "").strip() - default_fs = f"http://{lan}:8302" if lan else "http://127.0.0.1:8302" - return ( - os.getenv("FILE_STORAGE_SERVICE_URL") - or os.getenv("FILE_STORAGE_URL") - or default_fs - ) - -_SPAM_WINDOW_SECONDS = 45 -_SPAM_SIMILARITY_THRESHOLD = 0.88 -_SPAM_MESSAGE_LIMIT = 5 -_BURST_WINDOW_SECONDS = 30 -_BURST_COUNT_THRESHOLD = 20 -_SHORT_MESSAGE_LENGTH = 8 -_SHORT_MESSAGE_REPEAT_LIMIT = 4 - -_recent_message_cache: dict[int, deque[tuple[str, str, float, int]]] = defaultdict(deque) # (normalized, content, timestamp, message_id) -_message_rate_cache: dict[int, deque[tuple[float, int]]] = defaultdict(deque) # (timestamp, message_id) -_burst_last_logged: dict[int, float] = {} - - -def _normalize_for_spam(text: str) -> str: - normalized = unicodedata.normalize("NFKC", text or "").casefold() - # Remove whitespace and punctuation while keeping alphanumerics - cleaned = re.sub(r"[^0-9a-zа-яё]+", "", normalized, flags=re.IGNORECASE) - return cleaned - - -def _monitor_public_message_activity(user: User, content: str, message_id: int, db: Session) -> None: - now = time.time() - - def suspend(reason: str, event: str, message_ids_to_delete: list[int] = None, **extra: Any) -> None: - if user.suspended or user.id == 1: - return - - # Delete spam messages that triggered the ban - if message_ids_to_delete: - try: - deleted_count = db.query(Message).filter(Message.id.in_(message_ids_to_delete)).delete(synchronize_session=False) - db.commit() - logger.info(f"Deleted {deleted_count} spam messages for user {user.id}") - except Exception as e: - logger.error(f"Failed to delete spam messages: {e}") - db.rollback() - - user.suspended = True - user.suspension_reason = reason - db.commit() - log_security( - event, - severity="warning", - user_id=user.id, - username=user.username, - reason=reason, - deleted_messages=len(message_ids_to_delete) if message_ids_to_delete else 0, - **extra, - ) - try: - asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason)) - except Exception: - pass - - # Rate tracking for burst detection - rate_bucket = _message_rate_cache[user.id] - rate_bucket.append((now, message_id)) - while rate_bucket and now - rate_bucket[0][0] > _BURST_WINDOW_SECONDS: - rate_bucket.popleft() - - burst_count = len(rate_bucket) - if burst_count >= _BURST_COUNT_THRESHOLD: - last_logged = _burst_last_logged.get(user.id) - if not last_logged or now - last_logged > _BURST_WINDOW_SECONDS: - log_security( - "public_message_burst", - severity="warning", - user_id=user.id, - username=user.username, - count=burst_count, - window_seconds=_BURST_WINDOW_SECONDS, - ) - _burst_last_logged[user.id] = now - - # Get all message IDs from the burst window - burst_message_ids = [msg_id for _, msg_id in rate_bucket] - suspend( - "Automatic suspension: excessive message rate", - "auto_suspension_public_burst", - message_ids_to_delete=burst_message_ids, - count=burst_count, - window_seconds=_BURST_WINDOW_SECONDS, - ) - return - - # Similarity-based spam detection - normalized = _normalize_for_spam(content) - history = _recent_message_cache[user.id] - while history and now - history[0][2] > _SPAM_WINDOW_SECONDS: - history.popleft() - - prior_same = sum(1 for prev_norm, _, _, _ in history if prev_norm == normalized) - prior_similar = sum( - 1 - for prev_norm, _, _, _ in history - if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD - ) - - history.append((normalized, content, now, message_id)) - - total_matches = prior_same + prior_similar + 1 - - if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT: - # Get message IDs of all matching short messages - spam_message_ids = [msg_id for prev_norm, _, _, msg_id in history if prev_norm == normalized] - spam_message_ids.append(message_id) # Include current message - suspend( - "Automatic suspension: repeated short messages", - "auto_suspension_public_spam", - message_ids_to_delete=spam_message_ids, - occurrences=prior_same + 1, - window_seconds=_SPAM_WINDOW_SECONDS, - match_type="short", - ) - return - - if total_matches >= _SPAM_MESSAGE_LIMIT: - # Get message IDs of all matching similar messages - spam_message_ids = [] - for prev_norm, _, _, msg_id in history: - if prev_norm == normalized: - spam_message_ids.append(msg_id) - elif prev_norm and normalized and prev_norm != normalized: - similarity = SequenceMatcher(None, normalized, prev_norm).ratio() - if similarity >= _SPAM_SIMILARITY_THRESHOLD: - spam_message_ids.append(msg_id) - spam_message_ids.append(message_id) # Include current message - suspend( - "Automatic suspension: repeated similar public messages", - "auto_suspension_public_spam", - message_ids_to_delete=spam_message_ids, - similar_messages=total_matches, - window_seconds=_SPAM_WINDOW_SECONDS, - match_type="similar", - ) - - -def convert_message( - msg: Message, - verified_users_data: list[dict[str, str]] | None = None, -) -> dict: - vdata = verified_users_data or [] - # Group reactions by emoji - reactions_dict = {} - if msg.reactions: - for reaction in msg.reactions: - emoji = reaction.emoji - if emoji not in reactions_dict: - reactions_dict[emoji] = { - "emoji": emoji, - "count": 0, - "users": [] - } - reactions_dict[emoji]["count"] += 1 - reactions_dict[emoji]["users"].append({ - "id": reaction.user_id, - "username": reaction.user.display_name - }) - - # Handle deleted or suspended authors - if is_deleted_user(msg.author): - username = deleted_username_for(msg.author.id) - profile_picture = None - verified = False - verification_status = VerificationStatus.NONE.value - elif is_suspended_user(msg.author): - username = msg.author.display_name - profile_picture = msg.author.profile_picture - verified = False - verification_status = VerificationStatus.BLOCKED.value - else: - username = msg.author.display_name - profile_picture = msg.author.profile_picture - verified = msg.author.verified - verification_status = compute_verification_status(msg.author, vdata).value - - return { - "id": msg.id, - "user_id": msg.author.id, - "content": msg.content, - "timestamp": msg.timestamp.isoformat(), - "is_read": msg.is_read, - "is_edited": msg.is_edited, - "username": username, - "profile_picture": profile_picture, - "verified": verified, - "verification_status": verification_status, - "reply_to": convert_message(msg.reply_to, verified_users_data) if msg.reply_to else None, - "reactions": list(reactions_dict.values()), - "files": [ - { - "path": f"/api/uploads/files/normal/{Path(f.path).name}", - "id": f.id, - "name": f.name, - "message_id": f.message_id - } - for f in (msg.files or []) - ], - **_public_attachment_media_fields_sync(msg), - } - - -def convert_message_for_user( - msg: Message, - viewer_user_id: int | None, - *, - sender_client_message_id: str | None = None, - verified_users_data: list[dict[str, str]] | None = None, -) -> dict: - """ - Per-user public chat payload. [sender_client_message_id] is included only for the sender - so clients can match optimistic rows to the server ack; never exposed to other viewers. - """ - payload = convert_message(msg, verified_users_data) - if ( - sender_client_message_id - and viewer_user_id is not None - and viewer_user_id == msg.user_id - ): - payload["client_message_id"] = sender_client_message_id - return payload - - -def convert_dm_envelope(db: Session, envelope: DMEnvelope, user_id: int | None = None) -> dict: - # Group reactions by emoji - reactions_dict = {} - if envelope.reactions: - for reaction in envelope.reactions: - emoji = reaction.emoji - if emoji not in reactions_dict: - reactions_dict[emoji] = { - "emoji": emoji, - "count": 0, - "users": [] - } - reactions_dict[emoji]["count"] += 1 - reactions_dict[emoji]["users"].append({ - "id": reaction.user_id, - "username": reaction.user.display_name - }) - - # Get sender info for verified status - sender = db.query(User).filter(User.id == envelope.sender_id).first() - verified_users_data = get_verified_users_data(db) - - # Handle deleted or suspended senders - if sender and is_deleted_user(sender): - sender_verified = False - verification_status = VerificationStatus.NONE.value - sender_username = deleted_username_for(sender.id) - elif sender and is_suspended_user(sender): - sender_verified = False - verification_status = VerificationStatus.BLOCKED.value - sender_username = sender.display_name or sender.username - else: - sender_verified = sender.verified if sender else False - verification_status = ( - compute_verification_status(sender, verified_users_data).value - if sender - else VerificationStatus.NONE.value - ) - sender_username = sender.username if sender else f"user_{envelope.sender_id}" - - # Return only the MEK wrapped with the requesting user's key - if user_id == envelope.sender_id: - wrapped_mek_b64 = envelope.sender_wrapped_mek_b64 - elif user_id == envelope.recipient_id: - wrapped_mek_b64 = envelope.recipient_wrapped_mek_b64 - elif user_id == 1: - # Compliance user (ID 1) gets compliance MEK - wrapped_mek_b64 = envelope.compliance_wrapped_mek_b64 - else: - # User is not authorized to view this message - wrapped_mek_b64 = None - - result = { - "id": envelope.id, - "senderId": envelope.sender_id, - "recipientId": envelope.recipient_id, - "sender_username": sender_username, - "iv_b64": envelope.iv_b64, - "ciphertext_b64": envelope.ciphertext_b64, - "wrapped_mek_b64": wrapped_mek_b64, - "timestamp": envelope.timestamp.isoformat(), - "verified": sender_verified, - "verification_status": verification_status, - "reactions": list(reactions_dict.values()), - "files": [] - } - - for f in (envelope.files or []): - safe_path = f"/api/uploads/files/encrypted/{Path(f.path).name}" - # Files use the same MEK as the message envelope - selected_file_wrapped = wrapped_mek_b64 - result["files"].append( - { - "path": safe_path, - "id": f.id, - "name": f.name, - "dm_envelope_id": f.message_id, - "wrapped_mek_b64": selected_file_wrapped, - "nonce_b64": getattr(f, "nonce_b64", None), - } - ) - - return result - - -def convert_dm_envelope_for_conversation_preview( - db: Session, - envelope: DMEnvelope, - user_id: int | None = None, -) -> dict: - """Minimal last-message payload for DM conversation list previews.""" - if user_id == envelope.sender_id: - wrapped_mek_b64 = envelope.sender_wrapped_mek_b64 - elif user_id == envelope.recipient_id: - wrapped_mek_b64 = envelope.recipient_wrapped_mek_b64 - elif user_id == 1: - wrapped_mek_b64 = envelope.compliance_wrapped_mek_b64 - else: - wrapped_mek_b64 = None - - return { - "id": envelope.id, - "senderId": envelope.sender_id, - "recipientId": envelope.recipient_id, - "iv_b64": envelope.iv_b64, - "ciphertext_b64": envelope.ciphertext_b64, - "wrapped_mek_b64": wrapped_mek_b64, - "timestamp": envelope.timestamp.isoformat(), - } - - -def convert_dm_envelope_for_user( - db: Session, - envelope: DMEnvelope, - user_id: int | None, - *, - sender_client_message_id: str | None = None, -) -> dict: - """ - Per-user DM payload. [sender_client_message_id] is included only for the sender so clients - can match optimistic rows to the server ack; never exposed to the recipient. - """ - payload = convert_dm_envelope(db, envelope, user_id) - if ( - sender_client_message_id - and user_id is not None - and user_id == envelope.sender_id - ): - payload["client_message_id"] = sender_client_message_id - return payload - - -class PublicInitResumableUploadRequest(BaseModel): - filename: str - total_size: int - chunk_size: int | None = None - - -class PublicUploadChunkRequest(BaseModel): - offset: int - data_b64: str - - -@router.post("/public/upload/init") -async def init_public_resumable_upload( - request: PublicInitResumableUploadRequest, - current_user: User = Depends(get_current_user), -): - if request.total_size <= 0: - raise HTTPException(status_code=400, detail="total_size must be > 0") - - return await service_calls.init_resumable_upload_in_storage( - filename=request.filename, - total_size=request.total_size, - allowed_user_ids=[current_user.id], - chunk_size=request.chunk_size, - ) - - -@router.get("/public/upload/{upload_id}") -async def get_public_resumable_upload_status( - upload_id: str, - current_user: User = Depends(get_current_user), -): - return await service_calls.get_resumable_upload_status_in_storage(upload_id, current_user.id) - - -@router.patch("/public/upload/{upload_id}") -async def upload_public_resumable_chunk( - upload_id: str, - request: PublicUploadChunkRequest, - current_user: User = Depends(get_current_user), -): - return await service_calls.upload_resumable_chunk_in_storage( - upload_id=upload_id, - user_id=current_user.id, - offset=request.offset, - data_b64=request.data_b64, - ) - - -@router.post("/public/upload/{upload_id}/complete") -async def complete_public_resumable_upload( - upload_id: str, - current_user: User = Depends(get_current_user), -): - return await service_calls.complete_resumable_upload_in_storage(upload_id, current_user.id) - - -@router.delete("/public/upload/{upload_id}") -async def delete_public_resumable_upload( - upload_id: str, - current_user: User = Depends(get_current_user), -): - return await service_calls.delete_resumable_upload_in_storage(upload_id, current_user.id) - - -def _optimize_image_bytes_if_possible(content: bytes, original_name: str) -> bytes: - ext = Path(original_name).suffix.lower() - try: - image = ImageOps.exif_transpose(Image.open(io.BytesIO(content))) - img_format = image.format or ("PNG" if ext == ".png" else "JPEG") - buf = io.BytesIO() - save_kwargs = {"optimize": True} - if img_format.upper() == "JPEG": - save_kwargs["quality"] = 95 - image.save(buf, format=img_format, **save_kwargs) - buf.seek(0) - return buf.read() - except Exception: - return content - - -def _read_image_dimensions( - *, - content: bytes | None = None, - source_path: Path | None = None, - original_name: str = "", -) -> list[int]: - """Read pixel size without decoding full multi-hundred-MP payloads when possible.""" - try: - if source_path is not None: - dimensions = read_image_dimensions_from_path(source_path) - if dimensions is not None: - return dimensions - if content is not None: - dimensions = read_image_dimensions_from_bytes(content, Path(original_name).suffix) - if dimensions is not None: - return dimensions - except Exception as error: - logger.warning("PUBLIC THUMB: dimension read failed: %s", error) - return [1, 1] - - -async def _maybe_store_public_thumbnail( - stored_name: str, - original_name: str, - *, - content: bytes | None = None, - source_path: Path | None = None, - file_size: int, -) -> None: - """Generate and store a thumbnail under file_storage THUMBS_DIR when possible.""" - if Path(original_name).suffix.lower() not in _IMAGE_EXTENSIONS: - return - if file_size <= 0: - return - try: - wh = _read_image_dimensions( - content=content, - source_path=source_path, - original_name=original_name, - ) - if is_placeholder_dimensions(wh[0], wh[1]): - logger.warning("PUBLIC THUMB: skipping meta for %s — dimensions unknown", stored_name) - return - if file_size > _LARGE_FILE_THUMB_BYTES: - await service_calls.store_public_image_dimensions_in_storage( - stored_name, - width=wh[0], - height=wh[1], - file_size=file_size, - ) - return - if content is not None: - image_bytes = content - elif source_path is not None: - image_bytes = Path(source_path).read_bytes() - else: - return - jpeg, thumb_wh = _generate_public_thumbnail(image_bytes) - if not jpeg: - await service_calls.store_public_image_dimensions_in_storage( - stored_name, - width=wh[0], - height=wh[1], - file_size=file_size, - ) - return - await service_calls.store_public_thumb_in_storage( - stored_name, - jpeg, - width=thumb_wh[0], - height=thumb_wh[1], - file_size=file_size, - ) - except Exception as error: - logger.warning("PUBLIC THUMB: store failed for %s: %s", stored_name, error) - - -async def _store_public_normal_attachment( - message_id: int, - original_name: str, - *, - content: bytes | None = None, - source_path: Path | None = None, -) -> MessageFile: - """Write a public attachment to file_storage so download proxy can serve it.""" - import tempfile - - ext = Path(original_name).suffix.lower() - uid = uuid.uuid4().hex - safe_name = f"{message_id}_{uid}{ext or ''}" - - if content is not None: - payload = _optimize_image_bytes_if_possible(content, original_name) - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(payload) - tmp_path = Path(tmp.name) - try: - stored = await service_calls.store_normal_file_from_path_in_storage(safe_name, tmp_path) - finally: - tmp_path.unlink(missing_ok=True) - file_size = int(stored.get("size") or len(payload)) - await _maybe_store_public_thumbnail( - safe_name, - original_name, - content=payload, - file_size=file_size, - ) - elif source_path is not None: - src = Path(source_path) - if not src.is_file(): - raise HTTPException(status_code=404, detail="Upload payload not found") - src_size = int(src.stat().st_size) - # Avoid loading huge non-image blobs into memory just to recompress. - if ( - Path(original_name).suffix.lower() in _IMAGE_EXTENSIONS - and src_size <= _LARGE_FILE_THUMB_BYTES - ): - payload = _optimize_image_bytes_if_possible(src.read_bytes(), original_name) - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(payload) - tmp_path = Path(tmp.name) - try: - stored = await service_calls.store_normal_file_from_path_in_storage(safe_name, tmp_path) - finally: - tmp_path.unlink(missing_ok=True) - file_size = int(stored.get("size") or len(payload)) - await _maybe_store_public_thumbnail( - safe_name, - original_name, - content=payload, - file_size=file_size, - ) - else: - stored = await service_calls.store_normal_file_from_path_in_storage(safe_name, src) - file_size = int(stored.get("size") or src_size) - mod = service_calls._get_file_storage_module() - dimension_path = ( - mod.get_normal_file_path_internal(safe_name) - if mod is not None - else src - ) - await _maybe_store_public_thumbnail( - safe_name, - original_name, - source_path=dimension_path, - file_size=file_size, - ) - else: - raise HTTPException(status_code=500, detail="Attachment payload missing") - - # Canonical path matches file_storage layout so clients/proxies resolve by basename. - stored_path = str(stored.get("path") or f"/uploads/files/normal/{safe_name}") - return MessageFile( - message_id=message_id, - name=original_name, - path=stored_path, - ) - - -async def _attach_resumable_uploads_to_message( - message: Message, - upload_ids: list[str], - current_user: User, - db: Session, -) -> None: - if not upload_ids: - return - - total_size = 0 - payloads: list[dict] = [] - for upload_id in upload_ids: - uploaded_payload = await service_calls.get_resumable_upload_blob_path_in_storage( - upload_id, current_user.id - ) - file_size = int(uploaded_payload.get("file_size", 0)) - total_size += file_size - payloads.append(uploaded_payload) - if total_size > MAX_TOTAL_SIZE: - raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB") - - for upload_id, uploaded_payload in zip(upload_ids, payloads): - source_path = Path(uploaded_payload["encrypted_file_path"]) - original_name = Path(uploaded_payload.get("filename", "file")).name - mf = await _store_public_normal_attachment( - message.id, - original_name, - source_path=source_path, - ) - db.add(mf) - - db.commit() - db.refresh(message) - - for upload_id in upload_ids: - try: - await service_calls.delete_resumable_upload_in_storage(upload_id, current_user.id) - except Exception as cleanup_error: - logger.warning("Failed to cleanup resumable upload %s: %s", upload_id, cleanup_error) - - -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() - if not original_message: - raise HTTPException(status_code=404, detail="Original message not found") - - raw_content = message_request.content.strip() - uploaded_file_ids = [ - uid.strip() - for uid in (message_request.uploaded_file_ids or []) - if uid and uid.strip() - ] - - if not raw_content and not files and not uploaded_file_ids: - raise HTTPException( - status_code=400, - detail="No content provided" - ) - - # Check for profanity and reject the message instead of censoring - if raw_content and 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( - status_code=400, - detail="Message too long" - ) - - new_message = Message( - content=escaped_content, - user_id=current_user.id, - reply_to_id=message_request.reply_to_id, - timestamp=datetime.now() - ) - - db.add(new_message) - db.commit() - db.refresh(new_message) - - # Handle files if provided (normal, not encrypted) - if files: - total_size = 0 - for up in files: - # Accumulate size if available - if hasattr(up, "size") and up.size is not None: - total_size += int(up.size) - else: - # If size unknown, read into memory to determine - data = await up.read() - up.file.seek(0) - total_size += len(data) - if total_size > MAX_TOTAL_SIZE: - raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB") - - for up in files: - original_name = Path(up.filename or "file").name - content = await up.read() - up.file.seek(0) - mf = await _store_public_normal_attachment( - new_message.id, - original_name, - content=content, - ) - db.add(mf) - db.commit() - db.refresh(new_message) - - if uploaded_file_ids: - await _attach_resumable_uploads_to_message( - new_message, - uploaded_file_ids, - current_user, - db, - ) - - client_message_id = None - if message_request.client_message_id: - raw_client_id = message_request.client_message_id.strip() - if raw_client_id: - client_message_id = raw_client_id - - # Send push notifications for public messages - try: - logger.info( - "Public message saved: id=%s user=%s content_length=%s", - new_message.id, - current_user.id, - len(new_message.content or ""), - ) - await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id) - except Exception as e: - logger.error(f"Failed to send push notification for message {new_message.id}: {e}") - - # Realtime broadcast for HTTP uploads as well - try: - await messagingManager.broadcast_new_message( - new_message, - db, - sender_client_message_id=client_message_id, - ) - except Exception: - pass - - _monitor_public_message_activity(current_user, raw_content, new_message.id, db) - - message_payload = convert_message_for_user( - new_message, - current_user.id, - sender_client_message_id=client_message_id, - verified_users_data=get_verified_users_data(db), - ) - - # Prepare log fields - log_fields = { - "message_id": new_message.id, - "user_id": current_user.id, - "username": current_user.username, - "reply_to": new_message.reply_to_id, - "attachments": len(new_message.files or []), - "length": len(new_message.content), - "suspended": current_user.suspended, - "content": new_message.content, - } - - log_public_chat("message_created", **log_fields) - - return {"status": "success", "message": message_payload} - - -@router.post("/send_message") -@rate_limit_per_ip("30/minute") -async def send_message( - request: Request, - message_request: SendMessageRequest | None = None, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), - # Optional multipart form support - payload: str | None = Form(default=None), - files: list[UploadFile] = File(default=[]), -): - # If payload is provided, prefer it for multipart requests - if payload and message_request is None: - # Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null} - try: - obj = json.loads(payload) - content = obj.get("content", "") - reply_to_id = obj.get("reply_to_id", None) - client_message_id = obj.get("client_message_id") - uploaded_file_ids = obj.get("uploaded_file_ids") - message_request = SendMessageRequest( - content=content, - reply_to_id=reply_to_id, - client_message_id=client_message_id, - uploaded_file_ids=uploaded_file_ids, - ) - except Exception: - raise HTTPException(status_code=400, detail="Invalid payload JSON") - - if not message_request: - raise HTTPException(status_code=400, detail="Missing request data") - - return await _send_message_internal(message_request, current_user, db, files) - - -class RegisterFcmRequest(BaseModel): - token: str - - -@router.post("/push/register") -async def register_fcm_token(request: Request, body: RegisterFcmRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): - """ - Register or update an FCM token for the authenticated user. - """ - token = body.token.strip() if body and body.token else None - if not token: - raise HTTPException(status_code=400, detail="Missing token") - - try: - # If token already exists (from another device), reassign it to this user. - token_row = db.query(FcmToken).filter(FcmToken.token == token).first() - if token_row: - token_row.user_id = current_user.id - else: - # Create new token record (allow multiple tokens per user) - new = FcmToken(user_id=current_user.id, token=token) - db.add(new) - db.commit() - logger.info( - "Registered FCM token for user %s: ...%s", - current_user.id, - token[-8:], - ) - 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. - """ - token = body.token.strip() if body and body.token else None - logger.info( - "Unregister FCM request user=%s token=%s", - current_user.id, - f"...{token[-8:]}" if token else "ALL", - ) - try: - if token: - db.query(FcmToken).filter(FcmToken.user_id == current_user.id, FcmToken.token == token).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") - logger.info( - "push_test start: user=%s token_count=%s", - current_user.id, - len(fcm_rows), - ) - - 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: - response = push_service._send_fcm_to_token(fcm.token, title, body, data) - logger.info( - "push_test sent user=%s token=%s response=%s", - current_user.id, - f"{fcm.token[-8:]}", - response, - ) - except Exception as e: - logger.error( - "Failed to send test push to user %s token %s: %s", - current_user.id, - f"...{fcm.token[-8:]}", - 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") - - -MAX_MESSAGE_PAGE_LIMIT = 200 - - -def _normalize_page_limit(limit: int | None, *, max_limit: int = MAX_MESSAGE_PAGE_LIMIT) -> int | None: - if limit is None: - return None - return max(1, min(limit, max_limit)) - - -def _paginate_rows_by_id( - query, - id_column, - *, - limit: int | None = None, - before_id: int | None = None, - after_id: int | None = None, - around_id: int | None = None, -) -> tuple[list[Any], bool, bool, bool]: - """ - Paginate rows by monotonic id. Always returns rows in ascending id order. - - Returns (rows, has_more, has_more_before, has_more_after). - has_more mirrors has_more_before for before/around pages and has_more_after for after pages. - """ - if limit is None: - rows = query.order_by(id_column.asc()).all() - # region agent log - _agent_debug_log( - hypothesis_id="H1_after_pagination", - location="messaging._paginate_rows_by_id", - message="unbounded pagination", - data={ - "mode": "all", - "limit": None, - "before_id": before_id, - "after_id": after_id, - "around_id": around_id, - "row_count": len(rows), - "min_id": min((getattr(r, "id", None) for r in rows), default=None), - "max_id": max((getattr(r, "id", None) for r in rows), default=None), - }, - ) - # endregion agent log - return rows, False, False, False - - if around_id is not None: - anchor = query.filter(id_column == around_id).first() - if anchor is None: - # region agent log - _agent_debug_log( - hypothesis_id="H2_around_pagination", - location="messaging._paginate_rows_by_id", - message="around_id anchor missing", - data={"limit": limit, "around_id": around_id}, - ) - # endregion agent log - return [], False, False, False - - half = limit // 2 - older_count = half - newer_count = max(0, limit - half - 1) - - older = ( - query.filter(id_column < around_id) - .order_by(id_column.desc()) - .limit(older_count) - .all() - ) - older.reverse() - - newer = ( - query.filter(id_column > around_id) - .order_by(id_column.asc()) - .limit(newer_count) - .all() - ) - - rows = older + [anchor] + newer - if not rows: - return [], False, False, False - - min_id = min(getattr(row, "id") for row in rows) - max_id = max(getattr(row, "id") for row in rows) - has_more_before = ( - query.filter(id_column < min_id).limit(1).first() is not None - ) - has_more_after = ( - query.filter(id_column > max_id).limit(1).first() is not None - ) - # region agent log - _agent_debug_log( - hypothesis_id="H2_around_pagination", - location="messaging._paginate_rows_by_id", - message="around_id window", - data={ - "limit": limit, - "around_id": around_id, - "row_count": len(rows), - "min_id": min_id, - "max_id": max_id, - "has_more_before": has_more_before, - "has_more_after": has_more_after, - }, - ) - # endregion agent log - return rows, has_more_before, has_more_before, has_more_after - - if after_id is not None: - filtered = query.filter(id_column > after_id) - probe = filtered.order_by(id_column.asc()).limit(limit + 1).all() - has_more_after = len(probe) > limit - rows = probe[:limit] - if not rows: - # region agent log - _agent_debug_log( - hypothesis_id="H1_after_pagination", - location="messaging._paginate_rows_by_id", - message="after_id page empty", - data={ - "limit": limit, - "after_id": after_id, - }, - ) - # endregion agent log - return [], False, False, False - min_id = getattr(rows[0], "id") - has_more_before = ( - query.filter(id_column < min_id).limit(1).first() is not None - ) - # region agent log - _agent_debug_log( - hypothesis_id="H1_after_pagination", - location="messaging._paginate_rows_by_id", - message="after_id page", - data={ - "limit": limit, - "after_id": after_id, - "row_count": len(rows), - "first_id": getattr(rows[0], "id", None), - "last_id": getattr(rows[-1], "id", None), - "has_more_before": has_more_before, - "has_more_after": has_more_after, - }, - ) - # endregion agent log - return rows, has_more_after, has_more_before, has_more_after - - filtered = query - if before_id is not None and query.filter(id_column == before_id).first() is not None: - filtered = query.filter(id_column < before_id) - - probe = filtered.order_by(id_column.desc()).limit(limit + 1).all() - has_more_before = len(probe) > limit - rows = probe[:limit] - rows.reverse() - # region agent log - _agent_debug_log( - hypothesis_id="H3_before_pagination", - location="messaging._paginate_rows_by_id", - message="before/initial page", - data={ - "limit": limit, - "before_id": before_id, - "row_count": len(rows), - "first_id": getattr(rows[0], "id", None) if rows else None, - "last_id": getattr(rows[-1], "id", None) if rows else None, - "has_more_before": has_more_before, - }, - ) - # endregion agent log - return rows, has_more_before, has_more_before, False - - -def _message_page_response( - messages_data: list[dict], - *, - has_more: bool, - has_more_before: bool, - has_more_after: bool, - status: str = "success", -) -> dict: - return { - "status": status, - "messages": messages_data, - "has_more": has_more, - "has_more_before": has_more_before, - "has_more_after": has_more_after, - } - - -@router.get("/get_messages") -@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse -async def get_messages( - request: Request, - limit: int | None = None, - before_id: int | None = None, - after_id: int | None = None, - around_id: int | None = None, - current_user: User = Depends(get_current_user_allow_suspended), - db: Session = Depends(get_db), -): - page_limit = _normalize_page_limit(limit) - # region agent log - _agent_debug_log( - hypothesis_id="H3_get_messages_params", - location="messaging.get_messages", - message="incoming get_messages request", - data={ - "client_host": request.client.host if request.client else None, - "limit": page_limit, - "before_id": before_id, - "after_id": after_id, - "around_id": around_id, - "path": str(request.url.path), - "query": str(request.url.query), - }, - ) - # endregion agent log - if sum(x is not None for x in (before_id, after_id, around_id)) > 1: - if around_id is not None: - before_id = None - after_id = None - elif before_id is not None and after_id is not None: - after_id = None - - base_query = db.query(Message) - rows, has_more, has_more_before, has_more_after = _paginate_rows_by_id( - base_query, - Message.id, - limit=page_limit, - before_id=before_id, - after_id=after_id, - around_id=around_id, - ) - - verified_users_data = get_verified_users_data(db) - messages_data = [convert_message(msg, verified_users_data) for msg in rows] - - return _message_page_response( - messages_data, - has_more=has_more, - has_more_before=has_more_before, - has_more_after=has_more_after, - ) - - -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_allow_suspended), 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() - verified_users_data = get_verified_users_data(db) - messages_data = [convert_message(msg, verified_users_data) 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.get("/dm/fetch") -@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_allow_suspended), db: Session = Depends(get_db)): - envelopes = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id) - if since: - envelopes = envelopes.filter(DMEnvelope.id > since) - envelopes = envelopes.order_by(DMEnvelope.id.asc()).all() - - return { - "status": "ok", - "messages": [convert_dm_envelope(db, envelope, current_user.id) for envelope in envelopes] - } - - -@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, - limit: int | None = None, - before_id: int | None = None, - after_id: int | None = None, - around_id: int | None = None, - current_user: User = Depends(get_current_user_allow_suspended), - 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: - raise HTTPException(status_code=404, detail="User not found") - - page_limit = _normalize_page_limit(limit) - if sum(x is not None for x in (before_id, after_id, around_id)) > 1: - if around_id is not None: - before_id = None - after_id = None - elif before_id is not None and after_id is not None: - after_id = None - - base_query = db.query(DMEnvelope).filter( - ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) - | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id)), - DMEnvelope.deleted_at.is_(None), - ) - - rows, has_more, has_more_before, has_more_after = _paginate_rows_by_id( - base_query, - DMEnvelope.id, - limit=page_limit, - before_id=before_id, - after_id=after_id, - around_id=around_id, - ) - - messages_data = [ - convert_dm_envelope(db, envelope, current_user.id) for envelope in rows - ] - - return _message_page_response( - messages_data, - has_more=has_more, - has_more_before=has_more_before, - has_more_after=has_more_after, - status="ok", - ) - - -def _get_dm_conversation_preference( - db: Session, - user_id: int, - other_user_id: int, -) -> DmConversationPreference: - pref = db.query(DmConversationPreference).filter( - DmConversationPreference.user_id == user_id, - DmConversationPreference.other_user_id == other_user_id, - ).first() - if pref is not None: - return pref - pref = DmConversationPreference( - user_id=user_id, - other_user_id=other_user_id, - archived=False, - last_read_envelope_id=0, - ) - db.add(pref) - db.flush() - return pref - - -def _count_dm_unread( - db: Session, - user_id: int, - other_user_id: int, - last_read_envelope_id: int, -) -> int: - return db.query(DMEnvelope).filter( - DMEnvelope.sender_id == other_user_id, - DMEnvelope.recipient_id == user_id, - DMEnvelope.id > last_read_envelope_id, - DMEnvelope.deleted_at.is_(None), - ).count() - - -def _build_dm_conversation_list( - db: Session, - current_user: User, - *, - archived: bool, -) -> list[dict]: - conversations_query = db.query(DMEnvelope).filter( - (DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id), - DMEnvelope.deleted_at.is_(None), - ).order_by(DMEnvelope.timestamp.desc()) - - latest_by_other_user: dict[int, DMEnvelope] = {} - for envelope in conversations_query: - other_user_id = ( - envelope.recipient_id - if envelope.sender_id == current_user.id - else envelope.sender_id - ) - if other_user_id not in latest_by_other_user: - latest_by_other_user[other_user_id] = envelope - - prefs = { - pref.other_user_id: pref - for pref in db.query(DmConversationPreference).filter( - DmConversationPreference.user_id == current_user.id, - ).all() - } - - result: list[dict] = [] - for other_user_id, latest_message in latest_by_other_user.items(): - pref = prefs.get(other_user_id) - is_archived = bool(pref.archived) if pref is not None else False - if is_archived != archived: - continue - - other_user = db.query(User).filter(User.id == other_user_id).first() - if not other_user: - continue - - last_read_id = pref.last_read_envelope_id if pref is not None else 0 - unread_count = _count_dm_unread(db, current_user.id, other_user_id, last_read_id) - - result.append({ - "user": convert_user_for_dm_conversation(other_user, db), - "lastMessage": convert_dm_envelope_for_conversation_preview( - db, latest_message, current_user.id - ), - "unreadCount": unread_count, - }) - - result.sort(key=lambda x: x["lastMessage"]["timestamp"], reverse=True) - return result - - -@router.get("/dm/conversations") -@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_allow_suspended), db: Session = Depends(get_db)): - return { - "status": "success", - "conversations": _build_dm_conversation_list(db, current_user, archived=False), - } - - -@router.get("/dm/conversations/archived") -@rate_limit_per_ip("60/minute") -async def get_archived_dm_conversations(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): - return { - "status": "success", - "conversations": _build_dm_conversation_list(db, current_user, archived=True), - } - - -class DmMarkReadRequest(BaseModel): - upToEnvelopeId: int | None = None - - -def _mark_dm_conversation_read( - db: Session, - user_id: int, - other_user_id: int, - *, - up_to_envelope_id: int | None = None, -) -> int: - """Advance read cursor for a DM thread; returns the new last_read_envelope_id.""" - pref = _get_dm_conversation_preference(db, user_id, other_user_id) - if up_to_envelope_id is not None and up_to_envelope_id > 0: - pref.last_read_envelope_id = max(pref.last_read_envelope_id, up_to_envelope_id) - else: - latest = db.query(DMEnvelope).filter( - ((DMEnvelope.sender_id == user_id) & (DMEnvelope.recipient_id == other_user_id)) - | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == user_id)), - DMEnvelope.deleted_at.is_(None), - ).order_by(DMEnvelope.id.desc()).first() - if latest is not None: - pref.last_read_envelope_id = max(pref.last_read_envelope_id, latest.id) - db.flush() - return int(pref.last_read_envelope_id) - - -class DmArchiveRequest(BaseModel): - archived: bool - - -@router.post("/dm/conversations/{other_user_id}/archive") -@rate_limit_per_ip("60/minute") -async def set_dm_conversation_archived( - request: Request, - other_user_id: int, - body: DmArchiveRequest, - 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 archive conversation with yourself") - - other_user = db.query(User).filter(User.id == other_user_id).first() - if not other_user: - raise HTTPException(status_code=404, detail="User not found") - - has_messages = db.query(DMEnvelope).filter( - ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) - | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id)), - DMEnvelope.deleted_at.is_(None), - ).first() - if not has_messages: - raise HTTPException(status_code=404, detail="Conversation not found") - - pref = _get_dm_conversation_preference(db, current_user.id, other_user_id) - pref.archived = bool(body.archived) - db.commit() - - await messagingManager.send_update_to_user( - current_user.id, - "dmConversationArchive", - { - "otherUserId": other_user_id, - "archived": pref.archived, - }, - db, - ) - - return { - "status": "success", - "otherUserId": other_user_id, - "archived": pref.archived, - } - - -@router.post("/dm/conversations/{other_user_id}/read") -@rate_limit_per_ip("60/minute") -async def mark_dm_conversation_read( - request: Request, - other_user_id: int, - body: DmMarkReadRequest | None = None, - 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 mark conversation with yourself as read") - - other_user = db.query(User).filter(User.id == other_user_id).first() - if not other_user: - raise HTTPException(status_code=404, detail="User not found") - - has_messages = db.query(DMEnvelope).filter( - ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) - | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id)), - DMEnvelope.deleted_at.is_(None), - ).first() - if not has_messages: - raise HTTPException(status_code=404, detail="Conversation not found") - - up_to = body.upToEnvelopeId if body is not None else None - last_read = _mark_dm_conversation_read( - db, - current_user.id, - other_user_id, - up_to_envelope_id=up_to, - ) - db.commit() - - return { - "status": "success", - "otherUserId": other_user_id, - "lastReadEnvelopeId": last_read, - } - - -async def _edit_message_internal( - message_id: int, - edit_request: EditMessageRequest, - current_user: User, - db: Session -) -> dict: - """Internal function to edit a message without requiring a Request object. - - This can be called from both HTTP endpoints and WebSocket handlers. - """ - message = db.query(Message).filter(Message.id == message_id).first() - - if not message: - raise HTTPException(status_code=404, detail="Message not found") - if message.user_id != current_user.id: - raise HTTPException(status_code=403, detail="You can only edit your own messages") - raw_content = edit_request.content.strip() - - if not raw_content: - raise HTTPException(status_code=400, detail="Message content cannot be empty") - - original_content = message.content - - # Check for profanity and reject the edit instead of censoring - if contains_profanity(raw_content): - raise HTTPException( - status_code=422, # Unprocessable Entity - content validation failed - detail="Message contains inappropriate content and cannot be sent" - ) - - escaped_content = html.escape(raw_content, quote=False) - - if len(escaped_content) > 4096: - raise HTTPException(status_code=400, detail="Message too long") - - # Store edit history in compliance storage before updating the message - edit_history = MessageEditHistory( - message_id=message.id, - previous_content=original_content, - edited_by_user_id=current_user.id - ) - db.add(edit_history) - - message.content = escaped_content - message.is_edited = True - - db.commit() - db.refresh(message) - - verified_users_data = get_verified_users_data(db) - payload = convert_message(message, verified_users_data) - - # Prepare log fields - log_fields = { - "message_id": message.id, - "user_id": current_user.id, - "username": current_user.username, - "reply_to": message.reply_to_id, - "content": message.content, - "previous_content": original_content, - } - - log_public_chat("message_edited", **log_fields) - - return {"status": "success", "message": payload} - - -@router.put("/edit_message/{message_id}") -@rate_limit_per_ip("20/minute") -async def edit_message( - request: Request, - message_id: int, - edit_request: EditMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - return await _edit_message_internal(message_id, edit_request, current_user, db) - - -@router.delete("/delete_message/{message_id}") -async def delete_message( - message_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - message = db.query(Message).filter(Message.id == message_id).first() - - if not message: - raise HTTPException(status_code=404, detail="Message not found") - - # Allow owner to delete any message - if current_user.username != OWNER_USERNAME and message.user_id != current_user.id: - raise HTTPException(status_code=403, detail="You can only delete your own messages") - - original_content = message.content - db.delete(message) - db.commit() - - log_public_chat( - "message_deleted", - message_id=message_id, - actor_id=current_user.id, - actor_username=current_user.username, - original_author_id=message.user_id, - content=original_content, - ) - - return {"status": "success", "message_id": message_id} - - -@router.post("/add_reaction") -@rate_limit_per_ip("50/minute") -async def add_reaction( - request: 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 == 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 == reaction_request.message_id, - Reaction.user_id == current_user.id, - Reaction.emoji == reaction_request.emoji - ).first() - - if existing_reaction: - # Remove existing reaction (toggle off) - db.delete(existing_reaction) - action = "removed" - else: - # Add new reaction - new_reaction = Reaction( - message_id=reaction_request.message_id, - user_id=current_user.id, - emoji=reaction_request.emoji - ) - db.add(new_reaction) - action = "added" - - db.commit() - - # Refresh message to get updated reactions - db.refresh(message) - - verified_users_data = get_verified_users_data(db) - message_data = convert_message(message, verified_users_data) - - # Broadcast reaction update - try: - await messagingManager.broadcast({ - "type": "reactionUpdate", - "data": { - "message_id": reaction_request.message_id, - "emoji": reaction_request.emoji, - "action": action, - "user_id": current_user.id, - "username": current_user.username, - "reactions": message_data["reactions"] - } - }, db) - except Exception: - pass - - log_public_chat( - "reaction_update", - message_id=reaction_request.message_id, - user_id=current_user.id, - username=current_user.username, - action=action, - emoji=reaction_request.emoji, - ) - - return {"status": "success", "action": action, "reactions": message_data["reactions"]} - - -@router.post("/dm/add_reaction") -@rate_limit_per_ip("50/minute") -async def add_dm_reaction( - request: 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 == reaction_request.dm_envelope_id).first() - if not envelope: - raise HTTPException(status_code=404, detail="DM envelope not found") - - # Check if user is part of this DM conversation - if current_user.id not in [envelope.sender_id, envelope.recipient_id]: - raise HTTPException(status_code=403, detail="Not authorized to react to this message") - - # Check if reaction already exists - existing_reaction = db.query(DMReaction).filter( - DMReaction.dm_envelope_id == reaction_request.dm_envelope_id, - DMReaction.user_id == current_user.id, - DMReaction.emoji == reaction_request.emoji - ).first() - - if existing_reaction: - # Remove existing reaction (toggle off) - db.delete(existing_reaction) - action = "removed" - else: - # Add new reaction - new_reaction = DMReaction( - dm_envelope_id=reaction_request.dm_envelope_id, - user_id=current_user.id, - emoji=reaction_request.emoji - ) - db.add(new_reaction) - action = "added" - - db.commit() - - # Refresh envelope to get updated reactions - db.refresh(envelope) - - envelope_data = convert_dm_envelope(db, envelope, current_user.id) - - # Broadcast reaction update to both participants - try: - await messagingManager.broadcast({ - "type": "dmReactionUpdate", - "data": { - "dm_envelope_id": reaction_request.dm_envelope_id, - "emoji": reaction_request.emoji, - "action": action, - "user_id": current_user.id, - "username": current_user.username, - "reactions": envelope_data["reactions"] - } - }, db) - except Exception: - pass - - log_dm( - "reaction_update", - dm_envelope_id=reaction_request.dm_envelope_id, - user_id=current_user.id, - username=current_user.username, - action=action, - emoji=reaction_request.emoji, - ) - - return {"status": "success", "action": action, "reactions": envelope_data["reactions"]} - - -class MessaggingSocketManager: - def __init__(self) -> None: - self.connections: list[WebSocket] = [] - self.user_by_ws: dict[WebSocket, int] = {} - 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): - if websocket.client_state.name == "CONNECTED": - await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}}) - - async def _get_next_sequence(self, user_id: int, db: Session | None = None) -> 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: - # Initialize from database to avoid conflicts on restart - if db: - try: - from ..models import UpdateLog - latest = db.query(UpdateLog).filter(UpdateLog.user_id == user_id).order_by(UpdateLog.sequence.desc()).first() - self.sequence_numbers[user_id] = latest.sequence if latest else 0 - except Exception: - self.sequence_numbers[user_id] = 0 - else: - 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")} - elif update_type == "profileUpdate": - sig_data = { - "type": update_type, - "userId": data.get("id"), - "username": data.get("username"), - "display_name": data.get("display_name"), - "bio": data.get("bio"), - "profile_picture": data.get("profile_picture"), - } - elif update_type == "registeredUserCount": - sig_data = {"type": update_type, "count": data.get("count")} - elif update_type == "dmConversationArchive": - sig_data = { - "type": update_type, - "otherUserId": data.get("otherUserId"), - "archived": data.get("archived"), - } - else: - # For unknown types, use full data (less efficient but safe) - sig_data = {"type": update_type, "data": data} - - # Create hash of signature data - sig_json = json.dumps(sig_data, sort_keys=True) - return hashlib.md5(sig_json.encode()).hexdigest() - - def _add_update(self, websocket: WebSocket, update: dict): - """Add an update to the pending batch for a WebSocket (with deduplication)""" - if websocket not in self.pending_updates: - self.pending_updates[websocket] = [] - - # Check for duplicates - signature = self._get_update_signature(update) - if websocket not in self.recent_updates: - self.recent_updates[websocket] = set() - - # Skip if this exact update was recently added - if signature in self.recent_updates[websocket]: - logger.warning(f"Update was skipped due to duplicate signature {signature}") - return - - # Add to pending updates and track signature - self.pending_updates[websocket].append(update) - self.recent_updates[websocket].add(signature) - - # Limit recent updates cache size (keep last 100 signatures per websocket) - if len(self.recent_updates[websocket]) > 1: - self.recent_updates[websocket] = set(list(self.recent_updates[websocket])[-1]) - - async def _flush_updates(self, websocket: WebSocket, db: Session | None = None): - """Flush pending updates for a WebSocket connection""" - if websocket not in self.pending_updates or not self.pending_updates[websocket]: - return - - updates = self.pending_updates[websocket] - self.pending_updates[websocket] = [] - - # Clear recent updates cache after flushing (updates are now sent, can be re-added if needed) - if websocket in self.recent_updates: - # Keep only the last 50 signatures to allow some deduplication across batches - recent_list = list(self.recent_updates[websocket]) - if len(recent_list) > 50: - self.recent_updates[websocket] = set(recent_list[-50:]) - else: - # Keep all if under limit - pass - - if updates: - user_id = self.user_by_ws.get(websocket) - if not user_id: - # No user associated - this shouldn't happen for authenticated connections - # Skip sending to avoid seq: 0 issues - logger.warning(f"Attempted to flush updates for unauthenticated websocket, skipping") - return - - seq = await self._get_next_sequence(user_id, db) - - # 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 - # This is expected behavior when multiple connections exist for the same user - 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 (expected)") - else: - logger.warning(f"Unexpected error storing updates in database: {e}") - else: - # Already stored, skip - logger.debug(f"Update sequence {seq} for user {user_id} already marked as stored") - - # Only send if WebSocket is still connected - if websocket.client_state.name == "CONNECTED": - await websocket.send_json({ - "type": "updates", - "seq": seq, - "updates": updates - }) - else: - logger.debug(f"WebSocket already closed, skipping update send for sequence {seq}") - - async def _schedule_batch_flush(self, websocket: WebSocket, db: Session | None = None): - """Schedule a batch flush after a delay (50-100ms)""" - if websocket in self.update_batch_tasks: - self.update_batch_tasks[websocket].cancel() - - async def flush_after_delay(): - await asyncio.sleep(0.075) # 75ms delay for batching - await self._flush_updates(websocket, db) - if websocket in self.update_batch_tasks: - del self.update_batch_tasks[websocket] - - self.update_batch_tasks[websocket] = asyncio.create_task(flush_after_delay()) - - async def _send_update(self, websocket: WebSocket, update_type: str, update_data: dict, db: Session | None = None): - """Send an update (will be batched)""" - self._add_update(websocket, {"type": update_type, "data": update_data}) - await self._schedule_batch_flush(websocket, db) - - async def handle_connection(self, websocket: WebSocket, db: Session): - # Initialize subscriptions for this connection - self.ws_subscriptions[websocket] = set() - - # Import here to avoid circular import - from ..websocket.handlers import handler_registry - - while True: - try: - data = await websocket.receive_json() - except Exception as e: - logger.error(f"Error receiving WebSocket message: {e}") - break - - message_type = data["type"] - handler_info = handler_registry.get_handler(message_type) - - if handler_info: - handler, authRequired = handler_info - try: - # Authenticate user before calling handler - user = authenticate_user(data, db, authRequired) - # Set user association for authenticated connections - if user: - self.user_by_ws[websocket] = user.id - - # Extract inner data to pass to handler - handler_data = data.get("data", {}) - result = await handler(self, websocket, db, user, handler_data) - # If handler returns a value, send it as a WebSocket message - if result is not None and websocket.client_state.name == "CONNECTED": - 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 in handler for {message_type}: {e}") - await self.send_error(websocket, message_type, HTTPException(500, "Internal server error")) - else: - if websocket.client_state.name == "CONNECTED": - 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: - await websocket.close(code=code, reason=message) - finally: - self.connections.remove(websocket) - - async def connect(self, websocket: WebSocket, db: Session): - await websocket.accept() - client_ip = websocket.client.host if websocket.client else None - log_access( - "ws_connect", - path=str(websocket.url.path), - ip=client_ip, - ) - self.connections.append(websocket) - # Initialize update system for this connection - self.pending_updates[websocket] = [] - self.last_seq_by_ws[websocket] = 0 - try: - await self.handle_connection(websocket, db) - except WebSocketDisconnect as e: - logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}") - log_access( - "ws_disconnect", - severity="warning" if e.code != 1000 else "info", - path=str(websocket.url.path), - ip=client_ip, - code=e.code, - reason=e.reason, - ) - finally: - # Flush any pending updates before disconnecting - if websocket in self.pending_updates: - await self._flush_updates(websocket, db) - # Cancel any pending batch tasks - if websocket in self.update_batch_tasks: - self.update_batch_tasks[websocket].cancel() - del self.update_batch_tasks[websocket] - # Cleanup connection - self.connections.remove(websocket) - if websocket in self.user_by_ws: - user_id = self.user_by_ws[websocket] - try: - became_offline, last_seen = presence_service.unregister_connection(user_id, websocket) - if became_offline and last_seen is not None: - await self.broadcast_status_change( - user_id, - False, - last_seen.isoformat(), - db, - ) - except Exception as e: - logger.error(f"Failed to set user offline during cleanup: {e}") - 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, 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: - # 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 broadcast_new_message( - self, - message: Message, - db: Session | None = None, - *, - sender_client_message_id: str | None = None, - ): - """Broadcast newMessage; only the sender receives client_message_id when provided.""" - sender_id = message.user_id - verified_users_data = get_verified_users_data(db) - for websocket in self.connections: - viewer_id = self.user_by_ws.get(websocket) - if viewer_id is None: - continue - payload = convert_message_for_user( - message, - viewer_id, - sender_client_message_id=sender_client_message_id, - verified_users_data=verified_users_data, - ) - await self._send_update(websocket, "newMessage", payload, db) - - async def broadcast_registered_user_count(self, db: Session): - """Notify all clients of the current non-deleted user count (public chat member count).""" - try: - n = db.query(User).filter(User.deleted.is_(False)).count() - except Exception: - return - try: - await self.broadcast({"type": "registeredUserCount", "data": {"count": n}}, db) - except Exception: - pass - - 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 and websocket.client_state.name == "CONNECTED": - 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 (as batched update)""" - await self.send_update_to_user(user_id, "suspended", { - "reason": reason - }) - - async def send_unsuspension_to_user(self, user_id: int): - """Send unsuspension message to user's WebSocket connections (as batched update)""" - await self.send_update_to_user(user_id, "unsuspended", {}) - - async def send_deletion_to_user(self, user_id: int): - """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, db: Session | None = None): - """Broadcast status change to all connections that are subscribed to this user""" - # 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 self._send_update(websocket, "statusUpdate", { - "userId": user_id, - "online": online, - "lastSeen": last_seen - }, db) - - async def broadcast_profile_update(self, user_id: int, update_data: dict, db: Session | None = None): - """Broadcast profile update to connections subscribed to this user.""" - for websocket in self.connections: - if websocket in self.ws_subscriptions and user_id in self.ws_subscriptions[websocket]: - await self._send_update(websocket, "profileUpdate", update_data, db) - - async def cleanup_stale_typing_indicators(self, db: Session): - """Periodically cleanup typing indicators that haven't been updated in 3+ seconds""" - while True: - try: - current_time = time.time() - stale_threshold = 3.0 # 3 seconds - - # Cleanup public chat typing indicators - stale_public_typing = [ - user_id for user_id, timestamp in self.typing_users.items() - if current_time - timestamp > stale_threshold - ] - - for user_id in stale_public_typing: - was_typing = self.typing_state.get(user_id, False) - del self.typing_users[user_id] - - # 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 = [] - for user_id, recipients in self.dm_typing_users.items(): - for recipient_id, timestamp in list(recipients.items()): - if current_time - timestamp > stale_threshold: - 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_update_to_user(recipient_id, "stopDmTyping", { - "userId": user_id, - "username": username - }, db) - - # Wait 1 second before next cleanup - await asyncio.sleep(1.0) - except Exception as e: - logger.error(f"Error in typing cleanup task: {e}") - await asyncio.sleep(1.0) - - def start_cleanup_task(self): - """Start the cleanup task if not already running""" - if self._cleanup_task is None or self._cleanup_task.done(): - 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() - -@router.websocket("/chat/ws") -async def chat_websocket( - websocket: WebSocket, - db: Session = Depends(get_db) -): - await messagingManager.connect(websocket, db) - - -# File serving proxy endpoints -# Proxy file requests to file_storage service - -import httpx - - -@router.api_route("/uploads/files/normal/{filename:path}", methods=["GET"]) -async def proxy_normal_file( - request: Request, - filename: str, - current_user: User = Depends(get_current_user_allow_suspended) -): - """Proxy file requests to file_storage service.""" - from fastapi.responses import FileResponse, Response - - safe_name = Path(filename).name - if filename != safe_name: - raise HTTPException(status_code=400, detail="Invalid file name") - - mod = service_calls._get_file_storage_module() - if mod: - try: - return await mod.get_file_normal_internal(safe_name) - except HTTPException as exc: - if exc.status_code != 404: - raise - except Exception as e: - logger.error("In-process file_storage.get_file_normal failed: %s", e) - raise HTTPException(status_code=500, detail="File service unavailable") - else: - file_storage_url = _get_file_storage_url() - target_url = f"{file_storage_url}/uploads/files/normal/{safe_name}" - headers = {k: v for k, v in request.headers.items() if k.lower() != "host"} - async with httpx.AsyncClient() as client: - try: - response = await client.get(target_url, headers=headers) - if response.status_code == 200: - return Response( - content=response.content, - status_code=response.status_code, - headers=dict(response.headers), - media_type=response.headers.get("content-type") - ) - if response.status_code != 404: - return Response( - content=response.content, - status_code=response.status_code, - headers=dict(response.headers), - media_type=response.headers.get("content-type") - ) - except httpx.RequestError as e: - logger.error("Failed to proxy file request: %s", e) - raise HTTPException(status_code=500, detail="File service unavailable") - - legacy_path = FILES_NORMAL_DIR / safe_name - if legacy_path.is_file(): - return FileResponse(str(legacy_path)) - - raise HTTPException(status_code=404, detail="File not found") - - -@router.api_route("/uploads/files/thumbs/{filename:path}", methods=["GET"]) -async def proxy_thumb_file( - request: Request, - filename: str, - current_user: User = Depends(get_current_user_allow_suspended), -): - """Proxy public-chat thumbnail requests to file_storage THUMBS_DIR.""" - from fastapi.responses import Response - - safe_name = Path(filename).name - if filename != safe_name: - raise HTTPException(status_code=400, detail="Invalid file name") - - mod = service_calls._get_file_storage_module() - if mod: - try: - return await mod.get_file_thumb_internal(safe_name) - except HTTPException: - raise - except Exception as e: - logger.error("In-process file_storage.get_file_thumb failed: %s", e) - raise HTTPException(status_code=500, detail="File service unavailable") - - file_storage_url = _get_file_storage_url() - target_url = f"{file_storage_url}/uploads/files/thumbs/{safe_name}" - headers = {k: v for k, v in request.headers.items() if k.lower() != "host"} - async with httpx.AsyncClient() as client: - try: - response = await client.get(target_url, headers=headers) - return Response( - content=response.content, - status_code=response.status_code, - headers=dict(response.headers), - media_type=response.headers.get("content-type", "image/jpeg"), - ) - except httpx.RequestError as e: - logger.error("Failed to proxy thumb request: %s", e) - raise HTTPException(status_code=500, detail="File service unavailable") - - -@router.get("/test-proxy") -async def test_proxy(): - """Test proxy connectivity to file_storage service.""" - file_storage_url = _get_file_storage_url() - target_url = f"{file_storage_url}/health" - - logger.info(f"Testing proxy to: {target_url}") - - async with httpx.AsyncClient(timeout=10.0) as client: - try: - response = await client.get(target_url, follow_redirects=False) - logger.info(f"Test proxy response: {response.status_code}") - return {"status": "ok", "response_code": response.status_code} - except Exception as e: - logger.error(f"Test proxy failed: {e}") - return {"status": "error", "error": str(e)} - - -@router.api_route("/uploads/files/encrypted/{filename:path}", methods=["GET"]) -async def proxy_encrypted_file( - request: Request, - filename: str, - current_user: User = Depends(get_current_user_allow_suspended) -): - """Proxy file requests to file_storage service.""" - mod = service_calls._get_file_storage_module() - if mod: - try: - return await mod.get_file_encrypted_internal(filename, current_user.id) - except HTTPException: - raise - except Exception as e: - logger.error("In-process file_storage.get_file_encrypted failed: %s", e) - raise HTTPException(status_code=500, detail="File service unavailable") - - file_storage_url = _get_file_storage_url() - target_url = f"{file_storage_url}/uploads/files/encrypted/{filename}" - headers = {k: v for k, v in request.headers.items() if k.lower() != "host"} - headers["X-User-ID"] = str(current_user.id) - async with httpx.AsyncClient(timeout=30.0) as client: - try: - response = await client.get(target_url, headers=headers, follow_redirects=False) - from fastapi.responses import Response - return Response( - content=response.content, - status_code=response.status_code, - headers=dict(response.headers), - media_type=response.headers.get("content-type") - ) - except Exception as e: - logger.error("Failed to proxy file request: %s", e) - raise HTTPException(status_code=500, detail="File service unavailable") - - -@router.get("/compliance/edit-history/message/{message_id}") -async def get_message_edit_history_for_compliance( - request: Request, - message_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """ - Get complete edit history for a public message (compliance access only). - - RESTRICTED: Only accessible by user ID 1 (compliance officer). - This endpoint returns the full edit history for a public message, - including all previous content versions. - - Args: - message_id: ID of the public message - current_user: Current authenticated user (must be user_id 1) - db: Database session - - Returns: - Complete edit history for the message - """ - client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown' - - # Log compliance access attempt - log_security("message_edit_history_access_attempt", "warning", - user_id=current_user.id, - username=current_user.username, - ip=client_ip, - message_id=message_id) - - # Only user_id 1 (compliance officer) can access - if current_user.id != 1: - log_security("message_edit_history_access_denied", "error", - user_id=current_user.id, - username=current_user.username, - ip=client_ip, - reason="Unauthorized user (compliance officer access required)") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. This endpoint is restricted to compliance officers." - ) - - try: - # Get the original message - message = db.query(Message).filter(Message.id == message_id).first() - if not message: - log_security("message_edit_history_access_failed", "warning", - user_id=current_user.id, - ip=client_ip, - message_id=message_id, - reason="Message not found") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Message not found" - ) - - # Get edit history - edit_history = db.query(MessageEditHistory).filter( - MessageEditHistory.message_id == message_id - ).order_by(MessageEditHistory.edited_at).all() - - # Convert to response format - history_entries = [] - for entry in edit_history: - edited_by_user = db.query(User).filter(User.id == entry.edited_by_user_id).first() - history_entries.append({ - "id": entry.id, - "message_id": entry.message_id, - "previous_content": entry.previous_content, - "edited_at": entry.edited_at.isoformat(), - "edited_by_username": edited_by_user.username if edited_by_user else "unknown", - "edited_by_user_id": entry.edited_by_user_id - }) - - # Current message data - current_data = { - "id": message.id, - "content": message.content, - "user_id": message.user_id, - "timestamp": message.timestamp.isoformat(), - "is_edited": message.is_edited - } - - result = { - "message_id": message_id, - "current_version": current_data, - "edit_history": history_entries, - "total_edits": len(history_entries) - } - - log_security("message_edit_history_access_success", "info", - user_id=current_user.id, - username=current_user.username, - ip=client_ip, - message_id=message_id, - edit_count=len(history_entries)) - - return result - - except HTTPException: - raise - except Exception as e: - logger.exception("Error retrieving message edit history: %s", e) - log_security("message_edit_history_access_error", "error", - user_id=current_user.id, - ip=client_ip, - message_id=message_id, - error=str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve edit history" - ) \ No newline at end of file diff --git a/backend/services/main/routes/moderation.py b/backend/services/main/routes/moderation.py deleted file mode 100644 index 221d249..0000000 --- a/backend/services/main/routes/moderation.py +++ /dev/null @@ -1,114 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from typing import List - -from ..constants import OWNER_USERNAME -from ..dependencies import get_current_user -from ..models import User -from ..security.audit import log_security -from ..security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist -from ..security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits - - -class BlocklistUpdateRequest(BaseModel): - words: List[str] = Field(default_factory=list, min_items=1) - - -class UnblockIPRequest(BaseModel): - ip: str = Field(..., min_length=1) - - -router = APIRouter(prefix="/moderation", tags=["moderation"]) - - -def _ensure_owner(user: User) -> None: - if user.username != OWNER_USERNAME: - raise HTTPException(status_code=403, detail="Only owner can perform this action") - - -@router.get("/blocklist") -def list_blocklist(current_user: User = Depends(get_current_user)): - _ensure_owner(current_user) - return {"words": get_blocklist()} - - -@router.post("/blocklist") -def append_blocklist( - request: BlocklistUpdateRequest, - current_user: User = Depends(get_current_user) -): - _ensure_owner(current_user) - added, updated = add_to_blocklist(request.words) - log_security( - "blocklist_add", - actor=current_user.username, - actor_id=current_user.id, - added=added, - ) - return {"added": added, "words": updated} - - -@router.delete("/blocklist") -def delete_from_blocklist( - request: BlocklistUpdateRequest, - current_user: User = Depends(get_current_user) -): - _ensure_owner(current_user) - removed, updated = remove_from_blocklist(request.words) - log_security( - "blocklist_remove", - actor=current_user.username, - actor_id=current_user.id, - removed=removed, - ) - return {"removed": removed, "words": updated} - - -@router.post("/unblock-ip") -def unblock_ip( - request: UnblockIPRequest, - current_user: User = Depends(get_current_user) -): - """Unblock an IP address from rate limiting.""" - _ensure_owner(current_user) - ip = request.ip.strip() - - if not ip: - raise HTTPException(status_code=400, detail="IP address is required") - - cleared = reset_rate_limit_for_ip(ip) - - log_security( - "rate_limit_unblock", - actor=current_user.username, - actor_id=current_user.id, - ip=ip, - success=cleared, - ) - - if cleared: - return {"status": "success", "message": f"Rate limit cleared for IP: {ip}"} - else: - return {"status": "success", "message": f"No rate limit entries found for IP: {ip}"} - - -@router.post("/clear-all-rate-limits") -def clear_all_rate_limits_endpoint( - current_user: User = Depends(get_current_user) -): - """Clear all rate limit entries. Use with caution.""" - _ensure_owner(current_user) - - cleared = clear_all_rate_limits() - - log_security( - "rate_limit_clear_all", - actor=current_user.username, - actor_id=current_user.id, - entries_cleared=cleared, - ) - - return {"status": "success", "message": f"Cleared {cleared} rate limit entries"} - - - diff --git a/backend/services/main/routes/profile.py b/backend/services/main/routes/profile.py deleted file mode 100644 index e853231..0000000 --- a/backend/services/main/routes/profile.py +++ /dev/null @@ -1,634 +0,0 @@ -from pathlib import Path -import logging -import re -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File -from fastapi.responses import FileResponse -from sqlalchemy.orm import Session -from PIL import Image -import os -import uuid -import io -from fastapi import Request - -from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db -from ..presence_service import presence_service -from ..models import User, UpdateBioRequest, UserProfileResponse -from pydantic import BaseModel -from ..validation import is_valid_username, is_valid_display_name -from ..verification_service import ( - VerificationStatus, - compute_verification_status, - get_verified_users_data, -) -from .messaging import messagingManager -from ..security.audit import log_security -from ..security.profanity import contains_profanity -from ..security.rate_limit import rate_limit_per_ip -from ..deleted_user import DELETED_LAST_SEEN, deleted_user_api_fields, is_deleted_user - -logger = logging.getLogger("uvicorn.error") - -router = APIRouter() - - -def _build_user_profile_response( - user: User, - is_owner_request: bool = False, - *, - verified_users_data: list[dict[str, str]] | None = None, -) -> UserProfileResponse: - should_hide_profile = (not is_owner_request) and is_deleted_user(user) - if not should_hide_profile: - online, last_seen = presence_service.get_presence(user.id) - verification_status = ( - compute_verification_status(user, verified_users_data) - if verified_users_data is not None - else ( - VerificationStatus.VERIFIED - if user.verified - else VerificationStatus.NONE - ) - ) - return UserProfileResponse( - id=user.id, - username=user.username, - display_name=user.display_name or user.username, - profile_picture=user.profile_picture, - bio=user.bio, - online=online, - last_seen=last_seen, - created_at=user.created_at, - verified=bool(user.verified), - verification_status=verification_status.value, - suspended=bool(user.suspended), - suspension_reason=user.suspension_reason, - deleted=bool(user.deleted), - ) - - hidden = deleted_user_api_fields(user.id) - return UserProfileResponse( - id=user.id, - username=hidden["username"], - display_name=hidden["display_name"], - profile_picture=hidden["profile_picture"], - bio=hidden["bio"], - online=hidden["online"], - last_seen=DELETED_LAST_SEEN, - created_at=hidden["created_at"], - verified=hidden["verified"], - verification_status=hidden["verification_status"], - suspended=hidden["suspended"], - suspension_reason=hidden["suspension_reason"], - deleted=hidden["deleted"], - ) - - -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) - - -async def broadcast_profile_update(user: User, db: Session) -> None: - """Notify clients subscribed to this user that their public profile changed.""" - try: - payload = build_profile_update_payload(user, viewer_id=None, db=db) - subscriber_count = sum( - 1 - for ws, subs in messagingManager.ws_subscriptions.items() - if user.id in subs - ) - logger.info( - "broadcast_profile_update user_id=%s bio=%r subscribers=%s", - user.id, - user.bio, - subscriber_count, - ) - await messagingManager.broadcast_profile_update(user.id, payload, db) - except Exception: - pass - - -def build_profile_update_payload( - user: User, - viewer_id: int | None, - db: Session, -) -> dict: - verified_users_data = get_verified_users_data(db) - is_owner_request = viewer_id is not None and (viewer_id == user.id or viewer_id == 1) - return _build_user_profile_response( - user, - is_owner_request=is_owner_request, - verified_users_data=verified_users_data, - ).model_dump(mode="json") - -# Request models -class UpdateProfileRequest(BaseModel): - username: str | None = None - display_name: str | None = None - description: str | None = None - -# Create uploads directory if it doesn't exist -PROFILE_PICTURES_DIR = Path("data/uploads/pfp") - -os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) - -@router.post("/upload-profile-picture") -@rate_limit_per_ip("10/minute") -async def upload_profile_picture( - request: Request, - profile_picture: UploadFile = File(...), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """ - Upload and process a profile picture - """ - # Validate file type - if not profile_picture.content_type.startswith('image/'): - raise HTTPException(status_code=400, detail="File must be an image") - - # Validate file size (max 5MB) - if profile_picture.size > 5 * 1024 * 1024: - raise HTTPException(status_code=400, detail="File size must be less than 5MB") - - try: - # Read and process the image - image_data = await profile_picture.read() - - # Open image with PIL - image = Image.open(io.BytesIO(image_data)) - - # Convert to RGB if necessary - if image.mode != 'RGB': - image = image.convert('RGB') - - # Resize to a reasonable size (200x200) - image.thumbnail((200, 200), Image.Resampling.LANCZOS) - - # Generate unique filename - filename = f"{current_user.id}_{uuid.uuid4().hex}.jpg" - filepath = os.path.join(PROFILE_PICTURES_DIR, filename) - - # Save the processed image - image.save(filepath, 'JPEG', quality=85) - - # Update user's profile picture in database - profile_picture_url = f"/api/profile-picture/{filename}" - current_user.profile_picture = profile_picture_url - db.commit() - db.refresh(current_user) - - await broadcast_profile_update(current_user, db) - - return { - "message": "Profile picture uploaded successfully", - "profile_picture_url": profile_picture_url - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}") - -@router.get("/profile-picture/{filename}") -async def get_profile_picture(filename: str): - """ - Serve profile picture files - """ - - if not re.match(r"^\d+_[0-9a-z]+\.jpg$", filename): - raise HTTPException(status_code=400, detail="Invalid file name") - - filepath = os.path.join(PROFILE_PICTURES_DIR, filename) - - if not os.path.exists(filepath): - raise HTTPException(status_code=404, detail="Profile picture not found") - - return FileResponse(filepath, media_type="image/jpeg") - -@router.get("/user/profile") -async def get_user_profile( - current_user: User = Depends(get_current_user_allow_suspended), - db: Session = Depends(get_db) -): - """ - Get current user's profile information - """ - try: - _ensure_owner_unsuspended(current_user, db) - - online, last_seen = presence_service.get_presence(current_user.id) - verified_users_data = get_verified_users_data(db) - verification_status = compute_verification_status(current_user, verified_users_data) - 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=online, - last_seen=last_seen, - created_at=current_user.created_at, - verified=current_user.verified, - verification_status=verification_status.value, - suspended=current_user.suspended or False, - suspension_reason=current_user.suspension_reason, - deleted=current_user.deleted or False, - ) - except Exception as e: - # Log and return a consistent HTTP 500 error with minimal details - try: - import logging - logging.getLogger("uvicorn.error").exception("Error in get_user_profile: %s", e) - except Exception: - pass - raise HTTPException(status_code=500, detail="Internal server error") - - -@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() - verified_users_data = get_verified_users_data(db) - profile_items = [] - for user in users: - online, last_seen = presence_service.get_presence(user.id) - verification_status = compute_verification_status(user, verified_users_data) - profile_items.append( - UserProfileResponse( - id=user.id, - username=user.username, - display_name=user.display_name, - profile_picture=user.profile_picture, - bio=user.bio, - online=online, - last_seen=last_seen, - created_at=user.created_at, - verified=user.verified, - verification_status=verification_status.value, - suspended=user.suspended or False, - suspension_reason=user.suspension_reason, - deleted=user.deleted or False, - ).model_dump() - ) - return {"users": profile_items} - -@router.put("/user/profile") -@rate_limit_per_ip("10/minute") -async def update_user_profile( - request: Request, - update_request: UpdateProfileRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """ - Update current user's profile information - """ - updated = False - - # Update username if provided - if update_request.username is not None: - username = update_request.username.strip() - if not is_valid_username(username): - raise HTTPException( - status_code=400, - detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" - ) - if contains_profanity(username): - raise HTTPException( - status_code=400, - detail="Имя пользователя содержит запрещённые слова" - ) - - # Check if username is already taken by another user - existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first() - if existing_user: - raise HTTPException(status_code=400, detail="Это имя пользователя уже занято") - - current_user.username = username - updated = True - - # Update display name if provided - if update_request.display_name is not None: - display_name = update_request.display_name.strip() - if not is_valid_display_name(display_name): - raise HTTPException( - status_code=400, - detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" - ) - if contains_profanity(display_name): - raise HTTPException( - status_code=400, - detail="Отображаемое имя содержит запрещённые слова" - ) - - current_user.display_name = display_name - updated = True - - # Update bio if provided - if 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") - - current_user.bio = bio - updated = True - - if updated: - db.commit() - db.refresh(current_user) - await broadcast_profile_update(current_user, db) - return { - "message": "Profile updated successfully", - "username": current_user.username, - "display_name": current_user.display_name, - "bio": current_user.bio - } - else: - return { - "message": "No changes made", - "username": current_user.username, - "display_name": current_user.display_name, - "bio": current_user.bio - } - - -@router.put("/user/bio") -@rate_limit_per_ip("10/minute") -async def update_user_bio( - request: Request, - bio_request: UpdateBioRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """ - Update current user's bio - """ - 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 = bio_request.bio.strip() - db.commit() - db.refresh(current_user) - - await broadcast_profile_update(current_user, db) - - return { - "message": "Bio updated successfully", - "bio": current_user.bio - } - - -@router.get("/user/stats/registered-count") -def get_registered_user_count( - current_user: User = Depends(get_current_user_allow_suspended), - db: Session = Depends(get_db), -): - """Number of registered accounts (non-deleted users).""" - n = db.query(User).filter(User.deleted.is_(False)).count() - return {"count": n} - - -@router.get("/user/{username}") -async def get_user_by_username( - username: str, - current_user: User = Depends(get_current_user_allow_suspended), - db: Session = Depends(get_db) -): - """ - Get user profile by username - """ - if not username or not is_valid_username(username): - raise HTTPException(status_code=400, detail="Invalid username format") - - user = db.query(User).filter(User.username == username).first() - - if not user: - raise HTTPException(status_code=404, detail="User not found") - - _ensure_owner_unsuspended(user, db) - - is_owner_request = current_user.id == user.id or current_user.id == 1 - verified_users_data = get_verified_users_data(db) - return _build_user_profile_response( - user, - is_owner_request=is_owner_request, - verified_users_data=verified_users_data, - ) - -@router.get("/user/id/{user_id}") -async def get_user_by_id( - user_id: int, - current_user: User = Depends(get_current_user_allow_suspended), - db: Session = Depends(get_db) -): - """ - Get user profile by user ID - """ - if user_id <= 0: - raise HTTPException(status_code=400, detail="Invalid user ID") - - user = db.query(User).filter(User.id == user_id).first() - - if not user: - raise HTTPException(status_code=404, detail="User not found") - - _ensure_owner_unsuspended(user, db) - - is_owner_request = current_user.id == user.id or current_user.id == 1 - verified_users_data = get_verified_users_data(db) - return _build_user_profile_response( - user, - is_owner_request=is_owner_request, - verified_users_data=verified_users_data, - ) - - -@router.post("/user/{user_id}/verify") -async def verify_user( - user_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """ - Toggle verification status for a user (owner only) - """ - # Only user with ID 1 (owner) can verify users - if current_user.id != 1: - raise HTTPException(status_code=403, detail="Only owner can verify users") - - target_user = db.query(User).filter(User.id == user_id).first() - if not target_user: - raise HTTPException(status_code=404, detail="User not found") - - # Toggle verification status - target_user.verified = not target_user.verified - db.commit() - - verified_users_data = get_verified_users_data(db) - verification_status = compute_verification_status(target_user, verified_users_data) - - 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, - ) - - await broadcast_profile_update(target_user, db) - - return { - "verified": target_user.verified, - "verification_status": verification_status.value, - "message": f"User verification {'enabled' if target_user.verified else 'disabled'}" - } - - -# Admin endpoints for user management -class SuspendUserRequest(BaseModel): - reason: str - -@router.post("/user/{user_id}/suspend") -async def suspend_user( - user_id: int, - request: SuspendUserRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """ - Suspend a user account (admin only) - """ - # Only user with ID 1 (admin) can suspend users - if current_user.id != 1: - raise HTTPException(status_code=403, detail="Only admin can suspend users") - - target_user = db.query(User).filter(User.id == user_id).first() - if not target_user: - raise HTTPException(status_code=404, detail="User not found") - - # Cannot suspend admin - if target_user.id == 1: - raise HTTPException(status_code=400, detail="Cannot suspend admin account") - - # Suspend the user - target_user.suspended = True - 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) - except Exception as e: - # Log error but don't fail the request - pass - - return { - "status": "success", - "message": f"User {target_user.username} has been suspended", - "reason": request.reason - } - - -@router.post("/user/{user_id}/unsuspend") -async def unsuspend_user( - user_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """ - Unsuspend a user account (admin only) - """ - # Only user with ID 1 (admin) can unsuspend users - if current_user.id != 1: - raise HTTPException(status_code=403, detail="Only admin can unsuspend users") - - target_user = db.query(User).filter(User.id == user_id).first() - if not target_user: - raise HTTPException(status_code=404, detail="User not found") - - # Unsuspend the user - target_user.suspended = False - target_user.suspension_reason = None - db.commit() - - # Send WebSocket unsuspension message - try: - await messagingManager.send_unsuspension_to_user(user_id) - except Exception: - # Log error but don't fail the request - pass - - 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" - } - - -@router.post("/user/{user_id}/delete") -async def delete_user( - user_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """ - Delete a user account (admin only) - preserves messages/DMs/reactions/files - """ - # Only user with ID 1 (admin) can delete users - if current_user.id != 1: - raise HTTPException(status_code=403, detail="Only admin can delete users") - - target_user = db.query(User).filter(User.id == user_id).first() - if not target_user: - raise HTTPException(status_code=404, detail="User not found") - - # Cannot delete admin - 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/services/main/routes/public_chat.py b/backend/services/main/routes/public_chat.py deleted file mode 100644 index 9c2f2e5..0000000 --- a/backend/services/main/routes/public_chat.py +++ /dev/null @@ -1,31 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session - -from ..dependencies import get_current_user_allow_suspended, get_db -from ..models import PublicChatProfileResponse, User -from ..public_chat_config import load_public_chat_static_profile - -router = APIRouter() - - -@router.get("/public-chat/profile", response_model=PublicChatProfileResponse) -def get_public_chat_profile( - current_user: User = Depends(get_current_user_allow_suspended), - db: Session = Depends(get_db), -): - """Metadata for the instance public chat (title, bio, member count).""" - del current_user - try: - static_profile = load_public_chat_static_profile() - except (FileNotFoundError, ValueError, OSError) as exc: - raise HTTPException(status_code=500, detail="Public chat profile is not configured") from exc - - member_count = db.query(User).filter(User.deleted.is_(False)).count() - bio = static_profile["bio"].strip() or None - - return PublicChatProfileResponse( - id=static_profile["id"], - title=static_profile["title"], - bio=bio, - member_count=member_count, - ) diff --git a/backend/services/main/routes/push.py b/backend/services/main/routes/push.py deleted file mode 100644 index 87cf1ec..0000000 --- a/backend/services/main/routes/push.py +++ /dev/null @@ -1,46 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session -from ..dependencies import get_current_user, get_db -from ..models import User, PushSubscriptionRequest -from ..push_service import push_service - -router = APIRouter() - -@router.post("/subscribe") -async def subscribe_to_push_notifications( - request: PushSubscriptionRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Subscribe user to push notifications""" - try: - success = await push_service.subscribe_user( - db=db, - user_id=current_user.id, - endpoint=request.endpoint, - p256dh_key=request.keys["p256dh"], - auth_key=request.keys["auth"] - ) - - if success: - return {"status": "success", "message": "Push notifications enabled"} - else: - raise HTTPException(status_code=500, detail="Failed to enable push notifications") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.delete("/unsubscribe") -async def unsubscribe_from_push_notifications( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Unsubscribe user from push notifications""" - try: - success = await push_service.unsubscribe_user(db=db, user_id=current_user.id) - - if success: - return {"status": "success", "message": "Push notifications disabled"} - else: - raise HTTPException(status_code=500, detail="Failed to disable push notifications") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/services/main/routes/static.py b/backend/services/main/routes/static.py deleted file mode 100644 index 66ea3db..0000000 --- a/backend/services/main/routes/static.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Static legal documents and expressive icons served from the instance deploy. -""" - -from pathlib import Path - -from fastapi import APIRouter, HTTPException -from fastapi.responses import FileResponse - -router = APIRouter(tags=["static"]) - -_STATIC_DIR = Path(__file__).resolve().parent.parent / "static" -_ICONS_DIR = _STATIC_DIR / "icons" - - -@router.get("/static/PRIVACY.md") -async def privacy_markdown() -> FileResponse: - path = _STATIC_DIR / "PRIVACY.md" - if not path.is_file(): - raise HTTPException(status_code=404, detail="PRIVACY.md not found") - return FileResponse(path, media_type="text/markdown; charset=utf-8") - - -@router.get("/static/TERMS.md") -async def terms_markdown() -> FileResponse: - path = _STATIC_DIR / "TERMS.md" - if not path.is_file(): - raise HTTPException(status_code=404, detail="TERMS.md not found") - return FileResponse(path, media_type="text/markdown; charset=utf-8") - - -@router.get("/static/icons/{name}.webp") -async def static_icon(name: str) -> FileResponse: - safe = Path(name).name - if safe != name or ".." in name: - raise HTTPException(status_code=400, detail="Invalid icon name") - path = _ICONS_DIR / f"{safe}.webp" - if not path.is_file(): - raise HTTPException(status_code=404, detail="Icon not found") - return FileResponse(path, media_type="image/webp") diff --git a/backend/services/main/routes/webrtc.py b/backend/services/main/routes/webrtc.py deleted file mode 100644 index 8327e73..0000000 --- a/backend/services/main/routes/webrtc.py +++ /dev/null @@ -1,89 +0,0 @@ -import logging -import os -import hmac -import hashlib -import time -from fastapi import APIRouter, Depends -from ..dependencies import get_current_user -import traceback - -router = APIRouter() -logger = logging.getLogger("uvicorn.error") - - -def generate_turn_credentials(username: str, secret: str, expiration_minutes: int = 60): - """Generate time-limited TURN credentials using TURN REST API format. - - This creates temporary credentials that expire after the specified time. - The username format is: timestamp:username - The password is an HMAC hash of the username and secret. - """ - # Current timestamp (seconds since epoch) - timestamp = int(time.time()) + (expiration_minutes * 60) - - # Create temporary username: timestamp:original_username - temp_username = f"{timestamp}:{username}" - - # Generate password using HMAC-SHA1 - temp_password = hmac.new( - secret.encode('utf-8'), - temp_username.encode('utf-8'), - hashlib.sha1 - ).hexdigest() - - return temp_username, temp_password - - -@router.get("/ice") -async def get_ice_servers(current_user = Depends(get_current_user)): - """Return ICE server configuration (STUN/TURN) for WebRTC clients. - - Generates time-limited TURN credentials that expire in 1 hour. - """ - try: - # Prefer using your own coturn for both STUN and TURN - turn_domain = "fromchat.ru" - stun_urls = [ - f"stun:{turn_domain}:3478", - f"stuns:{turn_domain}:5349", - ] - - turn_urls = [ - f"turn:{turn_domain}:3478", - f"turns:{turn_domain}:5349", - ] - - # Get TURN configuration from environment - turn_username = os.getenv("TURN_USERNAME") - turn_secret = os.getenv("TURN_SECRET") - - # Check if required environment variables are set - if not turn_username: - logger.error("ERROR: TURN_USERNAME environment variable is not set") - raise ValueError("TURN_USERNAME environment variable is not set") - - if not turn_secret: - logger.error("ERROR: TURN_SECRET environment variable is not set") - raise ValueError("TURN_SECRET environment variable is not set") - - ice_servers: list[dict] = [{"urls": url} for url in stun_urls] - - temp_username, temp_password = generate_turn_credentials( - turn_username, - turn_secret, - expiration_minutes=60 # Expires in 1 hour - ) - - ice_servers.append({ - "urls": turn_urls, - "username": temp_username, - "credential": temp_password, - }) - - return {"iceServers": ice_servers} - - except Exception as e: - logger.error(f"ERROR in /api/webrtc/ice: {str(e)}") - logger.error(f"ERROR type: {type(e).__name__}") - traceback.print_exc() - raise \ No newline at end of file diff --git a/backend/services/main/security/__init__.py b/backend/services/main/security/__init__.py deleted file mode 100644 index 9429562..0000000 --- a/backend/services/main/security/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Package marker for security utilities - diff --git a/backend/services/main/security/audit.py b/backend/services/main/security/audit.py deleted file mode 100644 index f035d2d..0000000 --- a/backend/services/main/security/audit.py +++ /dev/null @@ -1,476 +0,0 @@ -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]: - # Handle compliance-related actions with beautiful formatting - if action == "compliance_access_attempt": - lines = [f"Compliance access attempt for message {fields.get('message_id', 'unknown')}"] - lines.append(f"User: {_format_user(fields)}") - if fields.get("ip"): - lines.append(f"IP: {fields['ip']}") - return lines - if action == "compliance_access_denied": - lines = [f"Compliance access denied for message {fields.get('message_id', 'unknown')}"] - lines.append(f"User: {_format_user(fields)}") - if fields.get("ip"): - lines.append(f"IP: {fields['ip']}") - if fields.get("reason"): - lines.append(f"Reason: {fields['reason']}") - return lines - if action == "compliance_access_failed": - lines = [f"Compliance access failed for message {fields.get('message_id', 'unknown')}"] - lines.append(f"User: {_format_user(fields)}") - if fields.get("ip"): - lines.append(f"IP: {fields['ip']}") - if fields.get("reason"): - lines.append(f"Reason: {fields['reason']}") - return lines - if action == "compliance_extraction_success": - lines = [f"Compliance extraction successful for message {fields.get('message_id', 'unknown')}"] - lines.append(f"Officer: {_format_user(fields)}") - sender_id = fields.get("sender_id") - recipient_id = fields.get("recipient_id") - if sender_id is not None and recipient_id is not None: - lines.append(f"Message: {_format_user({'username': fields.get('sender_username'), 'user_id': sender_id})} → {_format_user({'username': fields.get('recipient_username'), 'user_id': recipient_id})}") - if fields.get("ip"): - lines.append(f"IP: {fields['ip']}") - return lines - 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: {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: {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: {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: {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: {fields['ip']}") - return lines - if action == "admin_delete_user": - return [ - "Account removal", - f"Actor: {_format_actor(fields, 'actor')}", - f"Target: {_format_actor(fields, 'target')}", - ] - if action == "admin_suspend_user": - lines = [ - "User suspension", - f"Actor: {_format_actor(fields, 'actor')}", - f"Target: {_format_actor(fields, 'target')}", - ] - if fields.get("reason"): - lines.append(f"Reason: {fields.get('reason')}") - return lines - if action == "admin_unsuspend_user": - return [ - "User unsuspension", - f"Actor: {_format_actor(fields, 'actor')}", - f"Target: {_format_actor(fields, 'target')}", - ] - if action == "admin_verify_toggle": - return [ - "User verification", - f"Actor: {_format_actor(fields, 'actor')}", - f"Target: {_format_actor(fields, 'target')}", - f"Verified: {_yes_no(fields.get('verified'))}", - ] - if action == "self_delete_account": - return [f"User {_format_user(fields)} deleted their account"] - if action == "auto_suspension_public_spam": - lines = [ - f"Automatic suspension triggered for {_format_user(fields)}", - ] - match_type = fields.get("match_type") - if match_type: - lines.append(f"Match type: {match_type}") - similar = fields.get("similar_messages") - occurrences = fields.get("occurrences") - if similar: - lines.append(f"Similar messages detected: {similar}") - if occurrences and not similar: - lines.append(f"Occurrences: {occurrences}") - if fields.get("window_seconds"): - lines.append(f"Observation window: {fields['window_seconds']} seconds") - if fields.get("reason"): - lines.append(f"Reason: {fields['reason']}") - return lines - if action == "auto_suspension_public_burst": - lines = [ - f"Automatic suspension triggered for {_format_user(fields)}", - f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds", - ] - if fields.get("reason"): - lines.append(f"Reason: {fields['reason']}") - return lines - if action == "public_message_burst": - return [ - f"Rapid messaging spike for {_format_user(fields)}", - f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds", - ] - if action == "blocklist_add": - added = fields.get("added") or [] - lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"] - if added: - lines.append(f"Added entries: {', '.join(added)}") - total = len(fields.get("words") or []) - lines.append(f"Total entries: {total}") - return lines - if action == "blocklist_remove": - removed = fields.get("removed") or [] - lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"] - if removed: - lines.append(f"Removed entries: {', '.join(removed)}") - total = len(fields.get("words") or []) - lines.append(f"Total entries: {total}") - return lines - return [f"{action.replace('_', ' ').capitalize()}"] + [ - f"{key.replace('_', ' ').capitalize()}: {value}" - for key, value in fields.items() - if value is not None - ] - - -def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]: - if action == "message_created": - lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"] - if fields.get("reply_to"): - lines.append(f"In reply to message #{fields['reply_to']}") - attachments = fields.get("attachments") - if attachments: - lines.append(f"Attachments: {_plural('file', attachments)}") - - # If content was censored, log both raw and censored versions - if fields.get("raw_content") is not None: - lines.append("Raw content (before censoring):") - for line in unescape(fields["raw_content"]).splitlines(): - lines.append(f"| {line}") - lines.append("Censored content (stored):") - for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines(): - lines.append(f"| {line}") - elif fields.get("content"): - lines.append("Content:") - for line in unescape(fields["content"]).splitlines(): - lines.append(f"| {line}") - return lines - if action == "message_edited": - lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"] - if fields.get("reply_to"): - lines.append(f"Reply to #{fields['reply_to']}") - if fields.get("previous_content"): - lines.append("Previous content:") - for line in unescape(fields["previous_content"] or "").splitlines() or [""]: - lines.append(f"| {line}") - - # If content was censored, log both raw and censored versions - if fields.get("raw_content") is not None: - lines.append("Raw content (before censoring):") - for line in unescape(fields["raw_content"]).splitlines(): - lines.append(f"| {line}") - lines.append("Censored content (stored):") - for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines(): - lines.append(f"| {line}") - elif fields.get("content"): - lines.append("New content:") - for line in unescape(fields["content"] or "").splitlines() or [""]: - lines.append(f"| {line}") - - return lines - if action == "message_deleted": - lines = [ - f"Message #{fields.get('message_id')} deleted", - f"Actor: {_format_actor(fields, 'actor')}", - ] - if fields.get("original_author_id") is not None: - lines.append(f"Original author: user #{fields['original_author_id']}") - if fields.get("content"): - lines.append("Previous content:") - for line in unescape(fields["content"]).splitlines(): - lines.append(f"| {line}") - return lines - if action == "reaction_update": - lines = [ - f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}", - f"User: {_format_user(fields)}", - ] - if fields.get("emoji"): - lines.append(f"Emoji: {fields['emoji']}") - return lines - return [f"{action.replace('_', ' ').capitalize()}"] + [ - f"{key.replace('_', ' ').capitalize()}: {value}" - for key, value in fields.items() - if value is not None - ] - - -def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]: - if action in {"message_sent", "message_sent_ws"}: - lines = [ - f"Direct message #{fields.get('dm_envelope_id')} sent", - f"Sender: {_format_actor(fields, 'sender')}", - ] - if fields.get("recipient_id") is not None: - lines.append(f"Recipient: user id {fields['recipient_id']}") - attachments = fields.get("attachment_count") - if attachments: - lines.append(f"Attachments: {_plural('file', attachments)}") - if fields.get("reply_to"): - lines.append(f"In reply to DM #{fields['reply_to']}") - return lines - if action == "message_edited": - return [ - f"Direct message #{fields.get('dm_envelope_id')} edited", - f"Author: {_format_user(fields)}", - ] - if action == "message_deleted": - lines = [ - f"Direct message #{fields.get('dm_envelope_id')} deleted", - f"Actor: {_format_user(fields)}", - ] - if fields.get("recipient_id") is not None: - lines.append(f"Recipient: user id {fields['recipient_id']}") - return lines - if action == "reaction_update": - lines = [ - f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}", - f"User: {_format_user(fields)}", - ] - if fields.get("emoji"): - lines.append(f"Emoji: {fields['emoji']}") - return lines - return [f"{action.replace('_', ' ').capitalize()}"] + [ - f"{key.replace('_', ' ').capitalize()}: {value}" - for key, value in fields.items() - if value is not None - ] - - -def _render_access(action: str, fields: Dict[str, Any]) -> List[str]: - ip_raw = fields.get("ip") - ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw - if action == "http_request": - first_line = f"{fields.get('method')} {fields.get('path')}" - if ip_display: - first_line += f" from {ip_display}" - first_line += f" -> {fields.get('status')}" - lines = [first_line] - if fields.get("user"): - lines.append(f"Authenticated user: {_clean_username(fields['user'])}") - return lines - if action == "http_error": - first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}" - if ip_display: - first_line += f" from {ip_display}" - lines = [first_line] - if fields.get("error"): - lines.append(f"Exception: {fields['error']}") - if fields.get("user"): - lines.append(f"Authenticated user: {_clean_username(fields['user'])}") - return lines - if action == "ws_connect": - lines = ["WebSocket connected"] - if fields.get("path"): - lines.append(f"Endpoint: {fields['path']}") - if ip_display: - lines.append(f"IP: {ip_display}") - return lines - if action == "ws_disconnect": - lines = ["WebSocket disconnected"] - if fields.get("path"): - lines.append(f"Endpoint: {fields['path']}") - if fields.get("code") is not None: - reason = fields.get("reason") or "no reason" - lines.append(f"Code {fields['code']} ({reason})") - if ip_display: - lines.append(f"IP: {ip_display}") - return lines - if action == "ws_event": - event_name = fields.get("event") - path = fields.get("path") - first_line = "WS" - if path: - first_line += f" {path}" - if ip_display: - first_line += f" from {ip_display}" - if event_name: - first_line += f" -> {event_name}" - lines = [first_line] - if fields.get("user"): - lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}") - for key, value in fields.items(): - if key in {"path", "event", "user", "user_id", "ip"} or value is None: - continue - lines.append(f"{key.replace('_', ' ').capitalize()}: {value}") - return lines - if action == "compliance_access_attempt": - lines = [f"Compliance access attempt for message {fields.get('message_id', 'unknown')}"] - lines.append(f"User: {_format_user(fields)}") - if fields.get("ip"): - lines.append(f"IP: {fields['ip']}") - return lines - if action == "compliance_access_denied": - lines = [f"Compliance access denied for message {fields.get('message_id', 'unknown')}"] - lines.append(f"User: {_format_user(fields)}") - if fields.get("ip"): - lines.append(f"IP: {fields['ip']}") - if fields.get("reason"): - lines.append(f"Reason: {fields['reason']}") - return lines - if action == "compliance_access_failed": - lines = [f"Compliance access failed for message {fields.get('message_id', 'unknown')}"] - lines.append(f"User: {_format_user(fields)}") - if fields.get("ip"): - lines.append(f"IP: {fields['ip']}") - if fields.get("reason"): - lines.append(f"Reason: {fields['reason']}") - return lines - if action == "compliance_extraction_success": - lines = [f"Compliance extraction successful for message {fields.get('message_id', 'unknown')}"] - lines.append(f"Officer: {_format_user(fields)}") - sender_id = fields.get("sender_id") - recipient_id = fields.get("recipient_id") - if sender_id is not None and recipient_id is not None: - lines.append(f"Message: {_format_user({'username': fields.get('sender_username'), 'user_id': sender_id})} → {_format_user({'username': fields.get('recipient_username'), 'user_id': recipient_id})}") - if fields.get("ip"): - lines.append(f"IP: {fields['ip']}") - return lines - if action == "compliance_public_key_access": - lines = [f"Compliance public key accessed"] - if fields.get("ip"): - lines.append(f"IP: {fields['ip']}") - 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/services/main/security/profanity.py b/backend/services/main/security/profanity.py deleted file mode 100644 index 7091df4..0000000 --- a/backend/services/main/security/profanity.py +++ /dev/null @@ -1,694 +0,0 @@ -from __future__ import annotations - -import json -import re -import unicodedata -from pathlib import Path -from threading import RLock -from typing import Iterable, List, Set, Tuple - -from better_profanity import Profanity - -BLOCKLIST_PATH = Path("data/profanity/blocklist.json") -BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) - -_CUSTOM_RU_TERMS: Set[str] = { - "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", - "ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда", - "пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон", - "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", - "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор", - "пидоры", "пидорас", "пидорасы", "пидорасов", -} - -_ADULT_TERMS: Set[str] = { - "порно", "порнуха", "эротика", "эротический", "секс", "сексуальный", - "инцест", "порнография", "порностудия", "порновидео", "порносайт", - "сексчат", "сексчатик", "секслайв", "сексвидео", -} - -_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS)) - -# Words that should never be flagged as profanity (whitelist) -_WHITELIST: Set[str] = { - "говно", # Allow this word -} - -# Phrase patterns - these will be applied to normalized text (without special chars) -_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = ( - re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), -) - -# Patterns to check in original text (before normalization) to catch visual bypasses -# These patterns check for special character combinations that visually form letters -_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = ( - # Catch "}{" used to visually form "х" followed by "С0С" or similar patterns - # This catches "хуесос" written as "}{¥€С0С" or variations - # Matches: }{ + any characters (including special chars) + С/с + 0 + С/с - # The pattern allows any characters between to catch special chars like ¥€ - re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE), - # Also catch "}{" followed by "уесос" with 0 instead of о - re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE), -) - -# Map for normalizing homoglyphs (similar-looking characters) -# Maps English/Latin characters to their Cyrillic equivalents and vice versa -# Also includes Greek, full-width, and other Unicode variants -_LEET_MAP = { - # Numbers to letters - "0": "о", - "1": "и", - "3": "е", - "4": "а", - # Latin to Cyrillic (lowercase) - "a": "а", - "c": "с", - "e": "е", - "f": "ф", - "g": "г", - "i": "и", - "m": "м", - "n": "н", - "o": "о", - "p": "п", - "s": "с", - "t": "т", - "u": "у", - "v": "в", - "x": "х", - "y": "у", - "z": "з", # English 'z' to Cyrillic 'з' - # Latin to Cyrillic (uppercase) - "A": "а", - "C": "с", - "E": "е", - "F": "ф", - "G": "г", - "I": "и", - "M": "м", - "N": "н", - "O": "о", - "P": "п", - "S": "с", - "T": "т", - "U": "у", - "V": "в", - "X": "х", - "Y": "у", - "Z": "з", # English 'Z' to Cyrillic 'з' - # Greek letters that look like Cyrillic/Latin - "α": "а", # Greek alpha - "Α": "а", - "ο": "о", # Greek omicron - "Ο": "о", - "ρ": "р", # Greek rho (looks like Cyrillic р) - "Ρ": "р", - "υ": "у", # Greek upsilon - "Υ": "у", - "χ": "х", # Greek chi - "Χ": "х", - "ε": "е", # Greek epsilon - "Ε": "е", - "ι": "и", # Greek iota - "Ι": "и", - "ν": "н", # Greek nu - "Ν": "н", - "μ": "м", # Greek mu - "Μ": "м", - "π": "п", # Greek pi - "Π": "п", - "τ": "т", # Greek tau - "Τ": "т", - "γ": "г", # Greek gamma - "Γ": "г", - "σ": "с", # Greek sigma - "Σ": "с", - "φ": "ф", # Greek phi - "Φ": "ф", - # Full-width Latin characters - "a": "а", - "A": "а", - "c": "с", - "C": "с", - "e": "е", - "E": "е", - "f": "ф", - "F": "ф", - "g": "г", - "G": "г", - "i": "и", - "I": "и", - "m": "м", - "M": "м", - "n": "н", - "N": "н", - "o": "о", - "O": "о", - "p": "п", - "P": "п", - "s": "с", - "S": "с", - "t": "т", - "T": "т", - "u": "у", - "U": "у", - "v": "в", - "V": "в", - "x": "х", - "X": "х", - "y": "у", - "Y": "у", - "z": "з", # Full-width 'z' to Cyrillic 'з' - "Z": "з", - # Cyrillic to canonical Cyrillic (identity mappings) - "а": "а", - "с": "с", - "е": "е", - "ё": "е", - "ф": "ф", - "г": "г", - "и": "и", - "м": "м", - "н": "н", - "о": "о", - "п": "п", - "т": "т", - "у": "у", - "ү": "у", # Cyrillic capital U (U+04AE) - "Ү": "у", # Cyrillic capital U (U+04AE) - "в": "в", - "х": "х", - "р": "р", - "з": "з", # Cyrillic 'з' - "д": "д", # Cyrillic 'д' - "б": "б", # Cyrillic 'б' - "л": "л", # Cyrillic 'л' - "я": "я", # Cyrillic 'я' - "н": "н", # Already mapped, but explicit - # Special characters - "@": "а", - # Multi-character visual bypasses (handled separately in preprocessing) - # "}{" visually forms "х" - handled in _preprocess_visual_bypasses -} - -_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( - ("generic", ("айфон", "топ")), - ("generic", ("самсунг", "говно")), -) - -_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json") -_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {} - - -def _preprocess_visual_bypasses(text: str) -> str: - """ - Preprocess text to convert multi-character visual bypasses to their intended letters. - This handles cases like "}{" visually forming "х". - """ - result = text - # Convert "}{" to "х" (visual bypass for Cyrillic х) - # The curly braces visually form the letter х when placed together - result = result.replace("}{", "х") - return result - - -def _normalize_char(ch: str) -> str: - """Normalize a single character, mapping homoglyphs to canonical form.""" - # First try direct mapping (preserves case for non-mapped chars) - if ch in _LEET_MAP: - return _LEET_MAP[ch] - # Then try lowercase mapping - lower = ch.lower() - if lower in _LEET_MAP: - return _LEET_MAP[lower] - # If no mapping and character is ASCII letter, return lowercase - # This preserves English words like "fromchat" as-is - if ch.isascii() and ch.isalpha(): - return lower - # For other characters, return lowercase for consistency - return lower - - -def _normalize_token(token: str) -> str: - """Normalize a token by mapping all homoglyphs.""" - return "".join(_normalize_char(ch) for ch in token) - - -def _normalize_text_for_profanity(text: str) -> str: - """ - Normalize entire text by mapping homoglyphs to canonical forms. - This prevents bypasses like using English 'u' instead of Russian 'у'. - """ - return "".join(_normalize_char(ch) for ch in text) - - -def _strip_zero_width_chars(text: str) -> str: - """ - Remove zero-width characters that could be used to bypass filters. - """ - # Zero-width space, zero-width non-joiner, zero-width joiner, etc. - zero_width_chars = [ - '\u200B', # Zero-width space - '\u200C', # Zero-width non-joiner - '\u200D', # Zero-width joiner - '\uFEFF', # Zero-width no-break space - '\u2060', # Word joiner - '\u2061', # Function application - '\u2062', # Invisible times - '\u2063', # Invisible separator - '\u2064', # Invisible plus - ] - result = text - for zw_char in zero_width_chars: - result = result.replace(zw_char, '') - return result - - -def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]: - """ - Extract only alphanumeric characters from text and create a mapping - from normalized positions to original positions. - - Args: - preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching) - - Returns: - (normalized_text, position_map) where position_map[i] is the original - position of the i-th character in normalized_text - """ - # First preprocess visual bypasses (like "}{" -> "х") - text = _preprocess_visual_bypasses(text) - - # Then normalize Unicode (composed vs decomposed) - normalized_unicode = unicodedata.normalize('NFKC', text) - - # For phrase matching, convert zero-width chars to spaces instead of stripping - if preserve_spaces: - zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064'] - for zw_char in zero_width_chars: - normalized_unicode = normalized_unicode.replace(zw_char, ' ') - else: - # Strip zero-width characters - normalized_unicode = _strip_zero_width_chars(normalized_unicode) - - normalized = [] - position_map = [] - - for i, ch in enumerate(normalized_unicode): - # Check if character is alphanumeric (including Cyrillic) - if ch.isalnum(): - # For phrase matching, preserve ASCII letters as-is (just lowercase) - # to allow English words in patterns to match - if preserve_spaces and ch.isascii() and ch.isalpha(): - normalized.append(ch.lower()) - else: - # Normalize this character (homoglyphs, Cyrillic, etc.) - normalized.append(_normalize_char(ch)) - position_map.append(i) - elif preserve_spaces: - # For phrase matching, treat any whitespace or non-alphanumeric as word separator - if ch.isspace() or not ch.isalnum(): - # Normalize to single space to allow patterns to match - if normalized and normalized[-1] != ' ': # Don't add consecutive spaces - normalized.append(' ') - position_map.append(i) - - return "".join(normalized), position_map - - -def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]: - """ - Check for profane words as substrings or subsequences in normalized text. - This catches cases like "хуй" in "хууй" (with extra characters). - Returns list of (start, end) positions where profanity is found. - """ - spans = [] - normalized_lower = normalized_text.lower() - - for word in profane_words: - word_lower = word.lower() - - # First try exact substring match - start = 0 - while True: - pos = normalized_lower.find(word_lower, start) - if pos == -1: - break - spans.append((pos, pos + len(word_lower))) - start = pos + 1 - - # Also check if profane word appears as a subsequence (allowing extra chars) - # This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй" - # Now applies to ALL words, not just length >= 4, to prevent bypasses - word_chars = list(word_lower) - text_chars = list(normalized_lower) - - # Stricter span limits based on word length to prevent false positives - # Shorter words get much stricter limits - if len(word_lower) <= 3: - max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй") - elif len(word_lower) == 4: - max_span_ratio = 1.4 # Strict for 4-char words - elif len(word_lower) <= 5: - max_span_ratio = 1.5 # Moderate for 5-char words - else: - max_span_ratio = 1.8 # Slightly more lenient for longer words - - # Try to find the word as a subsequence - i = 0 # position in text - j = 0 # position in word - seq_start = None - - while i < len(text_chars) and j < len(word_chars): - if text_chars[i] == word_chars[j]: - if seq_start is None: - seq_start = i - j += 1 - if j == len(word_chars): - # Found the word as subsequence - seq_end = i + 1 - # Check if the span is reasonable (not too long) - span_length = seq_end - seq_start - max_allowed_span = int(len(word_lower) * max_span_ratio) - if span_length <= max_allowed_span: - # Only add if it's not already covered by exact match - if (seq_start, seq_end) not in spans: - spans.append((seq_start, seq_end)) - # Reset to find next occurrence - continue from after the end of this match - next_start = seq_start + 1 - seq_start = None - j = 0 - i = next_start - continue - i += 1 - - return spans - - -def _check_profanity_in_normalized(normalized_text: str) -> bool: - """ - Check if normalized text contains profanity. - Uses both better_profanity library and substring matching for better detection. - - Returns True if profanity is found. - """ - if not normalized_text: - return False - - # Check normalized text for profanity using better_profanity - censored = _profanity.censor(normalized_text, censor_char="\\*") - - # Check if better_profanity found anything - if "*" in censored: - return True - - # Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня") - profane_words = _STATIC_TERMS - substring_spans = _check_profanity_substrings(normalized_text, profane_words) - - # If we found any substring matches, there's profanity - if substring_spans: - return True - - return False - - -def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]: - tokens: List[Tuple[int, int, str]] = [] - start: int | None = None - buffer: List[str] = [] - - for idx, ch in enumerate(text): - if ch.isalnum() or ch in {"@", "#", "_"}: - if start is None: - start = idx - buffer.append(ch) - else: - if buffer and start is not None: - token_raw = "".join(buffer) - tokens.append((start, idx, _normalize_token(token_raw))) - buffer.clear() - start = None - if buffer and start is not None: - token_raw = "".join(buffer) - tokens.append((start, len(text), _normalize_token(token_raw))) - return tokens - - -def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool: - if a == b: - return True - if max_distance <= 0: - return False - if abs(len(a) - len(b)) > max_distance: - return False - - previous = list(range(len(b) + 1)) - for i, ca in enumerate(a, 1): - current = [i] - best = current[0] - for j, cb in enumerate(b, 1): - insert_cost = current[j - 1] + 1 - delete_cost = previous[j] + 1 - replace_cost = previous[j - 1] + (0 if ca == cb else 1) - cost = min(insert_cost, delete_cost, replace_cost) - current.append(cost) - if cost < best: - best = cost - if best > max_distance: - return False - previous = current - return previous[-1] <= max_distance - - -def _load_sensitive_phrases() -> List[Tuple[str, ...]]: - if not _SENSITIVE_PHRASE_PATH.exists(): - return [] - try: - payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8")) - phrases: List[Tuple[str, ...]] = [] - if isinstance(payload, list): - for entry in payload: - if isinstance(entry, list) and entry: - normalized = tuple(str(part).strip() for part in entry if str(part).strip()) - if normalized: - phrases.append(normalized) - return phrases - except Exception: - return [] - - -def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]: - if group not in _PHRASE_CACHE: - base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group] - if group == "sensitive": - base.extend(_load_sensitive_phrases()) - _PHRASE_CACHE[group] = tuple( - tuple(_normalize_token(part) for part in phrase) - for phrase in base - ) - return _PHRASE_CACHE[group] - - -def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]: - tokens = _tokenize_with_spans(text) - if not tokens: - return [] - - spans: List[Tuple[int, int]] = [] - normalized_phrases = _get_phrases(group) - - for index in range(len(tokens)): - for phrase in normalized_phrases: - if index + len(phrase) > len(tokens): - continue - matches = True - for offset, target in enumerate(phrase): - token = tokens[index + offset][2] - if not _edit_distance_limited(token, target): - matches = False - break - if matches: - span_start = tokens[index][0] - span_end = tokens[index + len(phrase) - 1][1] - spans.append((span_start, span_end)) - return spans - -_dictionary_lock = RLock() -_blocklist_signature: Tuple[str, ...] | None = None -_profanity = Profanity() - - -def _normalize_words(words: Iterable[str]) -> Set[str]: - normalized: Set[str] = set() - for raw in words: - if not raw: - continue - cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower() - if cleaned: - normalized.add(cleaned) - return normalized - - -def _load_blocklist() -> Set[str]: - if not BLOCKLIST_PATH.exists(): - return set() - try: - data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) - if isinstance(data, list): - return _normalize_words(data) - except Exception: - pass - return set() - - -def _write_blocklist(words: Iterable[str]) -> None: - BLOCKLIST_PATH.write_text( - json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n", - encoding="utf-8" - ) - - -def _rebuild_dictionary(force: bool = False) -> None: - global _profanity, _blocklist_signature - with _dictionary_lock: - blocklist_list = sorted(_load_blocklist()) - signature = tuple(blocklist_list) - if not force and _blocklist_signature == signature and _blocklist_signature is not None: - return - - profanity = Profanity() - profanity.load_censor_words() - # Remove whitelisted words from the default word list - try: - for word in _WHITELIST: - profanity.remove_censor_words([word]) - except AttributeError: - # If remove_censor_words doesn't exist, we'll handle it in post-processing - pass - combined = set(_STATIC_TERMS) - combined.update(blocklist_list) - # Remove whitelisted words from our custom terms - combined -= _WHITELIST - if combined: - profanity.add_censor_words(list(combined)) - - _profanity = profanity - _blocklist_signature = signature - - -def _check_phrase_patterns(text: str) -> bool: - """ - Check if text matches any phrase patterns. - Returns True if any pattern matches. - """ - # Normalize text for phrase matching (remove special chars but preserve spaces) - normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True) - normalized_lower = normalized_text.lower() - - # Check phrase patterns - for pattern in _PHRASE_PATTERNS: - if pattern.search(normalized_lower): - return True - - # Check fuzzy phrase spans - if _find_fuzzy_phrase_spans(normalized_lower, "generic"): - return True - - return False - - -def contains_profanity(text: str) -> bool: - """ - Check if text contains profanity. - Returns True if profanity is detected. - """ - if not text: - return False - - _rebuild_dictionary() - - # Check original text patterns first (before normalization) to catch visual bypasses - # like "}{" used to form "х" - for pattern in _ORIGINAL_TEXT_PATTERNS: - if pattern.search(text): - return True - - # Check phrase patterns - if _check_phrase_patterns(text): - return True - - # Normalize text for whitelist matching (to handle special characters) - normalized_for_whitelist, _ = _extract_alphanumeric_with_mapping(text) - normalized_for_whitelist_lower = normalized_for_whitelist.lower() - - # Check if text contains whitelisted words - if the entire text is a whitelisted word, skip profanity check - for whitelist_word in _WHITELIST: - normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) - normalized_whitelist_lower = normalized_whitelist.lower() - - # Check if the normalized text exactly matches a whitelisted word - if normalized_for_whitelist_lower == normalized_whitelist_lower: - return False - - # Extract only alphanumeric characters and normalize homoglyphs - # This removes special characters, emojis, etc. that could be used to bypass the filter - normalized_text, _ = _extract_alphanumeric_with_mapping(text) - - # Check profanity on normalized text (without special characters) - return _check_profanity_in_normalized(normalized_text) - - -def contains_sensitive_phrase(text: str) -> bool: - if not text: - return False - if _find_fuzzy_phrase_spans(text, "sensitive"): - return True - return False - - -def get_blocklist() -> List[str]: - with _dictionary_lock: - return sorted(_load_blocklist()) - - -def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]: - normalized = _normalize_words(words) - if not normalized: - return [], get_blocklist() - - with _dictionary_lock: - current = _load_blocklist() - added = sorted(normalized - current) - if not added: - return [], sorted(current) - - updated = sorted(current | normalized) - _write_blocklist(updated) - _rebuild_dictionary(force=True) - return added, updated - - -def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]: - normalized = _normalize_words(words) - if not normalized: - return [], get_blocklist() - - with _dictionary_lock: - current = _load_blocklist() - removed = sorted(word for word in normalized if word in current) - if not removed: - return [], sorted(current) - - updated = sorted(current - normalized) - _write_blocklist(updated) - _rebuild_dictionary(force=True) - return removed, updated - diff --git a/backend/services/main/security/rate_limit.py b/backend/services/main/security/rate_limit.py deleted file mode 100644 index f452767..0000000 --- a/backend/services/main/security/rate_limit.py +++ /dev/null @@ -1,232 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import time -from typing import Callable -from fastapi import Request -from slowapi import Limiter -from slowapi.util import get_remote_address - -from ..utils import get_client_ip - -logger = logging.getLogger("uvicorn.error") - -def get_ip_key(request: Request) -> str: - """Get rate limit key based on IP address.""" - return get_client_ip(request) or get_remote_address(request) - -# Initialize limiter with IP-based key function -# Note: We don't set default_limits to avoid affecting all users if one IP is attacked. -# Each endpoint should have an explicit rate limit based on its sensitivity. -# Rate limits automatically expire after the time window - IPs are not permanently blocked. -limiter = Limiter( - key_func=get_ip_key, - default_limits=[], # No global default - each endpoint must have explicit limits - storage_uri="memory://", # In-memory storage (can be changed to Redis later) -) - - -# Rate limit decorator for IP-based limiting -def rate_limit_per_ip(limit: str) -> Callable: - """Rate limit based on IP address.""" - return limiter.limit(limit, key_func=get_ip_key) - - -def _get_storage_dict(storage) -> dict | None: - """Get the internal storage dictionary from slowapi's memory storage.""" - if hasattr(storage, "_storage") and isinstance(storage._storage, dict): - return storage._storage - elif hasattr(storage, "storage") and isinstance(storage.storage, dict): - return storage.storage - return None - - -def reset_all_rate_limits() -> int: - """ - Reset all rate limits by clearing the storage. - This should be called on startup to ensure a clean state. - Returns the number of entries cleared. - """ - try: - # Access the private _storage attribute - storage = limiter._storage - storage_dict = _get_storage_dict(storage) - - if storage_dict is None: - # Try using the storage's reset method if available - if hasattr(storage, "reset"): - try: - # Try reset() with no args first (clears all) - storage.reset() - logger.info("Reset all rate limits on startup using storage.reset()") - return 1 # Assume it worked - except TypeError: - # reset() might require arguments, try clearing differently - try: - # Some storage backends need explicit clearing - if hasattr(storage, "clear"): - storage.clear() - logger.info("Reset all rate limits on startup using storage.clear()") - return 1 - except Exception: - pass - except Exception: - pass - logger.warning("Could not reset rate limits: storage dict not accessible and no reset method") - return 0 - - count = len(storage_dict) - if count > 0: - storage_dict.clear() - logger.info(f"Reset all rate limits on startup: cleared {count} entries") - return count - except Exception as e: - logger.warning(f"Failed to reset rate limits on startup: {e}") - return 0 - - -def reset_rate_limit_for_ip(ip: str) -> bool: - """ - Manually reset rate limit for a specific IP address. - This clears all rate limit entries for the given IP. - Returns True if any entries were cleared, False otherwise. - """ - if not ip: - return False - - try: - # Access the private _storage attribute - storage = limiter._storage - storage_dict = _get_storage_dict(storage) - - if storage_dict is None: - # Try alternative methods - if hasattr(storage, "reset"): - try: - storage.reset(ip) - return True - except Exception: - pass - return False - - cleared = False - # slowapi stores entries with keys like "LIMITER:{ip}:{endpoint}" - # We need to find all keys that contain this IP - # Also handle cases where IP might be in different positions - keys_to_remove = [] - - for key in list(storage_dict.keys()): - if isinstance(key, str): - # Check multiple patterns: - # - "LIMITER:{ip}:{endpoint}" - # - Keys containing the IP anywhere - # - Keys starting with the IP - if (key.startswith(f"LIMITER:{ip}:") or - key.startswith(f"LIMITER:{ip}") or - f":{ip}:" in key or - key.endswith(f":{ip}") or - (ip in key and "LIMITER" in key)): - keys_to_remove.append(key) - - for key in keys_to_remove: - try: - del storage_dict[key] - cleared = True - logger.info(f"Cleared rate limit key: {key}") - except KeyError: - pass - - if cleared: - logger.info(f"Successfully cleared rate limits for IP: {ip}") - else: - logger.warning(f"No rate limit entries found for IP: {ip}") - - return cleared - except Exception as e: - logger.warning(f"Failed to reset rate limit for IP {ip}: {e}") - return False - - -def clear_all_rate_limits() -> int: - """ - Clear all rate limit entries. Use with caution - this affects all IPs. - Returns the number of entries cleared. - """ - try: - # Access the private _storage attribute - storage = limiter._storage - storage_dict = _get_storage_dict(storage) - - if storage_dict is None: - return 0 - - count = len(storage_dict) - storage_dict.clear() - logger.warning(f"Cleared all {count} rate limit entries") - return count - except Exception as e: - logger.error(f"Failed to clear all rate limits: {e}") - return 0 - - -def cleanup_expired_rate_limits() -> int: - """ - Clean up expired rate limit entries from memory storage. - This helps prevent rate limits from being stuck indefinitely. - Returns the number of entries cleaned up. - """ - try: - # Access the private _storage attribute - storage = limiter._storage - storage_dict = _get_storage_dict(storage) - - if storage_dict is None: - return 0 - - # slowapi's memory storage stores entries as tuples: (count, reset_time) - # Entries should expire naturally, but we'll clean up any that are clearly expired - now = time.time() - cleaned = 0 - keys_to_remove = [] - - for key, value in storage_dict.items(): - if isinstance(value, (tuple, list)) and len(value) >= 2: - # Check if reset_time has passed (with some buffer) - reset_time = value[1] if isinstance(value[1], (int, float)) else 0 - # Add 60 second buffer to ensure we don't remove active entries - if reset_time > 0 and now > (reset_time + 60): - keys_to_remove.append(key) - elif isinstance(value, dict): - # Some storage formats use dicts with 'expiry' or 'reset' fields - expiry = value.get("expiry") or value.get("reset") or value.get("reset_time") - if expiry and isinstance(expiry, (int, float)) and now > (expiry + 60): - keys_to_remove.append(key) - - for key in keys_to_remove: - try: - del storage_dict[key] - cleaned += 1 - except KeyError: - pass - - if cleaned > 0: - logger.info(f"Cleaned up {cleaned} expired rate limit entries") - - return cleaned - except Exception as e: - logger.warning(f"Failed to cleanup expired rate limits: {e}") - return 0 - - -async def start_rate_limit_cleanup_task() -> None: - """Start a background task to periodically clean up expired rate limit entries.""" - while True: - try: - await asyncio.sleep(300) # Run every 5 minutes - cleanup_expired_rate_limits() - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"Error in rate limit cleanup task: {e}") - await asyncio.sleep(60) # Wait 1 minute before retrying \ No newline at end of file diff --git a/backend/services/main/service_calls.py b/backend/services/main/service_calls.py deleted file mode 100644 index de19dce..0000000 --- a/backend/services/main/service_calls.py +++ /dev/null @@ -1,830 +0,0 @@ -""" -Helper functions for inter-service communication used by the main service. - -Behavior: -- In development (single-process) the helpers call the in-process service modules directly. -- In Docker/production the helpers perform HTTP calls to the configured service URLs. -""" -from typing import Optional, Dict, Any -import os -import logging -import json -from pathlib import Path - -import httpx -from fastapi import HTTPException, status - -# Import request models for in-process calls - -logger = logging.getLogger("uvicorn.error") - - -def _default_file_storage_base_url() -> str: - lan = os.getenv("LAN_IP", "").strip() - if lan: - return f"http://{lan}:8302" - return "http://127.0.0.1:8302" - - -def _get_messaging_module(): - try: - from backend.services.messaging import main as messaging_module - return messaging_module - except Exception: - try: - from services.messaging import main as messaging_module # type: ignore - return messaging_module - except Exception: - return None - - -def _get_file_storage_module(): - try: - from backend.services.file_storage import main as storage_module - return storage_module - except Exception: - try: - from services.file_storage import main as storage_module # type: ignore - return storage_module - except Exception: - return None - - -async def get_messaging_transport_public_key(timeout: float = 5.0) -> Dict[str, Any]: - """ - Return messaging service ephemeral transport public key. - """ - mod = _get_messaging_module() - if mod: - # in-process async call - try: - return await mod.get_transport_public_key() # type: ignore - except Exception as e: - logger.error("In-process messaging.get_transport_public_key failed: %s", e) - raise - - # Out-of-process HTTP - messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301") - url = f"{messaging_url.rstrip('/')}/key/transport/public" - try: - try: - import httpx - r = httpx.get(url, timeout=timeout) - r.raise_for_status() - return r.json() - except Exception: - from urllib import request - with request.urlopen(url, timeout=timeout) as r: - return json.loads(r.read()) - except Exception as e: - logger.error("Failed to fetch messaging transport public key: %s", e) - raise - - -async def get_compliance_public_key(timeout: float = 5.0) -> Dict[str, Any]: - """ - Return compliance system public key (for MEK wrapping). - """ - try: - from services.shared.message_retention import get_message_retention - except ImportError: - from backend.services.shared.message_retention import get_message_retention # type: ignore - if get_message_retention().never_store_compliance_mek(): - return {"public_key_b64": ""} - - mod = _get_messaging_module() - if mod: - # in-process async call - try: - key = mod.get_compliance_public_key() - return {"public_key_b64": key} - except Exception as e: - logger.error("In-process messaging.get_compliance_public_key failed: %s", e) - raise - - # Out-of-process: Compliance key should be configured via environment variable - # The compliance public key is not exposed via HTTP for security reasons - compliance_key = os.getenv("COMPLIANCE_PUBLIC_KEY", "").strip() - if compliance_key: - return {"public_key_b64": compliance_key} - - logger.error("COMPLIANCE_PUBLIC_KEY environment variable not set and messaging service not available in-process") - raise RuntimeError("Compliance public key not available - set COMPLIANCE_PUBLIC_KEY environment variable") - - -async def invalidate_messaging_key(timeout: float = 5.0) -> Dict[str, Any]: - """ - Request messaging service to invalidate its current ephemeral transport key (rotate). - """ - mod = _get_messaging_module() - if mod: - try: - return await mod.invalidate_transport_key() # type: ignore - except Exception as e: - logger.error("In-process messaging.invalidate_transport_key failed: %s", e) - raise - - messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301") - url = f"{messaging_url.rstrip('/')}/key/transport/invalidate" - try: - try: - import httpx - r = httpx.post(url, timeout=timeout) - r.raise_for_status() - return r.json() - except Exception: - from urllib import request - req = request.Request(url, method="POST") - with request.urlopen(req, timeout=timeout) as r: - return json.loads(r.read()) - except Exception as e: - logger.error("Failed to invalidate messaging key: %s", e) - raise - - -async def upload_file_to_storage(file_obj: Any, timeout: float = 30.0) -> Dict[str, Any]: - """ - Upload a file to file storage service. Returns JSON response. - In-process: calls the in-process service. - Out-of-process: performs HTTP call to configured service URL. - """ - mod = _get_file_storage_module() - if mod: - try: - # Call the upload endpoint directly on the in-process module - return await mod.upload_file(None, file_obj) # type: ignore - except Exception as e: - logger.error("In-process file_storage.upload_file failed: %s", e) - raise - - # Out-of-process HTTP - # Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev - storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() - url = f"{storage_url.rstrip('/')}/upload" - try: - try: - import httpx - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.post(url, files={"file": file_obj}) - r.raise_for_status() - return r.json() - except Exception: - from urllib import request - # Synchronous fallback using urllib - req = request.Request(url, method="POST") - if hasattr(file_obj, "read"): - data = file_obj.read() - else: - data = file_obj - req.data = data - req.add_header("Content-Type", "application/octet-stream") - with request.urlopen(req, timeout=timeout) as r: - return json.loads(r.read()) - except Exception as e: - logger.error("Failed to upload file to storage: %s", e) - raise - - -async def store_encrypted_file( - encrypted_file_data_b64: str, - filename: str, - content_type: str = "application/octet-stream", - sender_id: int = None, - recipient_id: int = None, - timeout: float = 30.0, -) -> Dict[str, Any]: - """ - Store an encrypted file (base64 encoded) in the file storage service. - - Returns: - { - "file_id": stored filename, - "filename": original filename, - "size": file size in bytes, - "path": access path - } - """ - mod = _get_file_storage_module() - # Build allowed users list once for both in-process and HTTP modes - allowed_user_ids: list[int] = [] - if sender_id is not None: - allowed_user_ids.append(sender_id) - if recipient_id is not None: - allowed_user_ids.append(recipient_id) - - if mod: - try: - # In-process: call internal function directly - return await mod.upload_base64_internal( - filename=filename, - data_b64=encrypted_file_data_b64, - content_type=content_type, - allowed_user_ids=allowed_user_ids, - ) - except Exception as e: - logger.error("In-process file_storage.store_encrypted_file failed: %s", e) - raise - - # Out-of-process HTTP - # Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() - url = f"{file_storage_url.rstrip('/')}/upload-base64" - try: - try: - import httpx - - payload = { - "filename": filename, - "data_b64": encrypted_file_data_b64, - "content_type": content_type, - "allowed_user_ids": allowed_user_ids, - } - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.post(url, json=payload) - r.raise_for_status() - return r.json() - except Exception: - from urllib import request - - payload = { - "filename": filename, - "data_b64": encrypted_file_data_b64, - "content_type": content_type, - "allowed_user_ids": allowed_user_ids, - } - req = request.Request(url, method="POST") - req.data = json.dumps(payload).encode("utf-8") - req.add_header("Content-Type", "application/json") - with request.urlopen(req, timeout=timeout) as r: - return json.loads(r.read()) - except Exception as e: - logger.error("Failed to store encrypted file: %s", e) - # Fallback: attempt to store the file locally under data/file_storage/files - try: - import base64 - from pathlib import Path - import uuid - - # Store encrypted files in the same directory the messaging service serves from - FILES_DIR = Path("data/uploads/files/encrypted") - FILES_DIR.mkdir(parents=True, exist_ok=True) - - decoded = base64.b64decode(encrypted_file_data_b64) - stored_name = f"{uuid.uuid4().hex}_{filename}" - dest = FILES_DIR / stored_name - with open(dest, "wb") as f: - f.write(decoded) - try: - dest.chmod(0o644) - except Exception: - logger.debug("Could not chmod fallback file %s", dest) - - logger.info("FALLBACK: Stored encrypted file locally: %s", dest) - return { - "file_id": stored_name, - "filename": filename, - "size": len(decoded), - "path": f"/uploads/files/encrypted/{stored_name}", - } - except Exception as e2: - logger.exception("Fallback local storage failed: %s", e2) - raise - - -async def process_message_in_messaging_service( - client_public_key_b64: str, - transport_nonce_b64: str, - transport_ciphertext_b64: str, - compliance_public_key_b64: str, - sender_public_key_b64: str, - recipient_public_key_b64: str, - timeout: float = 5.0, -) -> Dict[str, Any]: - """ - Process an encrypted message through the messaging service envelope encryption pipeline. - - In-process: calls the in-process service. - Out-of-process: performs HTTP call to configured service URL. - - Args: - client_public_key_b64: Client's ephemeral public key - transport_nonce_b64: Nonce for transport encryption - transport_ciphertext_b64: Encrypted message - compliance_public_key_b64: Compliance system public key - sender_public_key_b64: Sender's public key - recipient_public_key_b64: Recipient's public key - timeout: Request timeout in seconds - - Returns: - Dict with encrypted message and wrapped MEKs: - { - "nonce": base64-encoded nonce, - "ciphertext": base64-encoded ciphertext, - "compliance_wrapped_mek": wrapped MEK, - "sender_wrapped_mek": wrapped MEK, - "recipient_wrapped_mek": wrapped MEK, - } - """ - mod = _get_messaging_module() - if mod: - try: - # In-process: call the process endpoint directly - return await mod.process_message( - client_public_key_b64=client_public_key_b64, - transport_nonce_b64=transport_nonce_b64, - transport_ciphertext_b64=transport_ciphertext_b64, - compliance_public_key_b64=compliance_public_key_b64, - sender_public_key_b64=sender_public_key_b64, - recipient_public_key_b64=recipient_public_key_b64, - ) # type: ignore - except Exception as e: - logger.error("In-process messaging.process_message failed: %s", e) - raise - - # Out-of-process HTTP - messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301") - url = f"{messaging_url.rstrip('/')}/process" - try: - try: - import httpx - payload = { - "client_public_key_b64": client_public_key_b64, - "transport_nonce_b64": transport_nonce_b64, - "transport_ciphertext_b64": transport_ciphertext_b64, - "compliance_public_key_b64": compliance_public_key_b64, - "sender_public_key_b64": sender_public_key_b64, - "recipient_public_key_b64": recipient_public_key_b64, - } - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.post(url, json=payload) - r.raise_for_status() - return r.json() - except Exception: - from urllib import request - payload = { - "client_public_key_b64": client_public_key_b64, - "transport_nonce_b64": transport_nonce_b64, - "transport_ciphertext_b64": transport_ciphertext_b64, - "compliance_public_key_b64": compliance_public_key_b64, - "sender_public_key_b64": sender_public_key_b64, - "recipient_public_key_b64": recipient_public_key_b64, - } - req = request.Request(url, method="POST") - req.data = json.dumps(payload).encode("utf-8") - req.add_header("Content-Type", "application/json") - with request.urlopen(req, timeout=timeout) as r: - return json.loads(r.read()) - except Exception as e: - logger.error("Failed to process message in messaging service: %s", e) - raise - - -async def process_message_with_files_in_messaging_service( - client_public_key_b64: str, - transport_nonce_b64: str, - transport_ciphertext_b64: str, - compliance_public_key_b64: str, - sender_public_key_b64: str, - recipient_public_key_b64: str, - transport_files: list[dict[str, str]], - timeout: float = 60.0, -) -> Dict[str, Any]: - """ - Process an encrypted message and transport-encrypted files using a single MEK. - - Returns: - { - "message": {"nonce": str, "ciphertext": str}, - "files": [{"nonce": str, "ciphertext": str}, ...], - "compliance_wrapped_mek": str, - "sender_wrapped_mek": str, - "recipient_wrapped_mek": str, - } - """ - mod = _get_messaging_module() - if mod: - try: - return await mod.process_message_with_files( # type: ignore - client_public_key_b64=client_public_key_b64, - transport_nonce_b64=transport_nonce_b64, - transport_ciphertext_b64=transport_ciphertext_b64, - compliance_public_key_b64=compliance_public_key_b64, - sender_public_key_b64=sender_public_key_b64, - recipient_public_key_b64=recipient_public_key_b64, - transport_files=transport_files, - ) - except Exception as e: - logger.error("In-process messaging.process_message_with_files failed: %s", e) - raise - - messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301") - url = f"{messaging_url.rstrip('/')}/process-with-files" - payload = { - "client_public_key_b64": client_public_key_b64, - "transport_nonce_b64": transport_nonce_b64, - "transport_ciphertext_b64": transport_ciphertext_b64, - "compliance_public_key_b64": compliance_public_key_b64, - "sender_public_key_b64": sender_public_key_b64, - "recipient_public_key_b64": recipient_public_key_b64, - "files": transport_files, - } - try: - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.post(url, json=payload) - r.raise_for_status() - return r.json() - except httpx.HTTPStatusError as e: - if e.response.status_code == status.HTTP_400_BAD_REQUEST: - try: - body = e.response.json() - detail = body.get("detail", str(body)) if isinstance(body, dict) else str(body) - except Exception: - detail = (e.response.text or "").strip() or str(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=detail, - ) from e - logger.error("Failed to process message+files in messaging service: %s", e) - raise - except Exception as e: - logger.error("Failed to process message+files in messaging service: %s", e) - raise - - -async def init_resumable_upload_in_storage( - filename: str, - total_size: int, - allowed_user_ids: list[int], - chunk_size: int | None = None, - timeout: float = 10.0, -) -> Dict[str, Any]: - mod = _get_file_storage_module() - if mod: - try: - return await mod.init_resumable_upload_internal( - filename=filename, - total_size=total_size, - allowed_user_ids=allowed_user_ids, - chunk_size=chunk_size, - ) - except Exception as e: - logger.error("In-process file_storage.init_resumable_upload failed: %s", e) - raise - - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() - url = f"{file_storage_url.rstrip('/')}/uploads/resumable/init" - payload = { - "filename": filename, - "total_size": total_size, - "allowed_user_ids": allowed_user_ids, - } - if chunk_size is not None: - payload["chunk_size"] = chunk_size - - import httpx - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.post(url, json=payload) - r.raise_for_status() - return r.json() - - -async def get_resumable_upload_status_in_storage( - upload_id: str, - user_id: int, - timeout: float = 10.0, -) -> Dict[str, Any]: - mod = _get_file_storage_module() - if mod: - try: - return await mod.get_resumable_upload_status_internal(upload_id, user_id) - except Exception as e: - logger.error("In-process file_storage.get_resumable_upload_status failed: %s", e) - raise - - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() - url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}" - - import httpx - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.get(url, headers={"X-User-ID": str(user_id)}) - r.raise_for_status() - return r.json() - - -async def upload_resumable_chunk_in_storage( - upload_id: str, - user_id: int, - offset: int, - data_b64: str, - timeout: float = 30.0, -) -> Dict[str, Any]: - mod = _get_file_storage_module() - if mod: - try: - return await mod.upload_resumable_chunk_internal( - upload_id, user_id, offset, data_b64 - ) - except Exception as e: - logger.error("In-process file_storage.upload_resumable_chunk failed: %s", e) - raise - - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() - url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}" - payload = { - "offset": offset, - "data_b64": data_b64, - } - - import httpx - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.patch(url, json=payload, headers={"X-User-ID": str(user_id)}) - r.raise_for_status() - return r.json() - - -async def complete_resumable_upload_in_storage( - upload_id: str, - user_id: int, - timeout: float = 10.0, -) -> Dict[str, Any]: - mod = _get_file_storage_module() - if mod: - try: - return await mod.complete_resumable_upload_internal(upload_id, user_id) - except Exception as e: - logger.error("In-process file_storage.complete_resumable_upload failed: %s", e) - raise - - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() - url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}/complete" - - import httpx - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.post(url, json={"upload_id": upload_id}, headers={"X-User-ID": str(user_id)}) - r.raise_for_status() - return r.json() - - -async def get_resumable_upload_blob_path_in_storage( - upload_id: str, - user_id: int, - timeout: float = 30.0, -) -> Dict[str, Any]: - mod = _get_file_storage_module() - if mod: - try: - return await mod.get_resumable_upload_blob_path_internal(upload_id, user_id) - except Exception as e: - logger.error("In-process file_storage.get_resumable_upload_blob_path failed: %s", e) - raise - - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() - url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}/blob-path" - - import httpx - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.get(url, headers={"X-User-ID": str(user_id)}) - r.raise_for_status() - return r.json() - - -async def store_encrypted_file_from_path( - source_path: str, - filename: str, - content_type: str = "application/octet-stream", - sender_id: int = None, - recipient_id: int = None, - timeout: float = 120.0, -) -> Dict[str, Any]: - mod = _get_file_storage_module() - allowed_user_ids: list[int] = [] - if sender_id is not None: - allowed_user_ids.append(sender_id) - if recipient_id is not None: - allowed_user_ids.append(recipient_id) - - if mod: - from pathlib import Path - - return await mod.upload_encrypted_file_from_path_internal( - filename=filename, - source_path=Path(source_path), - content_type=content_type, - allowed_user_ids=allowed_user_ids, - ) - - raise RuntimeError("store_encrypted_file_from_path requires in-process file_storage") - - -async def get_resumable_upload_data_in_storage( - upload_id: str, - user_id: int, - timeout: float = 30.0, -) -> Dict[str, Any]: - mod = _get_file_storage_module() - if mod: - try: - return await mod.get_resumable_upload_data_internal(upload_id, user_id) - except Exception as e: - logger.error("In-process file_storage.get_resumable_upload_data failed: %s", e) - raise - - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() - url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}/data-b64" - - import httpx - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.get(url, headers={"X-User-ID": str(user_id)}) - r.raise_for_status() - return r.json() - - -async def store_normal_file_from_path_in_storage( - stored_name: str, - source_path: str | Path, - timeout: float = 120.0, -) -> Dict[str, Any]: - """Persist a plain public-chat attachment where file downloads are served from.""" - mod = _get_file_storage_module() - src = Path(source_path) - if mod: - try: - return await mod.store_normal_file_from_path_internal(stored_name, src) - except Exception as e: - logger.error("In-process file_storage.store_normal_file_from_path failed: %s", e) - raise - - file_storage_url = ( - os.getenv("FILE_STORAGE_URL") - or os.getenv("FILE_STORAGE_SERVICE_URL") - or _default_file_storage_base_url() - ) - url = f"{file_storage_url.rstrip('/')}/uploads/files/normal/store" - import httpx - - async with httpx.AsyncClient(timeout=timeout) as client: - with open(src, "rb") as file_handle: - r = await client.post( - url, - data={"stored_name": stored_name}, - files={"file": (Path(stored_name).name, file_handle, "application/octet-stream")}, - ) - r.raise_for_status() - return r.json() - - -async def store_public_thumb_in_storage( - stored_name: str, - jpeg_bytes: bytes, - *, - width: int, - height: int, - file_size: int, - timeout: float = 30.0, -) -> Dict[str, Any]: - """Persist a public-chat thumbnail under file_storage THUMBS_DIR.""" - mod = _get_file_storage_module() - if mod: - try: - return await mod.store_public_thumb_internal( - stored_name, - jpeg_bytes, - width=width, - height=height, - file_size=file_size, - ) - except Exception as e: - logger.error("In-process file_storage.store_public_thumb failed: %s", e) - raise - - file_storage_url = ( - os.getenv("FILE_STORAGE_URL") - or os.getenv("FILE_STORAGE_SERVICE_URL") - or _default_file_storage_base_url() - ) - url = f"{file_storage_url.rstrip('/')}/uploads/files/thumbs/store" - import httpx - - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.post( - url, - data={ - "stored_name": stored_name, - "width": str(width), - "height": str(height), - "file_size": str(file_size), - }, - files={"file": (f"{Path(stored_name).stem}.jpg", jpeg_bytes, "image/jpeg")}, - ) - r.raise_for_status() - return r.json() - - -async def store_public_image_dimensions_in_storage( - stored_name: str, - *, - width: int, - height: int, - file_size: int, - timeout: float = 30.0, -) -> Dict[str, Any]: - """Persist image dimensions for large public attachments (no JPEG thumbnail).""" - mod = _get_file_storage_module() - if mod: - try: - return await mod.store_public_image_dimensions_internal( - stored_name, - width=width, - height=height, - file_size=file_size, - ) - except Exception as e: - logger.error("In-process file_storage.store_public_image_dimensions failed: %s", e) - raise - - file_storage_url = ( - os.getenv("FILE_STORAGE_URL") - or os.getenv("FILE_STORAGE_SERVICE_URL") - or _default_file_storage_base_url() - ) - url = f"{file_storage_url.rstrip('/')}/uploads/files/thumbs/dimensions" - import httpx - - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.post( - url, - data={ - "stored_name": stored_name, - "width": str(width), - "height": str(height), - "file_size": str(file_size), - }, - ) - r.raise_for_status() - return r.json() - - -async def get_public_thumb_meta_in_storage( - stored_name: str, - timeout: float = 10.0, -) -> Dict[str, Any] | None: - """Load thumbnail base64 + dimensions for a normal attachment basename.""" - mod = _get_file_storage_module() - if mod: - try: - return mod.get_public_thumb_meta_internal(stored_name) - except Exception as e: - logger.error("In-process file_storage.get_public_thumb_meta failed: %s", e) - return None - - file_storage_url = ( - os.getenv("FILE_STORAGE_URL") - or os.getenv("FILE_STORAGE_SERVICE_URL") - or _default_file_storage_base_url() - ) - stem = Path(stored_name).stem - url = f"{file_storage_url.rstrip('/')}/uploads/files/thumbs/{stem}.jpg" - import base64 - import httpx - - try: - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.get(url) - if r.status_code != 200: - return None - return { - "stored_name": Path(stored_name).name, - "width": 1, - "height": 1, - "file_size": 0, - "thumbnail_b64": base64.b64encode(r.content).decode("ascii"), - "thumb_path": f"/uploads/files/thumbs/{stem}.jpg", - } - except Exception as e: - logger.error("Remote file_storage.get_public_thumb_meta failed: %s", e) - return None - - -async def delete_resumable_upload_in_storage( - upload_id: str, - user_id: int, - timeout: float = 10.0, -) -> Dict[str, Any]: - mod = _get_file_storage_module() - if mod: - try: - return await mod.delete_resumable_upload_internal(upload_id, user_id) - except Exception as e: - logger.error("In-process file_storage.delete_resumable_upload failed: %s", e) - raise - - file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url() - url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}" - - import httpx - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.delete(url, headers={"X-User-ID": str(user_id)}) - r.raise_for_status() - return r.json() - - diff --git a/backend/services/main/similarity.py b/backend/services/main/similarity.py deleted file mode 100644 index 7e15c24..0000000 --- a/backend/services/main/similarity.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -Similarity detection utilities for username and display name comparison. -Implements both edit distance and visual similarity detection. -""" - -def levenshtein_distance(s1: str, s2: str) -> int: - """Calculate Levenshtein distance between two strings.""" - if len(s1) < len(s2): - return levenshtein_distance(s2, s1) - - if len(s2) == 0: - return len(s1) - - previous_row = list(range(len(s2) + 1)) - for i, c1 in enumerate(s1): - current_row = [i + 1] - for j, c2 in enumerate(s2): - insertions = previous_row[j + 1] + 1 - deletions = current_row[j] + 1 - substitutions = previous_row[j] + (c1 != c2) - current_row.append(min(insertions, deletions, substitutions)) - previous_row = current_row - - return previous_row[-1] - - -def check_visual_similarity(s1: str, s2: str) -> bool: - """ - Check if two strings are visually similar using common homoglyphs. - Returns True if strings are visually similar. - """ - if len(s1) != len(s2): - return False - - # Common homoglyph mappings - homoglyphs = { - '0': ['O', 'o', 'Q'], - 'O': ['0', 'o', 'Q'], - 'o': ['0', 'O', 'Q'], - '1': ['l', 'I', '|'], - 'l': ['1', 'I', '|'], - 'I': ['1', 'l', '|'], - '5': ['S', 's'], - 'S': ['5', 's'], - 's': ['5', 'S'], - '6': ['G', 'g'], - 'G': ['6', 'g'], - 'g': ['6', 'G'], - '8': ['B', 'b'], - 'B': ['8', 'b'], - 'b': ['8', 'B'], - '9': ['g', 'q'], - 'g': ['9', 'q'], - 'q': ['9', 'g'], - '2': ['Z', 'z'], - 'Z': ['2', 'z'], - 'z': ['2', 'Z'], - '3': ['E'], - 'E': ['3'], - '4': ['A'], - 'A': ['4'], - '7': ['T', 't'], - 'T': ['7', 't'], - 't': ['7', 'T'], - } - - for i in range(len(s1)): - c1, c2 = s1[i], s2[i] - if c1 == c2: - continue - - # Check if characters are homoglyphs - if (c1 in homoglyphs and c2 in homoglyphs[c1]) or \ - (c2 in homoglyphs and c1 in homoglyphs[c2]): - continue - - return False - - return True - - -def check_username_similarity(username1: str, username2: str) -> bool: - """ - Check if two usernames are similar using both edit distance and visual similarity. - Returns True if usernames are considered similar. - """ - if username1 == username2: - return False - - # Check edit distance (Levenshtein distance <= 2) - edit_distance = levenshtein_distance(username1.lower(), username2.lower()) - if edit_distance <= 2: - return True - - # Check visual similarity - if check_visual_similarity(username1, username2): - return True - - return False - - -def check_display_name_similarity(display_name1: str, display_name2: str) -> bool: - """ - Check if two display names are similar using both edit distance and visual similarity. - Returns True if display names are considered similar. - """ - if display_name1 == display_name2: - return False - - # Check edit distance (Levenshtein distance <= 2) - edit_distance = levenshtein_distance(display_name1.lower(), display_name2.lower()) - if edit_distance <= 2: - return True - - # Check visual similarity - if check_visual_similarity(display_name1, display_name2): - return True - - return False - - -def is_user_similar_to_verified(user_username: str, user_display_name: str, - verified_users: list[dict]) -> tuple[bool, str]: - """ - Check if a user is similar to any verified user. - - Args: - user_username: Username to check - user_display_name: Display name to check - verified_users: List of verified user dictionaries with 'username' and 'display_name' keys - - Returns: - Tuple of (is_similar, similar_to_username) - """ - for verified_user in verified_users: - verified_username = verified_user.get('username', '') - verified_display_name = verified_user.get('display_name', '') - - # Check username similarity - if check_username_similarity(user_username, verified_username): - return True, verified_username - - # Check display name similarity - if check_display_name_similarity(user_display_name, verified_display_name): - return True, verified_username - - return False, "" diff --git a/backend/services/main/static/PRIVACY.md b/backend/services/main/static/PRIVACY.md deleted file mode 100644 index 61d456e..0000000 --- a/backend/services/main/static/PRIVACY.md +++ /dev/null @@ -1,72 +0,0 @@ - - -## Общее - -Здесь политика конфиденциальности FromChat. Я знаю, что 99% ее даже читать не будут, сделал только для того, чтобы ко мне не было вопросов и чтобы те, кому реально интерессно знали, что происходит с данными. - -Эта политика действует только на официальном сервере [fromchat.ru](https://fromchat.ru). На других серверах политика ставится их админами. - -Вы можете свободно использовать этот текст в любых целях без указания авторства. - -Текст может меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если что-то изменится, я напишу об этом в Telegram-канале. - - -## Ваши данные - -### Какие данные собираются? - -- Логин, имя и прочие данные профиля — без них мессенджер не может существовать. Эти данные видны всем, кто общается с вами. -- Пароль — на сервере хранится только односторонний хеш, который используется для проверки. Сервер никогда не видит пароль открытым текстом. -- Сообщения в общем чате — они публичны. Любой пользователь на сервере может их увидеть. Они хранятся открытым текстом в базе данных. -- Личные сообщения — вкратце: они хранятся в зашифрованном виде, но сервер во время обработки кратко видит открытый текст сообщения. Они могут быть переданы по официальному запросу уполномоченных органов. Если интересно, как именно шифруются сообщения — читайте ниже. -- Статус «в сети» и время последней активности — чтобы собеседник видел, когда вы были в сети. К сожалению, скрыть его пока нельзя. -- Информация об устройствах (тип, ОС, браузер) — видна только вам, нужно для того, чтобы вы легко распознали взлом и его нейтрализовали. -- Звонки — идут в зашифрованном виде через WebRTC-сервер, могут быть записаны в целях соблюдения законодательства и предоставлены уполномоченным органам по запросу. - - -## Больше про личные сообщения - -Если вы очень беспокоетесь за безопасность ваших сообщений, сразу говорю — защита несовершенна и любую защиту можно взломать. Но я постарался сделать доступ к вашим перепискам максимально сложным для хакеров. - -### Весь путь сообщения от вас к собеседнику - -Ваше устройство: -1. Вы отправляете сообщение. -2. Приложение (клиент) запрашивает открытый ключ у сервера обработки сообщений. -3. Приложение скачивает ваш открытый ключ и открытый ключ вашего собеседника. -3. Сообщение шифруется этим открытым ключем и отсылается на сервер вместе с открытыми ключами, полученными в предыдущем шаге. - -Сервер: -1. Сервер получает ваш запрос на отправку сообщения и пересылает его в изолированный контейнер для обработки сообщений. -2. Контейнер расшифровывает ваше сообщение своим закрытым ключем и хранит его в оперативной памяти. -3. Создается строка из случайных чисел (MEK). -4. Текст вашего сообщения шифруется алгоритмом AES-256, MEK используется как ключ. -5. MEK шифруется три раза с помощью вашего открытого ключа и открытых ключей собеседника и официальных запросов. -6. Открытый текст вашего сообщения полностью удаляется из оперативной памяти. -7. Контейнер возвращает главному серверу зашифрованное сообщение вместе с тремя экземплярами MEK. -8. Сообщение записывается в базу данных. - -Устройство собеседника: -1. Оно получает ваше сообщение и расшифровывает MEK закрытым ключем, сохраненном в аккаунте собеседника в зашифрованном виде, где пароль от аккаунта используется как ключ. -2. Зашифрованный текст сообщения расшифровывается с MEK как ключ. -3. Собеседник прочитал ваше сообщение. - - -## Реклама и продажа данных - -Никакой рекламы с моей стороны и продажи ваших данных нет и никогда не будет. Мне нет смысла злить вас ради собственной выгоды. - -На данный момент приложение не собирает никакой аналитики. - -В каналах теоритически может быть реклама от их админов. Я в ней не виноват и контролировать не могу. - - -## Удаление данных - -Если вы хотите удалить сообщение, удерживайте и нажмите "Удалить". Тогда сообщение пропадет из публичного доступа. Зашифрованная копия сообщения останется в целях соблюдения законодательства на 6 месяцев. - -Если вам нужно удалить ваши данные профиля из публичного доступа, вы можете удалить аккаунт в настройках приложения. - -В таком случае все сообщения, которые вы отправили будут анонимизированы, но не удалены. - -Если вам нужно удалить ВСЕ, что связано с вашим профилем из публичного доступа, напишите в Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true) \ No newline at end of file diff --git a/backend/services/main/static/TERMS.md b/backend/services/main/static/TERMS.md deleted file mode 100644 index a189674..0000000 --- a/backend/services/main/static/TERMS.md +++ /dev/null @@ -1,68 +0,0 @@ - - - -## Общее - -**FromChat** — 100% бесплатный и открытый мессенджер. Я создал эти правила, чтобы вы точно знали, что можно, а что нельзя. - -Эти правила действуют только на официальном сервере [fromchat.ru](https://fromchat.ru). Админы других серверов устанавливают свои правила. - -Вы можете свободно использовать этот текст в любых целях без указания авторства. - -Сервис предоставляется как есть, перебои и сбои будут гарантированно из-за слабенькой малинки. - -Правила могут меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если правила изменятся, я напишу об этом в Telegram-канале. - - -## Ваш аккаунт - -Условия вступают в силу, когда вы создаете аккаунт. Также советую прочитать [политику конфиденциальности](/api/static/PRIVACY.md), поверьте, это очень важно. - -Вы полностью отвечаете за все, что происходит в вашем аккаунте. Если поставите пароль `12345` — вас точно взломают :) - -Если вы нарушите правила, я вас заблокирую. В таком случае вы сможете только читать сообщения, а отправка и реакции будут заблокированы. Если считаете, что я не прав — пишите в Telegram: [@denis0001_dev](https://t.me/denis0001_dev). - - -## Правила - -### Для общего чата -Общий чат — это площадка для общения между всеми пользователями на этом сервере. По очевидным причинам, тут запрещено: -- Материться, использовать 18+ и другие неприличные слова; -- Разговаривать на тему политики, религии, нелегальных действий и неприличия; -- Оскорблять других; -- Сливать персональные данные (адрес, номер, ФИО и прочее); -- Рекламировать любые продукты, сервисы и прочее без моего согласия; -- Популяризировать VPN и другие способы обхода блокировок (это закон, не мое личное правило); -- Угрожать в любом виде; -- Спамить или засорять чат. - -В целях защиты от спама количество сообщений в минуту ограничено и нельзя отправлять слишком много сообщений с одинаковым текстом. При нарушении вы будете автоматически заблокированы. Алгоритм очень примитивный, поэтому ошибки будут. Если это была ошибка, я вас разблокирую. - -### Для личных сообщений - -За личными сообщениями я не шпионю, но могу предоставить по официальному запросу. Поэтому я пока не могу выявлять там нарушения. Я скоро сделаю механизм жалоб. - -В личке правил гораздо меньше. Мне лень писать снова длинный список, поэтому просто прошу вас, не занимайтесь нелегальными вещами и не спамьте. В личке можно обсуждать все остальное и материться. - -### Глобальные правила - -Пожалуйста, не используйте мессенджер для спама и не устраивайте DDoS или любые другие атаки. - - -## Контакты - -### Если у вас возникли любые вопросы, пишите сюда: - -Почта: [support@fromchat.ru](mailto:support@fromchat.ru) - -Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true) - -FromChat: [@denis0001-dev](https://fromchat.ru/@denis0001-dev) - -### Вопросы по безопасности, сообщения об узвимостях - -Если вдруг вы найдете уязвимость или есть вопрос про безопасность, срочно пишите сюда: - -[security@fromchat.ru](mailto:security@fromchat.ru) - -О шифровании договоримся, если надо. \ No newline at end of file diff --git a/backend/services/main/static/icons/block.webp b/backend/services/main/static/icons/block.webp deleted file mode 100644 index 9fd0780..0000000 Binary files a/backend/services/main/static/icons/block.webp and /dev/null differ diff --git a/backend/services/main/static/icons/call.webp b/backend/services/main/static/icons/call.webp deleted file mode 100644 index 9aeda24..0000000 Binary files a/backend/services/main/static/icons/call.webp and /dev/null differ diff --git a/backend/services/main/static/icons/chat.webp b/backend/services/main/static/icons/chat.webp deleted file mode 100644 index 4350212..0000000 Binary files a/backend/services/main/static/icons/chat.webp and /dev/null differ diff --git a/backend/services/main/static/icons/delete.webp b/backend/services/main/static/icons/delete.webp deleted file mode 100644 index 2812bfc..0000000 Binary files a/backend/services/main/static/icons/delete.webp and /dev/null differ diff --git a/backend/services/main/static/icons/description.webp b/backend/services/main/static/icons/description.webp deleted file mode 100644 index 2db7722..0000000 Binary files a/backend/services/main/static/icons/description.webp and /dev/null differ diff --git a/backend/services/main/static/icons/lock.webp b/backend/services/main/static/icons/lock.webp deleted file mode 100644 index fbf61e3..0000000 Binary files a/backend/services/main/static/icons/lock.webp and /dev/null differ diff --git a/backend/services/main/static/icons/notifications.webp b/backend/services/main/static/icons/notifications.webp deleted file mode 100644 index 609c510..0000000 Binary files a/backend/services/main/static/icons/notifications.webp and /dev/null differ diff --git a/backend/services/main/static/icons/person.webp b/backend/services/main/static/icons/person.webp deleted file mode 100644 index 4229a5f..0000000 Binary files a/backend/services/main/static/icons/person.webp and /dev/null differ diff --git a/backend/services/main/static/icons/person_add.webp b/backend/services/main/static/icons/person_add.webp deleted file mode 100644 index 6721f62..0000000 Binary files a/backend/services/main/static/icons/person_add.webp and /dev/null differ diff --git a/backend/services/main/static/icons/phone.webp b/backend/services/main/static/icons/phone.webp deleted file mode 100644 index bd94e37..0000000 Binary files a/backend/services/main/static/icons/phone.webp and /dev/null differ diff --git a/backend/services/main/static/icons/privacy.webp b/backend/services/main/static/icons/privacy.webp deleted file mode 100644 index 65eff06..0000000 Binary files a/backend/services/main/static/icons/privacy.webp and /dev/null differ diff --git a/backend/services/main/static/icons/shield.webp b/backend/services/main/static/icons/shield.webp deleted file mode 100644 index 21e0ad7..0000000 Binary files a/backend/services/main/static/icons/shield.webp and /dev/null differ diff --git a/backend/services/main/static/icons/storage.webp b/backend/services/main/static/icons/storage.webp deleted file mode 100644 index 0743d32..0000000 Binary files a/backend/services/main/static/icons/storage.webp and /dev/null differ diff --git a/backend/services/main/static/icons/terms.webp b/backend/services/main/static/icons/terms.webp deleted file mode 100644 index 100b234..0000000 Binary files a/backend/services/main/static/icons/terms.webp and /dev/null differ diff --git a/backend/services/main/static/icons/visibility_off.webp b/backend/services/main/static/icons/visibility_off.webp deleted file mode 100644 index 8416535..0000000 Binary files a/backend/services/main/static/icons/visibility_off.webp and /dev/null differ diff --git a/backend/services/main/static/public_chat_profile.json b/backend/services/main/static/public_chat_profile.json deleted file mode 100644 index 9ae85e3..0000000 --- a/backend/services/main/static/public_chat_profile.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "general", - "title": "Общий чат", - "bio": "Общаемся со всеми пользователями FromChat!" -} diff --git a/backend/services/main/utils.py b/backend/services/main/utils.py deleted file mode 100644 index ed3586a..0000000 --- a/backend/services/main/utils.py +++ /dev/null @@ -1,71 +0,0 @@ -from datetime import datetime, timedelta -from fastapi import Request -import jwt -from typing import Optional, Any -import bcrypt - -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: - # 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": int(expire.timestamp()) # JWT exp must be Unix timestamp (int) - } - return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) - - -def verify_token(token: str) -> Optional[dict]: - try: - payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) - return payload - except jwt.ExpiredSignatureError: - return None - except jwt.InvalidTokenError: - return None - - -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") - - -def get_client_ip(request: Request) -> Optional[str]: - if not request: - return None - - headers = request.headers - - # First, check x-real-ip header (set by some proxies, or configured in Caddy) - real_ip = headers.get("x-real-ip") or headers.get("X-Real-IP") - if real_ip: - candidate = real_ip.strip() - if candidate: - return candidate - - # Fall back to x-forwarded-for header (Caddy sets this automatically) - forwarded = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For") - if forwarded: - # X-Forwarded-For can contain multiple IPs: "client, proxy1, proxy2" - # Take the first one (original client IP) - candidate = forwarded.split(",")[0].strip() - if candidate: - return candidate - - # Fall back to direct client connection (when not behind a proxy) - if request.client and request.client.host: - return request.client.host - - # Last resort: check scope - if isinstance(request.scope, dict): - client_info = request.scope.get("client") - if isinstance(client_info, (list, tuple)) and client_info: - return client_info[0] - - return None \ No newline at end of file diff --git a/backend/services/main/validation.py b/backend/services/main/validation.py deleted file mode 100644 index 376fdc2..0000000 --- a/backend/services/main/validation.py +++ /dev/null @@ -1,26 +0,0 @@ -import re - -def is_valid_username(username: str) -> bool: - if len(username) < 3 or len(username) > 20: - return False - # Only allow English letters, numbers, dashes and underscores - if not re.match(r'^[a-zA-Z0-9_-]+$', username): - return False - return True - - -def is_valid_display_name(display_name: str) -> bool: - if len(display_name) < 1 or len(display_name) > 64: - return False - # Check if not blank (only whitespace) - if not display_name.strip(): - return False - return True - - -def is_valid_password(password: str) -> bool: - if len(password) < 5 or len(password) > 50: - return False - if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password): - return False - return True \ No newline at end of file diff --git a/backend/services/main/verification_service.py b/backend/services/main/verification_service.py deleted file mode 100644 index 67f6338..0000000 --- a/backend/services/main/verification_service.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Server-side verification status computation.""" - -from enum import Enum - -from sqlalchemy.orm import Session - -from .models import User -from .similarity import is_user_similar_to_verified - - -class VerificationStatus(str, Enum): - VERIFIED = "verified" - WARNING = "warning" - BLOCKED = "blocked" - NONE = "none" - - -def get_verified_users_data(db: Session) -> list[dict[str, str]]: - verified_users = ( - db.query(User) - .filter( - User.verified.is_(True), - User.deleted.is_(False), - User.suspended.is_(False), - ) - .all() - ) - return [ - {"username": user.username, "display_name": user.display_name} - for user in verified_users - ] - - -def compute_verification_status( - user: User, - verified_users_data: list[dict[str, str]], -) -> VerificationStatus: - if user.deleted: - return VerificationStatus.NONE - if user.suspended: - return VerificationStatus.BLOCKED - if user.verified: - return VerificationStatus.VERIFIED - - is_similar, _ = is_user_similar_to_verified( - user.username, - user.display_name, - verified_users_data, - ) - return VerificationStatus.WARNING if is_similar else VerificationStatus.NONE diff --git a/backend/services/main/websocket/__init__.py b/backend/services/main/websocket/__init__.py deleted file mode 100644 index 857d63c..0000000 --- a/backend/services/main/websocket/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .registry import WebSocketHandlerRegistry - -# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency -# Import them directly from .handlers when needed - -__all__ = ["WebSocketHandlerRegistry"] - diff --git a/backend/services/main/websocket/handlers.py b/backend/services/main/websocket/handlers.py deleted file mode 100644 index 6ae018b..0000000 --- a/backend/services/main/websocket/handlers.py +++ /dev/null @@ -1,638 +0,0 @@ -from datetime import datetime -import json -import logging -import time -from typing import Any -from fastapi import HTTPException, WebSocket, Request -from sqlalchemy.orm import Session - -from .registry import WebSocketHandlerRegistry -from ..routes.messaging import ( - MessaggingSocketManager, - _send_message_internal, - _edit_message_internal, - _mark_dm_conversation_read, - get_messages, - edit_message, - delete_message, - add_reaction, - add_dm_reaction, -) -from ..models import ( - User, - SendMessageRequest, - EditMessageRequest, - DMEnvelope, - ReactionRequest, - DMReactionRequest, - UpdateLog, -) -from ..routes.profile import build_profile_update_payload -from ..security.audit import log_access, log_dm - -logger = logging.getLogger("uvicorn.error") - -# Create global registry instance -handler_registry = WebSocketHandlerRegistry() - -# Create decorator alias -websocket_handler = handler_registry.register - - -def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None: - """Log WebSocket event.""" - ws_path = getattr(getattr(websocket, "url", None), "path", None) - if not ws_path and isinstance(getattr(websocket, "scope", None), dict): - ws_path = websocket.scope.get("path") - ws_path = ws_path or "unknown" - headers = {} - if isinstance(getattr(websocket, "scope", None), dict): - headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])} - xff = headers.get("x-forwarded-for") - client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None) - - log_access( - "ws_event", - path=ws_path, - event=event, - user=user.username if user else None, - user_id=user.id if user else None, - ip=client_ip, - **extra, - ) - - -@websocket_handler("getUpdates", authRequired=True) -async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: - """Handle gap detection - client requests updates from a specific sequence number.""" - last_seq = data.get("lastSeq", 0) - manager.last_seq_by_ws[websocket] = last_seq - current_seq = manager.sequence_numbers.get(user.id, 0) - - # Query database for missed updates - missed_updates = [] - if last_seq > 0 and last_seq < current_seq: - try: - # Get all updates between last_seq and current_seq - update_logs = db.query(UpdateLog).filter( - UpdateLog.user_id == user.id, - UpdateLog.sequence > last_seq, - UpdateLog.sequence <= current_seq - ).order_by(UpdateLog.sequence.asc()).all() - - # Each log entry contains a batch of updates with the same sequence number - for log_entry in update_logs: - updates = json.loads(log_entry.updates) - missed_updates.append({ - "seq": log_entry.sequence, - "updates": updates - }) - except Exception as e: - logger.error(f"Failed to retrieve missed updates: {e}") - - # Send missed updates directly (not through return value) - for batch in missed_updates: - await websocket.send_json({ - "type": "updates", - "seq": batch["seq"], - "updates": batch["updates"] - }) - - # Update the websocket's last sequence tracking - manager.last_seq_by_ws[websocket] = current_seq - log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates)) - - return { - "status": "ok", - "lastSeq": current_seq, - "missedCount": len(missed_updates) - } - - -@websocket_handler("ping", authRequired=True) -async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: - """Handle ping - authenticate and set user online.""" - became_online = presence_service.register_connection(user.id, websocket) - presence_service.touch(user.id) - if became_online: - _, last_seen = presence_service.get_presence(user.id) - last_seen_iso = last_seen.isoformat() if last_seen else datetime.now().isoformat() - await manager.broadcast_status_change(user.id, True, last_seen_iso, 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, []) - - 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 using the new envelope encryption format.""" - payload = data - required = ["recipientId", "iv_b64", "ciphertext_b64", "wrapped_mek_b64"] - for key in required: - if key not in payload: - raise HTTPException(status_code=400, detail=f"Missing {key}") - - client_message_id = payload.get("client_message_id") or payload.get("clientMessageId") - if isinstance(client_message_id, str): - client_message_id = client_message_id.strip() or None - else: - client_message_id = None - - env = DMEnvelope( - sender_id=user.id, - recipient_id=int(payload["recipientId"]), - iv_b64=payload["iv_b64"], - ciphertext_b64=payload["ciphertext_b64"], - sender_wrapped_mek_b64=payload["wrapped_mek_b64"], # Client sends their own MEK - recipient_wrapped_mek_b64=payload["wrapped_mek_b64"], # For simplicity, store same MEK - compliance_wrapped_mek_b64=payload.get("compliance_wrapped_mek_b64"), - reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None, - ) - db.add(env) - db.commit() - db.refresh(env) - - # Send user-specific WebSocket updates (each user gets only their MEK) - base_payload = { - "id": env.id, - "senderId": env.sender_id, - "recipientId": env.recipient_id, - "iv_b64": env.iv_b64, - "ciphertext_b64": env.ciphertext_b64, - "timestamp": env.timestamp.isoformat(), - "replyToId": env.reply_to_id, - } - - # Send to recipient with their MEK - recipient_payload = { - "type": "dmNew", - "data": { - **base_payload, - "wrapped_mek_b64": env.recipient_wrapped_mek_b64, - } - } - await manager.send_update_to_user(env.recipient_id, "dmNew", recipient_payload["data"], db) - - # Send to sender with their MEK (client_message_id only for optimistic ack matching) - sender_data = { - **base_payload, - "wrapped_mek_b64": env.sender_wrapped_mek_b64, - } - if client_message_id: - sender_data["client_message_id"] = client_message_id - sender_payload = { - "type": "dmNew", - "data": sender_data, - } - await manager.send_update_to_user(env.sender_id, "dmNew", sender_payload["data"], db) - - # 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}") - - log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id) - log_dm( - "message_sent_ws", - dm_envelope_id=env.id, - sender_id=user.id, - sender_username=user.username, - recipient_id=env.recipient_id, - reply_to=env.reply_to_id, - ) - - return {"status": "ok", "id": env.id} - - -@websocket_handler("editMessage", authRequired=True) -async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: - """Edit a public chat message.""" - - message_id = data["message_id"] - edit_request: EditMessageRequest = EditMessageRequest.model_validate(data) - - response = await _edit_message_internal(message_id, edit_request, user, db) - await manager.broadcast({ - "type": "messageEdited", - "data": response["message"] - }, db) - - log(manager, websocket, user, "editMessage", message_id=message_id) - return response - - -@websocket_handler("dmEdit", authRequired=True) -async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: - """Edit a direct message.""" - payload = data - env_id = int(payload["id"]) - env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() - if not env: - raise HTTPException(status_code=404, detail="DM not found") - if env.sender_id != user.id: - raise HTTPException(status_code=403, detail="You can only edit your own messages") - - # Replace ciphertext and iv - env.iv_b64 = payload["iv"] - env.ciphertext_b64 = payload["ciphertext"] - env.sender_wrapped_mek_b64 = payload.get("wrappedMk", "") - env.recipient_wrapped_mek_b64 = payload.get("wrappedMk", "") - db.commit() - db.refresh(env) - - # Send user-specific payloads for edit - base_payload = { - "id": env.id, - "senderId": env.sender_id, - "recipientId": env.recipient_id, - "iv_b64": env.iv_b64, - "ciphertext_b64": env.ciphertext_b64, - "timestamp": env.timestamp.isoformat(), - } - - # Send to recipient with their MEK - recipient_payload = { - "type": "dmEdited", - "data": { - **base_payload, - "wrapped_mek_b64": env.recipient_wrapped_mek_b64, - } - } - await manager.send_update_to_user(env.recipient_id, "dmEdited", recipient_payload["data"], db) - - # Send to sender with their MEK - sender_payload = { - "type": "dmEdited", - "data": { - **base_payload, - "wrapped_mek_b64": env.sender_wrapped_mek_b64, - } - } - await manager.send_update_to_user(env.sender_id, "dmEdited", sender_payload["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("dmMarkRead", authRequired=True) -async def dmMarkRead(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: - """Mark DM envelopes up to the given id as read for the current user.""" - envelope_id = int(data["id"]) - env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == envelope_id).first() - if not env: - raise HTTPException(status_code=404, detail="DM not found") - if env.sender_id != user.id and env.recipient_id != user.id: - raise HTTPException(status_code=403, detail="Not a participant in this conversation") - - other_user_id = env.recipient_id if env.sender_id == user.id else env.sender_id - last_read = _mark_dm_conversation_read( - db, - user.id, - other_user_id, - up_to_envelope_id=envelope_id, - ) - db.commit() - - log(manager, websocket, user, "dmMarkRead", dm_envelope_id=envelope_id, other_user_id=other_user_id) - return {"status": "ok", "lastReadEnvelopeId": last_read} - - - 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.setdefault(websocket, set()).add(user_id_to_subscribe) - - target_user = db.query(User).filter(User.id == user_id_to_subscribe).first() - if not target_user: - 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") - - online, last_seen = presence_service.get_presence(user_id_to_subscribe) - await websocket.send_json({ - "type": "statusUpdate", - "data": { - "userId": user_id_to_subscribe, - "online": online, - "lastSeen": last_seen.isoformat() if last_seen else None, - }, - }) - - try: - profile_payload = build_profile_update_payload(target_user, user.id, db) - await websocket.send_json({ - "type": "profileUpdate", - "data": profile_payload, - }) - except Exception: - logger.exception( - "subscribeStatus profile snapshot failed subscriber=%s target=%s", - user.id, - user_id_to_subscribe, - ) - - log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe) - return {"status": "ok"} - - -@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/services/main/websocket/registry.py b/backend/services/main/websocket/registry.py deleted file mode 100644 index d9a6271..0000000 --- a/backend/services/main/websocket/registry.py +++ /dev/null @@ -1,33 +0,0 @@ -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/services/main/websocket/utils.py b/backend/services/main/websocket/utils.py deleted file mode 100644 index 6fc78e7..0000000 --- a/backend/services/main/websocket/utils.py +++ /dev/null @@ -1,92 +0,0 @@ -from fastapi import HTTPException -from fastapi.security import HTTPAuthorizationCredentials -from sqlalchemy.orm import Session -from types import SimpleNamespace -from ..dependencies import get_current_user -from ..models import User - - -def extract_token_from_data(data: dict) -> str | None: - """Extract authentication token from WebSocket message data. - - Args: - data: WebSocket message data dictionary - - Returns: - Token string or None if not present - """ - credentials = data.get("credentials") - if credentials and isinstance(credentials, dict): - return credentials.get("credentials") - return None - - -def get_current_user_from_token(token: str, db: Session) -> User | None: - """Get user from authentication token. - - Args: - token: JWT token string - db: Database session - - Returns: - User object or None if token is invalid - """ - try: - # Ensure session is in a usable state before querying - try: - db.rollback() - except Exception: - pass - - dummy_request = SimpleNamespace() - dummy_request.state = SimpleNamespace() - - try: - from fastapi.security import HTTPBearer - security = HTTPBearer() - # We need to create credentials manually - credentials = HTTPAuthorizationCredentials( - scheme="Bearer", - credentials=token - ) - return get_current_user(dummy_request, credentials, db) - except HTTPException: - return None - except Exception: - try: - db.rollback() - except Exception: - pass - return None - - -def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None: - """Authenticate user from WebSocket message data. - - Args: - data: WebSocket message data dictionary - db: Database session - authRequired: If True, raises 401 on missing/invalid token - - Returns: - User object (guaranteed not None if authRequired=True) or None - - Raises: - HTTPException: 401 if authRequired=True and token is missing/invalid - """ - token = extract_token_from_data(data) - - if authRequired: - if not token: - raise HTTPException(status_code=401, detail="Missing credentials") - - user = get_current_user_from_token(token, db) - if not user: - raise HTTPException(status_code=401, detail="Invalid credentials") - - return user - else: - if token: - return get_current_user_from_token(token, db) - return None - diff --git a/backend/services/messaging/__init__.py b/backend/services/messaging/__init__.py deleted file mode 100644 index 08e6f6f..0000000 --- a/backend/services/messaging/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Messaging service module \ No newline at end of file diff --git a/backend/services/messaging/encryption.py b/backend/services/messaging/encryption.py deleted file mode 100644 index 0a3ede3..0000000 --- a/backend/services/messaging/encryption.py +++ /dev/null @@ -1,429 +0,0 @@ -""" -Envelope encryption module for the messaging service. - -Handles: -- Transport encryption/decryption with ephemeral X25519 keys -- MEK (Message Encryption Key) generation and management -- Envelope encryption for messages using AES-GCM -- MEK wrapping for compliance, sender, and recipient keys -""" - -import os -import base64 -import logging -from pathlib import Path -from typing import BinaryIO -from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives import hashes, serialization -from nacl.public import Box, PrivateKey, PublicKey -import nacl.bindings as sodium - -logger = logging.getLogger(__name__) - -# Nonce/IV sizes -TRANSPORT_NONCE_SIZE = 24 # For X25519 transport encryption (PyNaCl Box/XSalsa20Poly1305) -MEK_NONCE_SIZE = 12 # For AES-GCM content encryption -MEK_SIZE = 32 # Message Encryption Key size -GCM_TAG_SIZE = 16 # AES-GCM authentication tag appended to file ciphertext -FILE_ENCRYPT_CHUNK_SIZE = 1024 * 1024 - -# Client streaming transport format (chunked AES-256-GCM): FCAE | version | frames… -FCAE_MAGIC = b"FCAE" -FCAE_VERSION = 1 -FCAE_PREFIX_BYTES = len(FCAE_MAGIC) + 1 -FCAE_FRAME_LENGTH_BYTES = 4 -TRANSPORT_FILE_KEY_CONTEXT = "fromchat_transport_file_v1" - - -def generate_mek() -> bytes: - """Generate a random Message Encryption Key (32 bytes).""" - return os.urandom(MEK_SIZE) - - -def generate_nonce(size: int = MEK_NONCE_SIZE) -> bytes: - """Generate a random nonce for AES-GCM.""" - return os.urandom(size) - - -def derive_shared_secret(private_key: X25519PrivateKey, peer_public_key_b64: str) -> bytes: - """ - Compute a shared secret from a private key and peer's public key using X25519. - - Args: - private_key: X25519PrivateKey - peer_public_key_b64: Peer's public key in base64 (raw format) - - Returns: - Shared secret (32 bytes) - """ - try: - peer_public_bytes = base64.b64decode(peer_public_key_b64) - peer_public_key = X25519PublicKey.from_public_bytes(peer_public_bytes) - return private_key.exchange(peer_public_key) - except Exception as e: - logger.error("Failed to derive shared secret: %s", e) - raise - - -def derive_key_from_shared_secret(shared_secret: bytes, context: str, key_size: int = MEK_SIZE) -> bytes: - """ - Derive a key from a shared secret using HKDF-SHA256. - - Args: - shared_secret: The shared secret from ECDH - context: Context string for key derivation (e.g., "transport_key") - key_size: Output key size in bytes (default 32) - - Returns: - Derived key bytes - """ - hkdf = HKDF( - algorithm=hashes.SHA256(), - length=key_size, - salt=b"\x00" * 16, # 16 zero bytes salt - info=context.encode(), - ) - return hkdf.derive(shared_secret) - - -def decrypt_transport_message( - client_public_key_b64: str, - nonce_b64: str, - ciphertext_b64: str, - ephemeral_private_key: X25519PrivateKey, -) -> bytes: - """ - Decrypt a message that was encrypted with the ephemeral public key. - - The client encrypts plaintext with the ephemeral transport key using tweetnacl.box, - which performs ECDH + XSalsa20Poly1305 encryption. - - Args: - client_public_key_b64: Client's ephemeral public key (base64, raw X25519) - nonce_b64: Encryption nonce (base64, 24 bytes for XSalsa20Poly1305) - ciphertext_b64: Encrypted message (base64) - ephemeral_private_key: Server's ephemeral X25519 private key - - Returns: - Decrypted plaintext - """ - try: - # Convert cryptography X25519 key to raw bytes - server_private_bytes = ephemeral_private_key.private_bytes_raw() - - # Convert client public key from base64 to raw bytes - client_public_bytes = base64.b64decode(client_public_key_b64) - - # Decode nonce and ciphertext - nonce = base64.b64decode(nonce_b64) - ciphertext = base64.b64decode(ciphertext_b64) - - # Decrypt using PyNaCl's low-level function (compatible with tweetnacl) - # Parameters: ciphertext, nonce, sender_public_key, recipient_private_key - plaintext = sodium.crypto_box_open_easy( - ciphertext, - nonce, - client_public_bytes, # sender public key - server_private_bytes # recipient private key - ) - return plaintext - except Exception as e: - logger.error("Failed to decrypt transport message: %s", e) - raise - - -def is_fcae_transport_blob(prefix: bytes) -> bool: - return len(prefix) >= len(FCAE_MAGIC) and prefix[: len(FCAE_MAGIC)] == FCAE_MAGIC - - -def derive_transport_file_aes_key( - client_public_key_b64: str, - ephemeral_private_key: X25519PrivateKey, -) -> bytes: - client_public_bytes = base64.b64decode(client_public_key_b64) - server_private_bytes = ephemeral_private_key.private_bytes_raw() - shared = sodium.crypto_box_beforenm(client_public_bytes, server_private_bytes) - return derive_key_from_shared_secret(shared, TRANSPORT_FILE_KEY_CONTEXT) - - -def _read_fcae_frame_payload(source: BinaryIO) -> tuple[bytes, bytes] | None: - length_bytes = source.read(FCAE_FRAME_LENGTH_BYTES) - if not length_bytes: - return None - if len(length_bytes) < FCAE_FRAME_LENGTH_BYTES: - raise ValueError("Truncated FCAE frame length") - frame_len = int.from_bytes(length_bytes, byteorder="big", signed=False) - if frame_len <= MEK_NONCE_SIZE: - raise ValueError("Invalid FCAE frame length") - frame = source.read(frame_len) - if len(frame) < frame_len: - raise ValueError("Truncated FCAE frame") - iv = frame[:MEK_NONCE_SIZE] - ciphertext = frame[MEK_NONCE_SIZE:] - return iv, ciphertext - - -def _decrypt_fcae_transport_stream_io( - client_public_key_b64: str, - source: BinaryIO, - ephemeral_private_key: X25519PrivateKey, -) -> bytes: - prefix = source.read(FCAE_PREFIX_BYTES) - if len(prefix) < FCAE_PREFIX_BYTES: - raise ValueError("FCAE blob is too short") - if not is_fcae_transport_blob(prefix): - raise ValueError("Not an FCAE transport blob") - if prefix[4] != FCAE_VERSION: - raise ValueError("Unsupported FCAE version") - aes_key = derive_transport_file_aes_key(client_public_key_b64, ephemeral_private_key) - cipher = AESGCM(aes_key) - parts: list[bytes] = [] - while True: - frame = _read_fcae_frame_payload(source) - if frame is None: - break - iv, ciphertext = frame - parts.append(cipher.decrypt(iv, ciphertext, None)) - return b"".join(parts) - - -def decrypt_fcae_transport_blob_to_file( - client_public_key_b64: str, - encrypted_path: Path, - ephemeral_private_key: X25519PrivateKey, - output_path: Path, -) -> int: - """Stream-decrypt FCAE transport ciphertext from disk to a plaintext file.""" - output_path.parent.mkdir(parents=True, exist_ok=True) - total_out = 0 - with open(encrypted_path, "rb") as enc, open(output_path, "wb") as out: - prefix = enc.read(FCAE_PREFIX_BYTES) - if not is_fcae_transport_blob(prefix): - raise ValueError("Not an FCAE transport blob") - if prefix[4] != FCAE_VERSION: - raise ValueError("Unsupported FCAE version") - aes_key = derive_transport_file_aes_key(client_public_key_b64, ephemeral_private_key) - cipher = AESGCM(aes_key) - while True: - frame = _read_fcae_frame_payload(enc) - if frame is None: - break - iv, ciphertext = frame - plain = cipher.decrypt(iv, ciphertext, None) - out.write(plain) - total_out += len(plain) - return total_out - - -def encrypt_message_to_file(plaintext_path: Path, mek: bytes, output_path: Path) -> str: - """ - AES-GCM encrypt a file on disk; returns nonce_b64. - - On-disk layout: ``ciphertext || tag`` (16-byte GCM tag at EOF). - Hazmat ``encryptor.finalize()`` does not emit the tag; it is taken from ``encryptor.tag``. - """ - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - - nonce = generate_nonce(MEK_NONCE_SIZE) - output_path.parent.mkdir(parents=True, exist_ok=True) - encryptor = Cipher(algorithms.AES(mek), modes.GCM(nonce)).encryptor() - with open(plaintext_path, "rb") as src, open(output_path, "wb") as dst: - while True: - chunk = src.read(FILE_ENCRYPT_CHUNK_SIZE) - if not chunk: - break - dst.write(encryptor.update(chunk)) - encryptor.finalize() - dst.write(encryptor.tag) - return base64.b64encode(nonce).decode("utf-8") - - -def decrypt_message_to_file( - nonce_b64: str, - mek: bytes, - encrypted_path: Path, - output_path: Path, -) -> int: - """ - Decrypt a file produced by [encrypt_message_to_file] (ciphertext || tag). - - Returns plaintext byte count. - """ - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - - nonce = base64.b64decode(nonce_b64) - enc_size = encrypted_path.stat().st_size - if enc_size < GCM_TAG_SIZE: - raise ValueError("Encrypted file is too short") - - output_path.parent.mkdir(parents=True, exist_ok=True) - ciphertext_length = enc_size - GCM_TAG_SIZE - total_out = 0 - - with open(encrypted_path, "rb") as src: - src.seek(ciphertext_length) - tag = src.read(GCM_TAG_SIZE) - if len(tag) != GCM_TAG_SIZE: - raise ValueError("Encrypted file truncated (missing GCM tag)") - - decryptor = Cipher(algorithms.AES(mek), modes.GCM(nonce, tag)).decryptor() - src.seek(0) - - with open(output_path, "wb") as dst: - processed = 0 - while processed < ciphertext_length: - to_read = min(FILE_ENCRYPT_CHUNK_SIZE, ciphertext_length - processed) - chunk = src.read(to_read) - if len(chunk) != to_read: - raise ValueError("Encrypted file truncated") - processed += len(chunk) - plain = decryptor.update(chunk) - if plain: - dst.write(plain) - total_out += len(plain) - final = decryptor.finalize() - if final: - dst.write(final) - total_out += len(final) - - if total_out <= 0: - raise ValueError("Decrypted file is empty") - return total_out - - -def decrypt_transport_blob( - client_public_key_b64: str, - encrypted_blob: bytes, - ephemeral_private_key: X25519PrivateKey, - nonce_size: int = TRANSPORT_NONCE_SIZE, -) -> bytes: - """ - Decrypt a transport-encrypted binary blob produced by `tweetnacl.box`. - - The client sends a single blob that is `nonce || ciphertext`. - This function extracts the nonce and decrypts the ciphertext using the server's - ephemeral transport private key and the client's public key. - - Args: - client_public_key_b64: Client ephemeral public key in base64 (raw X25519), - the same key as used for the transport-encrypted message body. - encrypted_blob: Raw bytes of `nonce || ciphertext`. - ephemeral_private_key: Server ephemeral X25519 private key. - nonce_size: Nonce size in bytes (24 for XSalsa20-Poly1305). - - Returns: - Decrypted plaintext bytes. - """ - if is_fcae_transport_blob(encrypted_blob): - import io - - return _decrypt_fcae_transport_stream_io( - client_public_key_b64, - io.BytesIO(encrypted_blob), - ephemeral_private_key, - ) - - if len(encrypted_blob) < nonce_size + 16: - # crypto_box has a MAC; ciphertext must have at least some overhead. - raise ValueError("Encrypted blob is too short to contain nonce + ciphertext") - - nonce = encrypted_blob[:nonce_size] - ciphertext = encrypted_blob[nonce_size:] - - try: - server_private_bytes = ephemeral_private_key.private_bytes_raw() - client_public_bytes = base64.b64decode(client_public_key_b64) - plaintext = sodium.crypto_box_open_easy( - ciphertext, - nonce, - client_public_bytes, # sender public key - server_private_bytes, # recipient private key - ) - return plaintext - except Exception as e: - logger.error("Failed to decrypt transport blob: %s", e) - raise - - -def encrypt_message(plaintext: bytes, mek: bytes) -> tuple[str, str]: - """ - Encrypt plaintext using AES-GCM with a Message Encryption Key. - - Args: - plaintext: Message content to encrypt - mek: Message Encryption Key (32 bytes) - - Returns: - Tuple of (nonce_b64, ciphertext_b64) for storage - """ - cipher = AESGCM(mek) - nonce = generate_nonce(MEK_NONCE_SIZE) - ciphertext = cipher.encrypt(nonce, plaintext, None) - return base64.b64encode(nonce).decode("utf-8"), base64.b64encode(ciphertext).decode("utf-8") - - -def decrypt_message(nonce_b64: str, ciphertext_b64: str, mek: bytes) -> bytes: - """ - Decrypt ciphertext using the MEK. - - Args: - nonce_b64: Base64-encoded nonce - ciphertext_b64: Base64-encoded ciphertext + tag - mek: Message Encryption Key (32 bytes) - - Returns: - Plaintext bytes - """ - try: - nonce = base64.b64decode(nonce_b64) - ciphertext = base64.b64decode(ciphertext_b64) - cipher = AESGCM(mek) - plaintext = cipher.decrypt(nonce, ciphertext, None) - return plaintext - except Exception as e: - logger.error("Failed to decrypt message: %s", e) - raise - - -def wrap_mek(mek: bytes, wrap_key: bytes) -> str: - """ - Wrap a MEK using a key encryption key (wrap_key). - Encrypts MEK with AES-256-GCM and returns base64-encoded result. - - Args: - mek: Message Encryption Key to wrap (32 bytes) - wrap_key: Key to wrap with (32 bytes) - - Returns: - Base64-encoded (nonce + ciphertext + tag) - """ - cipher = AESGCM(wrap_key) - nonce = generate_nonce(MEK_NONCE_SIZE) - ciphertext = cipher.encrypt(nonce, mek, None) - wrapped = nonce + ciphertext - return base64.b64encode(wrapped).decode("utf-8") - - -def unwrap_mek(wrapped_b64: str, wrap_key: bytes) -> bytes: - """ - Unwrap a MEK using a key encryption key (wrap_key). - - Args: - wrapped_b64: Base64-encoded (nonce + ciphertext + tag) - wrap_key: Key to unwrap with (32 bytes) - - Returns: - Unwrapped MEK (32 bytes) - """ - try: - wrapped = base64.b64decode(wrapped_b64) - nonce = wrapped[:MEK_NONCE_SIZE] - ciphertext = wrapped[MEK_NONCE_SIZE:] - cipher = AESGCM(wrap_key) - mek = cipher.decrypt(nonce, ciphertext, None) - return mek - except Exception as e: - logger.error("Failed to unwrap MEK: %s", e) - raise diff --git a/backend/services/messaging/main.py b/backend/services/messaging/main.py deleted file mode 100644 index d904498..0000000 --- a/backend/services/messaging/main.py +++ /dev/null @@ -1,506 +0,0 @@ -""" -Messaging Service - Secure cryptographic processing for private messages with compliance access. - -This service handles all encryption/decryption operations for private messages and files, -providing compliance access while ensuring zero-knowledge storage of plaintext content. - -API Endpoints: -- GET /health: Health check -- GET /key/transport/public: Get current ephemeral transport public key -- POST /process: Process encrypted message through envelope encryption pipeline -""" - -import logging -import sys -import time -import base64 -import os -import tempfile -from pathlib import Path -from typing import Dict, Any, Union -from fastapi import FastAPI, HTTPException, status -from nacl.exceptions import CryptoError -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -from pydantic import BaseModel - -logger = logging.getLogger("uvicorn.error") - -_B64_DECODE_KW = {"validate": True} if sys.version_info >= (3, 11) else {} - -# Import encryption modules -from .encryption import ( - generate_nonce, - TRANSPORT_NONCE_SIZE, - decrypt_transport_blob, - decrypt_transport_message, - is_fcae_transport_blob, - decrypt_fcae_transport_blob_to_file, -) -from .processor import process_encrypted_message, process_encrypted_message_and_files - -try: - from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey - from cryptography.hazmat.primitives import serialization -except ImportError: - X25519PrivateKey = None - - -# ============================================================================ -# Compliance Key Management -# ============================================================================ - -_COMPLIANCE_PUBLIC_KEY_B64: str = "" - - -def _initialize_compliance_key(): - """ - Initialize compliance public key from environment variable. - - The compliance public key is generated offline on an air-gapped machine. - Only the public key is provided to the server via COMPLIANCE_PUBLIC_KEY env variable. - The private key never exists on the server - all decryption is done offline. - """ - global _COMPLIANCE_PUBLIC_KEY_B64 - - try: - from services.shared.message_retention import get_message_retention - except ImportError: - from backend.services.shared.message_retention import get_message_retention # type: ignore - - if get_message_retention().never_store_compliance_mek(): - _COMPLIANCE_PUBLIC_KEY_B64 = "" - logger.info( - "Compliance MEK not stored (MESSAGE_RETENTION_DAYS=-1); COMPLIANCE_PUBLIC_KEY optional" - ) - return - - env_key = os.getenv("COMPLIANCE_PUBLIC_KEY", "").strip() - if not env_key: - raise RuntimeError( - "COMPLIANCE_PUBLIC_KEY environment variable must be set. " - "Generate offline on an air-gapped machine: " - "X25519 private key → export public key (base64) → set as env var" - ) - - _COMPLIANCE_PUBLIC_KEY_B64 = env_key - logger.info("Loaded compliance public key from COMPLIANCE_PUBLIC_KEY environment variable") - - -def get_compliance_public_key() -> str: - """Return the compliance system public key.""" - if not _COMPLIANCE_PUBLIC_KEY_B64: - _initialize_compliance_key() - return _COMPLIANCE_PUBLIC_KEY_B64 - - -# ============================================================================ -# Ephemeral Key Management -# ============================================================================ - -_KEY_STATE: Dict[str, Any] = {} - - -def _generate_keypair(): - """ - Generate a fresh X25519 keypair and store it in memory. - - This generates an ephemeral keypair for the session. The private key is kept - in-memory and is never persisted. When a new keypair is generated, the old - one is discarded and its associated data is no longer accessible. - """ - if X25519PrivateKey is None: - raise RuntimeError("cryptography library required for X25519 key generation") - - priv = X25519PrivateKey.generate() - pub = priv.public_key() - pub_bytes = pub.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw) - key_id = str(int(time.time() * 1000)) # Millisecond precision for uniqueness - - _KEY_STATE.clear() - _KEY_STATE.update({ - "key_id": key_id, - "private_key": priv, - "public_key_b64": base64.b64encode(pub_bytes).decode("ascii"), - "created_at": time.time(), - }) - logger.info("Generated new ephemeral keypair with key_id=%s", key_id) - - -def _get_ephemeral_private_key() -> X25519PrivateKey: - """Retrieve the current ephemeral private key, regenerating if necessary.""" - if not _KEY_STATE: - _generate_keypair() - return _KEY_STATE.get("private_key") - - -# ============================================================================ -# FastAPI App Setup -# ============================================================================ - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Startup and shutdown event handler.""" - # Startup: Initialize compliance key and ephemeral keys - try: - _initialize_compliance_key() - _generate_keypair() - logger.info("Messaging service: initialized at startup") - except Exception as e: - logger.error("Messaging service: failed to initialize: %s", e) - raise - - yield - - # Shutdown - logger.info("Messaging service: shutting down") - - -app = FastAPI( - title="FromChat Messaging Service", - description="Secure cryptographic processing service for private messages", - version="1.0.0", - lifespan=lifespan, -) - -# Add security middleware -try: - from services.shared.middleware import add_security_middleware -except ImportError: - try: - from backend.services.shared.middleware import add_security_middleware - except ImportError: - add_security_middleware = None - -if add_security_middleware: - add_security_middleware(app) - -try: - from services.shared.inter_service_rate_limit import attach_internal_service_rate_limit -except ImportError: - from backend.services.shared.inter_service_rate_limit import attach_internal_service_rate_limit # type: ignore - -_internal_limiter = attach_internal_service_rate_limit(app, default_limit="5000/minute") - -# CORS configuration for inter-service communication -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allow all origins for inter-service communication - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -# ============================================================================ -# Pydantic Models -# ============================================================================ - -class ProcessMessageRequest(BaseModel): - """ - Request to process an encrypted message through the envelope encryption pipeline. - - The client must: - 1. Encrypt plaintext with the ephemeral transport public key using X25519 + ChaCha20 - 2. Provide the encrypted message and associated metadata - 3. Provide public keys for compliance, sender, and recipient for MEK wrapping - """ - client_public_key_b64: str - transport_nonce_b64: str - transport_ciphertext_b64: str - compliance_public_key_b64: str - sender_public_key_b64: str - recipient_public_key_b64: str - - -class ProcessMessageWithFilesFile(BaseModel): - """ - A single transport-encrypted file blob (base64 of nonce||ciphertext), - encrypted with the same ephemeral client key as the message body. - """ - encrypted_file_data_b64: str - filename: str = "file" - - -class ProcessMessageWithFilesRequest(ProcessMessageRequest): - """ - Process a transport-encrypted message and a list of transport-encrypted files - using a single MEK for the whole envelope. - - Each file blob uses the same client_public_key_b64 / X25519 ephemeral pair as the message. - """ - files: list[ProcessMessageWithFilesFile] - -# ============================================================================ -# Health Checks -# ============================================================================ - -@app.get("/health", response_model=None) -@_internal_limiter.exempt -async def health_check(): - """Health check endpoint for messaging service.""" - return {"status": "healthy", "service": "messaging"} - - -@app.get("/", response_model=None) -async def root(): - """Root endpoint for messaging service.""" - return {"message": "FromChat Messaging Service", "status": "operational"} - - -# ============================================================================ -# Ephemeral Key Endpoints -# ============================================================================ - -@app.get("/key/transport/public", response_model=None) -async def get_transport_public_key(): - """ - Return the current ephemeral transport public key for client-side message encryption. - - Clients use this key to encrypt their messages with X25519 + ChaCha20-Poly1305 - before sending to the server. - """ - if not _KEY_STATE: - try: - _generate_keypair() - except Exception as e: - logger.error("Failed to regenerate ephemeral key: %s", e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Key generation failed" - ) - - return { - "key_id": _KEY_STATE.get("key_id"), - "public_key_b64": _KEY_STATE.get("public_key_b64"), - "created_at": _KEY_STATE.get("created_at"), - } - - - - -# ============================================================================ -# Message Processing Endpoints -# ============================================================================ - -async def process_message( - client_public_key_b64: str, - transport_nonce_b64: str, - transport_ciphertext_b64: str, - compliance_public_key_b64: str, - sender_public_key_b64: str, - recipient_public_key_b64: str, -): - """ - Process an encrypted message through the envelope encryption pipeline. - - This is the core processing function used by both HTTP and in-process calls. - - Flow: - 1. Decrypt client message using transport encryption (ephemeral key) - 2. Generate random MEK (Message Encryption Key) - 3. Encrypt plaintext with MEK using ChaCha20-Poly1305 - 4. Wrap MEK for compliance, sender, and recipient - 5. Return encrypted message + 3 wrapped MEKs - - Args: - client_public_key_b64: Client's ephemeral public key - transport_nonce_b64: Nonce for transport encryption - transport_ciphertext_b64: Encrypted message - compliance_public_key_b64: Compliance system public key - sender_public_key_b64: Sender's public key - recipient_public_key_b64: Recipient's public key - - Returns: - Dict with: - - nonce: Base64-encoded nonce for content encryption - - ciphertext: Base64-encoded encrypted content - - compliance_wrapped_mek: Wrapped MEK for compliance system - - sender_wrapped_mek: Wrapped MEK for message sender - - recipient_wrapped_mek: Wrapped MEK for message recipient - """ - try: - private_key = _get_ephemeral_private_key() - - result = process_encrypted_message( - client_public_key_b64=client_public_key_b64, - transport_nonce_b64=transport_nonce_b64, - transport_ciphertext_b64=transport_ciphertext_b64, - compliance_public_key_b64=compliance_public_key_b64, - sender_public_key_b64=sender_public_key_b64, - recipient_public_key_b64=recipient_public_key_b64, - ephemeral_private_key=private_key, - ) - - logger.info("Successfully processed encrypted message") - return result - - except Exception as e: - logger.exception("Failed to process message: %s", e) - raise - - -@app.post("/process", response_model=None) -async def process_message_http(request: ProcessMessageRequest): - """ - HTTP endpoint for processing encrypted messages. - - Delegates to the core process_message function. - """ - return await process_message( - client_public_key_b64=request.client_public_key_b64, - transport_nonce_b64=request.transport_nonce_b64, - transport_ciphertext_b64=request.transport_ciphertext_b64, - compliance_public_key_b64=request.compliance_public_key_b64, - sender_public_key_b64=request.sender_public_key_b64, - recipient_public_key_b64=request.recipient_public_key_b64, - ) - - -async def process_message_with_files( - client_public_key_b64: str, - transport_nonce_b64: str, - transport_ciphertext_b64: str, - compliance_public_key_b64: str, - sender_public_key_b64: str, - recipient_public_key_b64: str, - transport_files: list[dict], -): - """ - In-process helper: process message + transport-encrypted files with one MEK. - - File blobs must be encrypted with the same ephemeral client key as the message - (same client_public_key_b64), not the sender's long-term identity key. - transport_files: list of {"encrypted_file_data_b64": str, "filename": str} - """ - private_key = _get_ephemeral_private_key() - - plaintext_message = decrypt_transport_message( - client_public_key_b64, - transport_nonce_b64, - transport_ciphertext_b64, - private_key, - ) - - plaintext_files: list[bytes] = [] - plaintext_file_paths: list[Path | None] = [] - filenames: list[str] = [] - temp_paths: list[Path] = [] - try: - for idx, tf in enumerate(transport_files): - enc_path = (tf.get("encrypted_file_path") or "").strip() - if enc_path: - blob_path = Path(enc_path) - if not blob_path.is_file(): - raise ValueError(f"Transport file path missing index={idx}") - prefix = blob_path.read_bytes()[: len(b"FCAE") + 1] - if is_fcae_transport_blob(prefix): - plain_tmp = Path(tempfile.mkstemp(prefix="fcae-plain-", suffix=".bin")[1]) - temp_paths.append(plain_tmp) - decrypt_fcae_transport_blob_to_file( - client_public_key_b64=client_public_key_b64, - encrypted_path=blob_path, - ephemeral_private_key=private_key, - output_path=plain_tmp, - ) - plaintext_files.append(b"") - plaintext_file_paths.append(plain_tmp) - else: - transport_blob = blob_path.read_bytes() - plaintext_files.append( - decrypt_transport_blob( - client_public_key_b64=client_public_key_b64, - encrypted_blob=transport_blob, - ephemeral_private_key=private_key, - ) - ) - plaintext_file_paths.append(None) - else: - enc_b64 = tf.get("encrypted_file_data_b64", "") - try: - transport_blob = base64.b64decode(enc_b64, **_B64_DECODE_KW) - except Exception as e: - logger.error( - "Invalid base64 for transport file index=%s filename=%r: %s", - idx, - tf.get("filename"), - e, - ) - raise - try: - plaintext_files.append( - decrypt_transport_blob( - client_public_key_b64=client_public_key_b64, - encrypted_blob=transport_blob, - ephemeral_private_key=private_key, - ) - ) - except Exception as e: - logger.error( - "Transport file decrypt failed index=%s filename=%r (check same ephemeral as message): %s", - idx, - tf.get("filename"), - e, - ) - raise - plaintext_file_paths.append(None) - filenames.append(tf.get("filename", "file")) - - return process_encrypted_message_and_files( - plaintext_message=plaintext_message, - plaintext_files=plaintext_files, - filenames=filenames, - plaintext_file_paths=plaintext_file_paths, - compliance_public_key_b64=compliance_public_key_b64, - sender_public_key_b64=sender_public_key_b64, - recipient_public_key_b64=recipient_public_key_b64, - ) - finally: - for p in temp_paths: - try: - p.unlink(missing_ok=True) - except Exception: - pass - - -@app.post("/process-with-files", response_model=None) -async def process_message_with_files_http(request: ProcessMessageWithFilesRequest): - """ - Process an encrypted message and its files using a single MEK. - - - Message and file transport layers use the same client ephemeral X25519 keypair - (client_public_key_b64); files are NaCl box ciphertexts to the server transport key - - One MEK is generated and used to encrypt message + all files - - MEK is wrapped for compliance, sender, and recipient (stored on DM envelope) - """ - try: - transport_files = [ - {"encrypted_file_data_b64": f.encrypted_file_data_b64, "filename": f.filename} - for f in request.files - ] - return await process_message_with_files( - client_public_key_b64=request.client_public_key_b64, - transport_nonce_b64=request.transport_nonce_b64, - transport_ciphertext_b64=request.transport_ciphertext_b64, - compliance_public_key_b64=request.compliance_public_key_b64, - sender_public_key_b64=request.sender_public_key_b64, - recipient_public_key_b64=request.recipient_public_key_b64, - transport_files=transport_files, - ) - except CryptoError as e: - logger.warning("process-with-files: transport CryptoError: %s", e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=( - "Transport decryption failed: message and files must use the same client ephemeral " - "key as when file ciphertext was produced." - ), - ) from e - except Exception as e: - logger.exception("Failed to process message with files: %s", e) - raise - -if __name__ == "__main__": - import uvicorn - port = int(os.getenv("PORT", "8301")) - uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/backend/services/messaging/processor.py b/backend/services/messaging/processor.py deleted file mode 100644 index ffc371b..0000000 --- a/backend/services/messaging/processor.py +++ /dev/null @@ -1,343 +0,0 @@ -""" -Message processing pipeline for envelope encryption. - -This module handles the core envelope encryption workflow: -1. Decrypt client-encrypted message (transport encryption) -2. Generate random MEK -3. Encrypt plaintext with MEK -4. Wrap MEK for compliance, sender, and recipient -5. Store encrypted message + wrapped keys -""" - -import io -import logging -import json -import time -import base64 -from pathlib import Path -from typing import Dict, Any, Optional -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey - -from .encryption import ( - decrypt_transport_message, - generate_mek, - encrypt_message, - encrypt_message_to_file, - wrap_mek, - derive_shared_secret, - derive_key_from_shared_secret, -) - -logger = logging.getLogger("uvicorn.error") - - -def _store_compliance_wrapped_mek() -> bool: - try: - from services.shared.message_retention import get_message_retention - except ImportError: - from backend.services.shared.message_retention import get_message_retention # type: ignore - return not get_message_retention().never_store_compliance_mek() - - -def process_encrypted_message( - client_public_key_b64: str, - transport_nonce_b64: str, - transport_ciphertext_b64: str, - compliance_public_key_b64: str, - sender_public_key_b64: str, - recipient_public_key_b64: str, - ephemeral_private_key: X25519PrivateKey, -) -> Dict[str, Any]: - """ - Process an encrypted message through the envelope encryption pipeline. - - Step 1: Decrypt client message using transport encryption (ephemeral keys) - Step 2: Generate random MEK - Step 3: Encrypt plaintext with MEK - Step 4: Wrap MEK for compliance, sender, recipient (using their provided public keys) - Step 5: Return encrypted message + 3 wrapped MEKs - - Args: - client_public_key_b64: Client's ephemeral public key for transport decryption - transport_nonce_b64: Nonce used for transport encryption - transport_ciphertext_b64: Client's encrypted plaintext - compliance_public_key_b64: Compliance system's public key for MEK wrapping - sender_public_key_b64: Sender's public key for MEK wrapping - recipient_public_key_b64: Recipient's public key for MEK wrapping - ephemeral_private_key: Server's ephemeral X25519 private key - - Returns: - Dict with encrypted message and wrapped MEKs: - { - "nonce": base64-encoded nonce for content encryption, - "ciphertext": base64-encoded encrypted content, - "compliance_wrapped_mek": base64-encoded wrapped MEK, - "sender_wrapped_mek": base64-encoded wrapped MEK, - "recipient_wrapped_mek": base64-encoded wrapped MEK, - } - """ - try: - start_time = time.time() - - # Step 1: Decrypt transport message - logger.info("CRYPTO: Starting envelope encryption processing") - plaintext = decrypt_transport_message( - client_public_key_b64, - transport_nonce_b64, - transport_ciphertext_b64, - ephemeral_private_key, - ) - logger.info( - "CRYPTO: Transport decryption complete, plaintext size: %d bytes", - len(plaintext) - ) - - # Step 2: Generate random MEK - mek = generate_mek() - logger.info("CRYPTO: Generated random MEK (32 bytes)") - - # Step 3: Encrypt plaintext with MEK - content_nonce, ciphertext = encrypt_message(plaintext, mek) - logger.info( - "CRYPTO: Content encryption with MEK complete, ciphertext size: %d bytes", - len(ciphertext) - ) - - # Step 4a: Derive wrap keys deterministically from recipient public keys - # This avoids needing to store the ephemeral transport key - logger.info("CRYPTO: Deriving key wrap keys deterministically") - - # Use HKDF with recipient public key bytes as input to derive wrap keys - # This is deterministic and doesn't require storing ephemeral keys - import base64 - sender_key_bytes = base64.b64decode(sender_public_key_b64) - recipient_key_bytes = base64.b64decode(recipient_public_key_b64) - - logger.info( - "🔑 Deriving wrap keys for sender=%s... recipient=%s...", - sender_public_key_b64[:20], - recipient_public_key_b64[:20], - ) - - sender_wrap_key = derive_key_from_shared_secret(sender_key_bytes, "sender_wrap_key") - recipient_wrap_key = derive_key_from_shared_secret(recipient_key_bytes, "recipient_wrap_key") - - logger.info("✅ Sender/recipient wrap keys derived successfully") - - if _store_compliance_wrapped_mek(): - if not (compliance_public_key_b64 or "").strip(): - raise ValueError( - "compliance public key required when MESSAGE_RETENTION_DAYS is not -1" - ) - compliance_key_bytes = base64.b64decode(compliance_public_key_b64) - compliance_wrap_key = derive_key_from_shared_secret( - compliance_key_bytes, "compliance_wrap_key" - ) - compliance_wrapped_mek = wrap_mek(mek, compliance_wrap_key) - logger.info( - "🔐 Compliance MEK: %s... (%s chars)", - compliance_wrapped_mek[:30], - len(compliance_wrapped_mek), - ) - else: - compliance_wrapped_mek = None - logger.info("CRYPTO: Compliance MEK not stored (MESSAGE_RETENTION_DAYS=-1)") - - sender_wrapped_mek = wrap_mek(mek, sender_wrap_key) - recipient_wrapped_mek = wrap_mek(mek, recipient_wrap_key) - - logger.info(f"🔐 MEK wrapping complete:") - logger.info(f" Sender MEK: {sender_wrapped_mek[:30]}... ({len(sender_wrapped_mek)} chars)") - logger.info(f" Recipient MEK: {recipient_wrapped_mek[:30]}... ({len(recipient_wrapped_mek)} chars)") - - duration = time.time() - start_time - logger.info( - "CRYPTO: Successfully processed message with MEK wraps in %.2fms", - duration * 1000, - ) - - # Get the transport public key for storage with the message - transport_public_key_b64 = base64.b64encode(ephemeral_private_key.public_key().public_bytes_raw()).decode("ascii") - - return { - "nonce": content_nonce, - "ciphertext": ciphertext, - "compliance_wrapped_mek": compliance_wrapped_mek, - "sender_wrapped_mek": sender_wrapped_mek, - "recipient_wrapped_mek": recipient_wrapped_mek, - } - - except Exception as e: - duration = time.time() - start_time - logger.exception( - "CRYPTO: Failed to process encrypted message after %.2fms: %s", - duration * 1000, str(e) - ) - raise - - -_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) -_THUMB_SIZE = 80 - - -def _generate_thumbnail(image_bytes: bytes) -> tuple[str | None, list[int]]: - """Generate tiny JPEG thumbnail (Telegram-style). Returns (base64_jpeg, [w,h]) or (None, [1,1]) on error.""" - try: - from PIL import Image, ImageOps - img = ImageOps.exif_transpose(Image.open(io.BytesIO(image_bytes))) - img = img.convert("RGB") - if hasattr(img, "info") and img.info: - img.info.pop("icc_profile", None) - w, h = img.size - # Pixel dimensions after EXIF orientation (clients compute width/height from this). - aspect_wh = [w, h] - if w > _THUMB_SIZE or h > _THUMB_SIZE: - scale = min(_THUMB_SIZE / w, _THUMB_SIZE / h) - new_w = max(1, int(w * scale)) - new_h = max(1, int(h * scale)) - img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=85, optimize=True) - jpeg_b64 = base64.b64encode(buf.getvalue()).decode("ascii") - logger.info("THUMB: Image %dx%d -> thumb %dx%d, b64len=%d", w, h, img.width, img.height, len(jpeg_b64)) - return (jpeg_b64, aspect_wh) - except Exception as e: - logger.warning("THUMB: Generation failed: %s", e) - return (None, [1, 1]) - - -_LARGE_FILE_THUMB_BYTES = 32 * 1024 * 1024 - - -def process_encrypted_message_and_files( - plaintext_message: bytes, - plaintext_files: list[bytes], - filenames: list[str], - compliance_public_key_b64: str, - sender_public_key_b64: str, - recipient_public_key_b64: str, - plaintext_file_paths: list[Path | None] | None = None, -) -> Dict[str, Any]: - """ - Process a message and its attached files using a single MEK. - - - Generates one random MEK - - Encrypts message and each file with AES-GCM using that MEK (unique nonce per item) - - Wraps the MEK for compliance, sender, and recipient - Returns: - { - "message": {"nonce": str, "ciphertext": str}, - "files": [{"nonce": str, "ciphertext": str}, ...], - ... - } - """ - start_time = time.time() - if len(filenames) != len(plaintext_files): - filenames = [f"file_{i}" for i in range(len(plaintext_files))] - - paths = plaintext_file_paths or [None] * len(plaintext_files) - if len(paths) < len(plaintext_files): - paths = paths + [None] * (len(plaintext_files) - len(paths)) - - # One MEK for everything in this envelope - mek = generate_mek() - - # Build message plaintext: when we have files, use JSON with text + fileThumbnails + fileAspectRatios + fileSizes - file_thumbnails: list[str] = [] - file_aspect_ratios: list[list[int]] = [] - file_sizes: list[int] = [] - for i, f_bytes in enumerate(plaintext_files): - name = filenames[i] if i < len(filenames) else "" - path = paths[i] - size = int(path.stat().st_size) if path is not None else len(f_bytes) - file_sizes.append(size) - if ( - path is None - and Path(name).suffix.lower() in _IMAGE_EXTENSIONS - ): - thumb_b64, wh = _generate_thumbnail(f_bytes) - file_thumbnails.append(thumb_b64 or "") - file_aspect_ratios.append(wh) - elif ( - path is not None - and size <= _LARGE_FILE_THUMB_BYTES - and Path(name).suffix.lower() in _IMAGE_EXTENSIONS - ): - thumb_b64, wh = _generate_thumbnail(path.read_bytes()) - file_thumbnails.append(thumb_b64 or "") - file_aspect_ratios.append(wh) - else: - file_thumbnails.append("") - file_aspect_ratios.append([1, 1]) - - if plaintext_files: - msg_obj = { - "text": plaintext_message.decode("utf-8", errors="replace"), - "fileThumbnails": file_thumbnails, - "fileAspectRatios": file_aspect_ratios, - "fileSizes": file_sizes, - } - logger.info( - "THUMB: Message with %d files, thumbnails=%s, aspectRatios=%s", - len(file_thumbnails), - [f"len={len(t)}" if t else "empty" for t in file_thumbnails], - file_aspect_ratios, - ) - plaintext_to_encrypt = json.dumps(msg_obj, ensure_ascii=False).encode("utf-8") - else: - plaintext_to_encrypt = plaintext_message - - # Encrypt message - msg_nonce, msg_ciphertext = encrypt_message(plaintext_to_encrypt, mek) - - # Encrypt files (same MEK, per-file nonce) - files_out: list[Dict[str, Any]] = [] - import tempfile - - for i, f_bytes in enumerate(plaintext_files): - path = paths[i] - if path is not None: - enc_tmp = Path(tempfile.mkstemp(prefix="mek-enc-", suffix=".bin")[1]) - f_nonce = encrypt_message_to_file(path, mek, enc_tmp) - files_out.append({"nonce": f_nonce, "ciphertext_path": str(enc_tmp)}) - else: - f_nonce, f_ciphertext = encrypt_message(f_bytes, mek) - files_out.append({"nonce": f_nonce, "ciphertext": f_ciphertext}) - - # Derive wrap keys deterministically (same as existing flow) - sender_key_bytes = base64.b64decode(sender_public_key_b64) - recipient_key_bytes = base64.b64decode(recipient_public_key_b64) - - sender_wrap_key = derive_key_from_shared_secret(sender_key_bytes, "sender_wrap_key") - recipient_wrap_key = derive_key_from_shared_secret(recipient_key_bytes, "recipient_wrap_key") - - if _store_compliance_wrapped_mek(): - if not (compliance_public_key_b64 or "").strip(): - raise ValueError( - "compliance public key required when MESSAGE_RETENTION_DAYS is not -1" - ) - compliance_key_bytes = base64.b64decode(compliance_public_key_b64) - compliance_wrap_key = derive_key_from_shared_secret( - compliance_key_bytes, "compliance_wrap_key" - ) - compliance_wrapped_mek = wrap_mek(mek, compliance_wrap_key) - else: - compliance_wrapped_mek = None - - sender_wrapped_mek = wrap_mek(mek, sender_wrap_key) - recipient_wrapped_mek = wrap_mek(mek, recipient_wrap_key) - - duration = time.time() - start_time - logger.info( - "CRYPTO: Processed message+%d files with single MEK in %.2fms", - len(files_out), - duration * 1000, - ) - - return { - "message": {"nonce": msg_nonce, "ciphertext": msg_ciphertext}, - "files": files_out, - "compliance_wrapped_mek": compliance_wrapped_mek, - "sender_wrapped_mek": sender_wrapped_mek, - "recipient_wrapped_mek": recipient_wrapped_mek, - } diff --git a/backend/services/shared/__init__.py b/backend/services/shared/__init__.py deleted file mode 100644 index 6aa3ac7..0000000 --- a/backend/services/shared/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Shared code across microservices \ No newline at end of file diff --git a/backend/services/shared/inter_service_rate_limit.py b/backend/services/shared/inter_service_rate_limit.py deleted file mode 100644 index 9fbb5b9..0000000 --- a/backend/services/shared/inter_service_rate_limit.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Per-IP rate limits for internal FastAPI apps (messaging, file_storage). - -Complements the main service's endpoint-specific limits. Uses a generous default -because traffic is mostly from the main backend (single Docker bridge IP). -""" - -from __future__ import annotations - -from fastapi import FastAPI, Request -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.errors import RateLimitExceeded -from slowapi.middleware import SlowAPIMiddleware -from slowapi.util import get_remote_address - - -def _client_ip_key(request: Request) -> str: - if request is None: - return "unknown" - headers = request.headers - real = (headers.get("x-real-ip") or headers.get("X-Real-IP") or "").strip() - if real: - return real - forwarded = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For") - if forwarded: - first = forwarded.split(",")[0].strip() - if first: - return first - if request.client and request.client.host: - return request.client.host - return get_remote_address(request) - - -def attach_internal_service_rate_limit( - app: FastAPI, - *, - default_limit: str = "6000/minute", -) -> Limiter: - """ - Register SlowAPI on ``app`` with a default limit for all routes. - Use ``@limiter.exempt`` on ``/health`` (and similar) so probes are not throttled. - """ - limiter = Limiter( - key_func=_client_ip_key, - default_limits=[default_limit], - storage_uri="memory://", - ) - app.state.limiter = limiter - app.add_middleware(SlowAPIMiddleware) - app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - return limiter diff --git a/backend/services/shared/message_retention.py b/backend/services/shared/message_retention.py deleted file mode 100644 index 1cca054..0000000 --- a/backend/services/shared/message_retention.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Single MESSAGE_RETENTION_DAYS policy (required env, no in-code default). - -- Positive float: age-based cleanup after that many days (same cutoff for compliance MEK, - soft-deleted DM keys, and edit-history rows). Value may be an arithmetic expression. -- 0: retain forever (no time-based cleanup; compliance MEK is still stored when a public key is configured). -- -1: do not store compliance-wrapped MEK; no time-based cleanup (same as 0 for expiry). -""" - -from __future__ import annotations - -import ast -import math -import os -from dataclasses import dataclass -from datetime import timedelta -MESSAGE_RETENTION_DAYS = "MESSAGE_RETENTION_DAYS" - -_state: MessageRetentionState | None = None - - -@dataclass(frozen=True) -class MessageRetentionState: - """Parsed MESSAGE_RETENTION_DAYS (days, after evaluating optional expression).""" - - days: float - - def never_store_compliance_mek(self) -> bool: - return self.days == -1.0 - - def cleanup_enabled(self) -> bool: - return self.days > 0.0 - - def retention_timedelta(self) -> timedelta: - return timedelta(days=self.days) - - -def _eval_numeric(node: ast.AST) -> float: - if isinstance(node, ast.Constant): - if isinstance(node.value, bool): - raise ValueError("MESSAGE_RETENTION_DAYS expression must be numeric") - if isinstance(node.value, (int, float)): - return float(node.value) - raise ValueError("MESSAGE_RETENTION_DAYS expression must be numeric") - if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): - return -_eval_numeric(node.operand) - if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.UAdd): - return _eval_numeric(node.operand) - if isinstance(node, ast.BinOp): - left = _eval_numeric(node.left) - right = _eval_numeric(node.right) - if isinstance(node.op, ast.Add): - return left + right - if isinstance(node.op, ast.Sub): - return left - right - if isinstance(node.op, ast.Mult): - return left * right - if isinstance(node.op, ast.Div): - return left / right - if isinstance(node.op, ast.FloorDiv): - return left // right - if isinstance(node.op, ast.Mod): - return left % right - if isinstance(node.op, ast.Pow): - return left ** right - raise ValueError("Unsupported operator in MESSAGE_RETENTION_DAYS") - if isinstance(node, ast.Num): # py<3.8 compatibility - return float(node.n) - raise ValueError("Unsupported syntax in MESSAGE_RETENTION_DAYS (only numbers and + - * / // % **)") - - -def eval_message_retention_expression(raw: str) -> float: - s = raw.strip() - if not s: - raise ValueError("MESSAGE_RETENTION_DAYS must not be empty") - tree = ast.parse(s, mode="eval") - if not isinstance(tree, ast.Expression): - raise ValueError("Invalid MESSAGE_RETENTION_DAYS expression") - value = _eval_numeric(tree.body) - if math.isnan(value) or math.isinf(value): - raise ValueError("MESSAGE_RETENTION_DAYS must be finite") - if value < 0 and value != -1.0: - raise ValueError("MESSAGE_RETENTION_DAYS must be >= 0, or exactly -1") - return value - - -def load_message_retention_from_env() -> MessageRetentionState: - raw = os.getenv(MESSAGE_RETENTION_DAYS) - if raw is None or not str(raw).strip(): - raise ValueError( - "MESSAGE_RETENTION_DAYS environment variable must be set " - "(float days; expressions like 1/24/60*5 allowed; 0 = retain forever; -1 = do not store compliance MEK)" - ) - return MessageRetentionState(days=eval_message_retention_expression(str(raw))) - - -def get_message_retention() -> MessageRetentionState: - global _state - if _state is None: - _state = load_message_retention_from_env() - return _state - - -def reset_message_retention_cache_for_tests() -> None: - global _state - _state = None diff --git a/backend/services/shared/middleware.py b/backend/services/shared/middleware.py deleted file mode 100644 index b913dad..0000000 --- a/backend/services/shared/middleware.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Shared middleware for inter-service communication validation and security. - -Provides: -- Request size limiting (max 5GB) -- Input validation and sanitization -- Comprehensive audit logging -- Health check access log filtering -""" - -import logging -import time -from typing import Callable -from fastapi import FastAPI, Request, HTTPException, status -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.responses import Response - -logger = logging.getLogger("uvicorn.error") -access_logger = logging.getLogger("uvicorn.access") - - -class HealthCheckFilter(logging.Filter): - """Filter to suppress access logs for health check requests.""" - - def filter(self, record: logging.LogRecord) -> bool: - """Return False to suppress logs containing health check requests.""" - message = record.getMessage() - return "GET /health HTTP/" not in message - -# Maximum request size: 5GB -MAX_REQUEST_SIZE = 5 * 1024 * 1024 * 1024 # 5GB in bytes - - -class RequestSizeLimitMiddleware(BaseHTTPMiddleware): - """Middleware to enforce maximum request size.""" - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - """Check request size before processing.""" - # Check Content-Length header if available - content_length = request.headers.get("content-length") - if content_length: - try: - size = int(content_length) - if size > MAX_REQUEST_SIZE: - logger.warning( - "Request size %d exceeds limit %d from %s %s", - size, - MAX_REQUEST_SIZE, - request.client.host if request.client else "unknown", - request.url.path, - ) - raise HTTPException( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - detail=f"Request size exceeds {MAX_REQUEST_SIZE} bytes limit" - ) - except ValueError: - pass - - return await call_next(request) - - -# Apply health check filter to access logger -if not any(isinstance(f, HealthCheckFilter) for f in access_logger.filters): - access_logger.addFilter(HealthCheckFilter()) - - -def add_security_middleware(app: FastAPI): - """ - Add all security and audit middleware to FastAPI app. - - Args: - app: FastAPI application instance - """ - # Request size limiting (inner, checked first) - app.add_middleware(RequestSizeLimitMiddleware) diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..4db365c --- /dev/null +++ b/compose.yml @@ -0,0 +1,12 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.fromchat.ru} + env_file: + - .env + ports: + - "8301:80" + restart: always \ No newline at end of file diff --git a/data/database.db-shm b/data/database.db-shm deleted file mode 100644 index fe9ac28..0000000 Binary files a/data/database.db-shm and /dev/null differ diff --git a/data/database.db-wal b/data/database.db-wal deleted file mode 100644 index e69de29..0000000 diff --git a/deployment/.dockerignore b/deployment/.dockerignore deleted file mode 100644 index d6dd40d..0000000 --- a/deployment/.dockerignore +++ /dev/null @@ -1,34 +0,0 @@ -# Node.js -node_modules -npm-debug.log -.env -.idea -.vscode - -# Python -__pycache__/ -*.pyc -.pytest_cache/ -.mypy_cache/ -.ipynb_checkpoints -.venv -venv/ - -# Git -.git -.gitignore - -# macOS -.DS_Store - -# Common -# Exclude editor and OS files, as well as test results -dist -dist-electron -build -coverage -test_results/ -out - -data -logs \ No newline at end of file diff --git a/deployment/Dockerfile b/deployment/Dockerfile deleted file mode 100644 index 43205a1..0000000 --- a/deployment/Dockerfile +++ /dev/null @@ -1,111 +0,0 @@ -# ============================================================================ -# COMPLIANCE ARCHITECTURE - Unified Dockerfile -# ============================================================================ -# Base stage with common dependencies for all services - -FROM python:3.12-slim AS base - -# Create common directories -RUN mkdir -p /app && \ - useradd -u 1000 -m app && \ - useradd -u 1001 -m messaging && \ - useradd -u 1002 -m -s /bin/false filestorage - -# Set working directory -WORKDIR /app - -# Copy health check script -COPY --chown=app:app deployment/healthcheck.py /usr/local/bin/healthcheck.py -RUN chmod +x /usr/local/bin/healthcheck.py - -# Copy and install Python dependencies with pip cache -COPY --chown=app:app backend/requirements.txt . -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install --no-cache-dir -r requirements.txt - -# ============================================================================ -# MAIN SERVICE - User-facing operations -# ============================================================================ -FROM base AS main - -# Copy main service code -COPY --chown=app:app backend/services/main/ ./services/main/ -COPY --chown=app:app backend/services/shared/ ./services/shared/ -COPY --chown=app:app backend/alembic/ ./alembic/ -COPY --chown=app:app backend/alembic.ini ./ - -# Create data directories for main service -RUN mkdir -p /app/data /app/logs /app/alembic/versions && \ - chown -R app:app /app/data /app/logs /app/alembic - -# Switch to non-root user -USER app - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD python3 /usr/local/bin/healthcheck.py - -# Expose port -EXPOSE ${PORT:-8300} - -# Run main service -CMD ["python", "-m", "services.main.main"] - -# ============================================================================ -# MESSAGING SERVICE - Secure cryptographic processing -# ============================================================================ -FROM base AS messaging - -# Copy messaging service code -COPY --chown=messaging:messaging backend/services/messaging/ ./services/messaging/ -COPY --chown=messaging:messaging backend/services/shared/ ./services/shared/ - -# Create directories with restricted permissions -RUN mkdir -p /app/logs && \ - chown -R messaging:messaging /app && \ - chmod 700 /app - -# Switch to non-root user -USER messaging - -# Health check - only accessible internally -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD python3 /usr/local/bin/healthcheck.py - -# Expose port (internal only) -EXPOSE ${PORT:-8301} - -# Run messaging service -CMD ["python", "-m", "services.messaging.main"] - -# ============================================================================ -# FILE STORAGE SERVICE - Secure file storage with execution prevention -# ============================================================================ -FROM base AS file_storage - -# Copy file storage service code -COPY --chown=filestorage:filestorage backend/services/file_storage/ ./services/file_storage/ -COPY --chown=filestorage:filestorage backend/services/shared/ ./services/shared/ -COPY --chown=filestorage:filestorage backend/services/main/db.py ./services/main/ -COPY --chown=filestorage:filestorage backend/services/main/dependencies.py ./services/main/ -COPY --chown=filestorage:filestorage backend/services/main/models.py ./services/main/ -COPY --chown=filestorage:filestorage backend/services/main/constants.py ./services/main/ -COPY --chown=filestorage:filestorage backend/services/main/utils.py ./services/main/ - -# Create secure file storage directories -RUN mkdir -p /app/files /app/logs && \ - chown -R filestorage:filestorage /app && \ - chmod 700 /app - -# Switch to non-root user -USER filestorage - -# Health check - only accessible internally -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD python3 /usr/local/bin/healthcheck.py - -# Expose port (internal only) -EXPOSE ${PORT:-8302} - -# Run file storage service with permission fix -CMD ["sh", "-c", "chown -R filestorage:filestorage /app/files /app/logs 2>/dev/null || true && exec python -m services.file_storage.main"] \ No newline at end of file diff --git a/deployment/Dockerfile.backend b/deployment/Dockerfile.backend deleted file mode 100644 index e2c4fac..0000000 --- a/deployment/Dockerfile.backend +++ /dev/null @@ -1,29 +0,0 @@ -# 1. Install pip dependencies -FROM python:3.12 AS builder - -WORKDIR /app -RUN python3 -m venv .venv -COPY backend/requirements.txt . -RUN --mount=type=cache,target=/root/.cache/pip \ - ./.venv/bin/pip3 install -r requirements.txt - -# 2. Runtime stage -FROM python:3.12-slim AS runtime - -# 2.1. Non-root user -WORKDIR /app -RUN useradd -u 1000 app && \ - chown -R app /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 /app/logs && \ - chown -R app /app/data /app/logs && \ - printf '#!/bin/sh\nexec /app/.venv/bin/python /app/admin_cli.py "$@"\n' > /usr/local/bin/admin-cli && \ - chmod +x /usr/local/bin/admin-cli - -USER app - -# 3. Final command -ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py \ No newline at end of file diff --git a/deployment/Dockerfile.postgres b/deployment/Dockerfile.postgres deleted file mode 100644 index b820731..0000000 --- a/deployment/Dockerfile.postgres +++ /dev/null @@ -1,10 +0,0 @@ -FROM postgres:15 - -# Install envsubst for environment variable substitution -RUN apt-get update && apt-get install -y gettext-base && rm -rf /var/lib/apt/lists/* - -# Copy the template -COPY init-postgres.sql.template /docker-entrypoint-initdb.d/init-postgres.sql.template - -# Set the default command to process template and run PostgreSQL -CMD ["bash", "-c", "if [ ! -f /var/lib/postgresql/data/PG_VERSION ]; then echo 'Processing PostgreSQL init template...'; envsubst < /docker-entrypoint-initdb.d/init-postgres.sql.template > /docker-entrypoint-initdb.d/init-postgres.sql; echo 'Template processing complete.'; fi; exec docker-entrypoint.sh postgres"] \ No newline at end of file diff --git a/deployment/README.md b/deployment/README.md deleted file mode 100644 index 5b63578..0000000 --- a/deployment/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# FromChat Compliance Architecture - Docker Deployment - -This directory contains the Docker configuration for the 3-service compliance architecture. - -## Architecture Overview - -``` -┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Clients │────│ Main Service │────│ Messaging │ -│ │ │ (Port 8300) │ │ Service │ -│ Web/Apps │ │ │ │ (Port 8301) │ -│ │ │ • User auth │ │ • Encryption │ -└─────────────┘ │ • WebSocket │ │ • Compliance │ - │ • API proxy │ │ • No ext access │ - └─────────────────┘ └─────────────────┘ - │ │ - │ │ - ▼ ▼ - ┌─────────────────┐ ┌─────────────────┐ - │ File Storage │ │ PostgreSQL │ - │ Service │ │ Database │ - │ (Port 8302) │ │ • Main schema │ - │ • Secure files │ │ • Messaging │ - │ • No ext access │ │ • File schema │ - └─────────────────┘ └─────────────────┘ -``` - -## Docker Build Optimization - -- **Unified Dockerfile**: Single Dockerfile with multi-stage builds for all services -- **Shared Base**: Common Python dependencies cached in base stage -- **Zero System Dependencies**: No gcc, curl, or system packages - pure Python -- **Python Health Checks**: Built-in health monitoring using urllib -- **Aggressive Caching**: Pip cache and layer optimization -- **Security**: Non-root users, restricted permissions per service - -## Security Features - -- **Network Isolation**: Messaging and file storage services attach only to the internal `services` network (`internal: true`) — no path to the public internet. PostgreSQL is on `services` only (not on `public`), so other `public`-only containers cannot reach the DB over Docker DNS; the host still uses the published `127.0.0.1:5432` port map. -- **Inter-service rate limits**: The messaging and file_storage apps use SlowAPI with a high per-IP default (`5000/minute`) plus an exempt `/health` route; traffic is mostly from the main service. The main API keeps finer per-route limits. -- **Firewall note**: Isolation is enforced with Docker networks (not iptables inside containers). Optional **gVisor / runsc** remains a manual host-level step (see plan); it is not automated here. -- **Database Separation**: Each service has its own schema with minimal required permissions -- **Secure File Storage**: File storage uses restricted permissions and user isolation (stored files `chmod 600`, dirs `700`) -- **Ephemeral Keys**: Messaging service generates temporary keys (never persisted) - -## Environment Variables Required - -Create a `.env` file in this directory with the following variables: - -```bash -# Database -POSTGRES_PASSWORD=your_secure_postgres_password -MAIN_DB_PASSWORD=separate_password_for_main_service -MESSAGING_DB_PASSWORD=separate_password_for_messaging -FILE_STORAGE_DB_PASSWORD=separate_password_for_file_storage - -# Security -JWT_SECRET=your_jwt_secret_key -VAPID_PUBLIC_KEY=generated_vapid_public_key -VAPID_PRIVATE_KEY=generated_vapid_private_key - -# Compliance (public key only - private key stays offline) -COMPLIANCE_PUBLIC_KEY=base64_encoded_public_key -``` - -The main backend **requires** Firebase for Android push (FCM). It is not generated into `.env`. The code loads `backend/firebase-cert.json` (path fixed relative to the backend tree); `docker-compose.yml` read-only-mounts that file into the container. Place your Firebase service account JSON at `backend/firebase-cert.json` before `docker compose up` (gitignored; excluded from the image build via the repo-root `.dockerignore`). - -## Deployment Commands - -```bash -# Start all services -docker compose up -d - -# View logs -docker compose logs -f - -# Stop services -docker compose down - -# Rebuild and restart -docker compose up -d --build -``` - -## Development Mode - -For local development, set `SERVICE_MODE=development` to run all services in a single Python process instead of containers. - -## Network Architecture - -- **public**: External client access (main service, frontend, reverse proxy). Main is also on `services` so it can reach Postgres, messaging, and file_storage. -- **services**: Internal bridge (`internal: true`). Postgres, messaging, file_storage, and main. The **frontend** is on both `public` and `services` so the Node server can reach `main` and `file_storage` (`FILE_STORAGE_HOST`) for SSR/proxy paths without exposing those backends on `public` directly. -- Messaging and file_storage are **not** on `public` and cannot reach the internet. -- Inter-service traffic is HTTP with shared middleware (request size cap, rate limits on internal apps). - -## Database Schema Separation - -- `fromchat_main`: User data, authentication, profiles -- `fromchat_messaging`: Encrypted messages, keys, compliance data -- `fromchat_files`: File metadata, storage references - -Each service has minimal required database permissions for security isolation. \ No newline at end of file diff --git a/deployment/caddy/Caddyfile b/deployment/caddy/Caddyfile deleted file mode 100644 index d9b1285..0000000 --- a/deployment/caddy/Caddyfile +++ /dev/null @@ -1,120 +0,0 @@ -{ - servers { - listener_wrappers { - proxy_protocol - tls - } - - # Only trust PROXY protocol from local forwarder. - trusted_proxies static 127.0.0.1/32 ::1/128 - } - - http_port 8080 - https_port 8443 -} - -fromchat.ru { - reverse_proxy frontend: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'; 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} - } - } - } -} - -git.fromchat.ru { - reverse_proxy 172.18.0.1:3000 host.docker.internal:3000 172.17.0.1:3000 { - 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" - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - Permissions-Policy "geolocation=(), microphone=(), camera=()" - } - - rate_limit { - zone global { - key {remote_ip} - window 1m - burst 20 - events 500 - } - } -} \ No newline at end of file diff --git a/deployment/caddy/Dockerfile b/deployment/caddy/Dockerfile deleted file mode 100644 index 0aab7e0..0000000 --- a/deployment/caddy/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# -# Custom Caddy built with: -# - Rate limit plugin -# - -FROM caddy:2-builder AS builder -RUN xcaddy build \ - --with github.com/mholt/caddy-ratelimit - -FROM caddy:2 - -COPY --from=builder /usr/bin/caddy /usr/bin/caddy -COPY Caddyfile /etc/caddy/Caddyfile -COPY additions /etc/caddy/additions - -# Merge base Caddyfile + additional snippets (if present), -# in alphabetical order, and remove additions from final image. -RUN sh -c 'set -eu; \ - if [ -d /etc/caddy/additions ]; then \ - tmp="$(mktemp)"; \ - cat /etc/caddy/Caddyfile > "$tmp"; \ - for f in $(printf "%s\n" /etc/caddy/additions/* 2>/dev/null | sort); do \ - [ -f "$f" ] || continue; \ - base="$(basename "$f")"; \ - [ "$base" = ".gitkeep" ] && continue; \ - [ "$base" = ".gitignore" ] && continue; \ - printf "\n\n" >> "$tmp"; \ - cat "$f" >> "$tmp"; \ - done; \ - mv "$tmp" /etc/caddy/Caddyfile; \ - rm -rf /etc/caddy/additions; \ - fi' \ No newline at end of file diff --git a/deployment/caddy/additions/.gitignore b/deployment/caddy/additions/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/deployment/caddy/additions/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml deleted file mode 100644 index 865a62a..0000000 --- a/deployment/docker-compose.yml +++ /dev/null @@ -1,238 +0,0 @@ -services: - main: - build: - dockerfile: deployment/Dockerfile - context: .. - target: main - environment: - PORT: 8300 - SERVICE_MODE: production - MESSAGING_SERVICE_URL: http://messaging:8301 - FILE_STORAGE_SERVICE_URL: http://file_storage:8302 - DATABASE_URL: postgresql://main_user:${MAIN_DB_PASSWORD}@postgres:5432/fromchat_main - MAIN_DB_PASSWORD: ${MAIN_DB_PASSWORD} - JWT_SECRET: ${JWT_SECRET} - VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} - VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} - COMPLIANCE_PUBLIC_KEY: ${COMPLIANCE_PUBLIC_KEY} - MESSAGE_RETENTION_DAYS: ${MESSAGE_RETENTION_DAYS} - ports: - - "8300:8300" - volumes: - - data:/app/data - - main_logs:/app/logs - # backend/firebase-cert.json on host → path resolved by push_service (__file__ → /app) - - ../backend/firebase-cert.json:/app/firebase-cert.json:ro - networks: - - public - - services - depends_on: - postgres: - condition: service_healthy - healthcheck: - test: ["CMD", "python3", "/usr/local/bin/healthcheck.py"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 20s - restart: unless-stopped - extra_hosts: - - "host.docker.internal:host-gateway" - develop: - watch: - - action: sync+restart - path: ../backend/services/main - target: /app/services/main - - action: sync+restart - path: ../backend/services/shared - target: /app/services/shared - - action: rebuild - path: ../backend/requirements.txt - - messaging: - build: - dockerfile: deployment/Dockerfile - context: .. - target: messaging - environment: - PORT: 8301 - SERVICE_MODE: production - DATABASE_URL: postgresql://messaging_user:${MESSAGING_DB_PASSWORD}@postgres:5432/fromchat_messaging - MESSAGING_DB_PASSWORD: ${MESSAGING_DB_PASSWORD} - COMPLIANCE_PUBLIC_KEY: ${COMPLIANCE_PUBLIC_KEY} - MESSAGE_RETENTION_DAYS: ${MESSAGE_RETENTION_DAYS} - volumes: - - messaging_logs:/app/logs - networks: - - services - depends_on: - postgres: - condition: service_healthy - main: - condition: service_healthy - healthcheck: - test: ["CMD", "python3", "/usr/local/bin/healthcheck.py"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 3s - restart: unless-stopped - - develop: - watch: - - action: sync+restart - path: ../backend/services/messaging - target: /app/services/messaging - - action: sync+restart - path: ../backend/services/shared - target: /app/services/shared - - action: rebuild - path: ../backend/requirements.txt - - file_storage: - build: - dockerfile: deployment/Dockerfile - context: .. - target: file_storage - environment: - PORT: 8302 - SERVICE_MODE: production - DATABASE_URL: postgresql://file_storage_user:${FILE_STORAGE_DB_PASSWORD}@postgres:5432/fromchat_files - FILE_STORAGE_DB_PASSWORD: ${FILE_STORAGE_DB_PASSWORD} - JWT_SECRET: ${JWT_SECRET} - volumes: - - files:/app/files - - file_storage_logs:/app/logs - networks: - - services - depends_on: - postgres: - condition: service_healthy - main: - condition: service_healthy - healthcheck: - test: ["CMD", "python3", "/usr/local/bin/healthcheck.py"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 3s - restart: unless-stopped - - develop: - watch: - - action: sync+restart - path: ../backend/services/file_storage - target: /app/services/file_storage - - action: sync+restart - path: ../backend/services/shared - target: /app/services/shared - - action: rebuild - path: ../backend/requirements.txt - - frontend: - build: - dockerfile: deployment/frontend/Dockerfile - context: .. - environment: - PORT: 8301 - BACKEND_HOST: http://main:8300 - FILE_STORAGE_HOST: http://file_storage:8302 - ports: - - "8301:8301" - networks: - - public - - services - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8301/"] - interval: 30s - timeout: 10s - retries: 3 - restart: unless-stopped - - develop: - watch: - - action: rebuild - path: ../frontend - - action: sync+restart - path: ../server.js - target: /server/server.js - - action: rebuild - path: ../package.json - - caddy: - build: - context: ./caddy - dockerfile: Dockerfile - profiles: - - production - restart: unless-stopped - ports: - - "127.0.0.1:8080:8080" - - "127.0.0.1:8443:8443" - extra_hosts: - - "host.docker.internal:host-gateway" - volumes: - - caddy:/root/site/certs - environment: - XDG_DATA_HOME: /root/site/certs - XDG_CONFIG_HOME: /root/site/certs - networks: - - public - - haproxy: - image: haproxy:latest - profiles: - - production - restart: unless-stopped - network_mode: host - # Image defaults to USER haproxy (non-root); that user cannot bind 80/443 on host. - # Rootful Docker does not change that — only the container user does. - user: "0:0" - cap_add: - - NET_BIND_SERVICE - depends_on: - - caddy - volumes: - - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro - - postgres: - build: - context: . - dockerfile: Dockerfile.postgres - environment: - POSTGRES_DB: fromchat - POSTGRES_USER: postgres - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - MAIN_DB_PASSWORD: ${MAIN_DB_PASSWORD} - MESSAGING_DB_PASSWORD: ${MESSAGING_DB_PASSWORD} - FILE_STORAGE_DB_PASSWORD: ${FILE_STORAGE_DB_PASSWORD} - ports: - - "127.0.0.1:5432:5432" - volumes: - - db:/var/lib/postgresql/data - networks: - - services - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 10s - retries: 15 - start_period: 3s - restart: unless-stopped - -volumes: - data: - main_logs: - messaging_logs: - files: - file_storage_logs: - db: - caddy: - -networks: - public: - driver: bridge - internal: false - services: - driver: bridge - internal: true \ No newline at end of file diff --git a/deployment/fromchat.service b/deployment/fromchat.service deleted file mode 100644 index 1a930cf..0000000 --- a/deployment/fromchat.service +++ /dev/null @@ -1,33 +0,0 @@ -[Unit] -Description=FromChat server -After=multi-user.target -Wants=network-online.target -After=network-online.target -StartLimitIntervalSec=60 -StartLimitBurst=3 - -[Service] -Type=simple -User=root -Group=root -ExecStart=/bin/bash -c "COMPOSE_PROFILES=production docker compose up --remove-orphans --force-recreate" -ExecStop=/bin/bash -c "COMPOSE_PROFILES=production docker compose down --remove-orphans" -WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment -Restart=always -RestartSec=10 -StartLimitBurst=3 - -# Security settings -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ReadWritePaths=/var/log - -# Logging -StandardOutput=journal -StandardError=journal -StandardInput=tty-force -SyslogIdentifier=fromchat - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/deployment/frontend/Dockerfile b/deployment/frontend/Dockerfile deleted file mode 100644 index fb4e243..0000000 --- a/deployment/frontend/Dockerfile +++ /dev/null @@ -1,57 +0,0 @@ -# 1. Build the frontend -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. Copy remaining frontend code and build -COPY frontend frontend -RUN npm run frontend:build - - -# 2. Build the static file server -FROM node:24 AS server - -# 2.1. Install npm dependencies -WORKDIR /server -COPY deployment/frontend/package.json . -RUN --mount=type=cache,target=/root/.npm \ - npm install - -# 2.2. Copy the code -COPY deployment/frontend/ . - -# 2.3. Build -RUN npm run build - - -# 3. Put it all together -FROM node:24-slim - -# 3.1. Install curl for health checks -RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* - -# 3.2. Non-root user -RUN useradd -u 1001 app && \ - mkdir -p /app && \ - chown -R app /app && \ - mkdir /server && \ - chown -R app /server -USER app - -# 3.1. Frontend static files -WORKDIR /app -COPY --from=frontend --chown=app /app/frontend/build/normal/dist . - -# 3.2. Static file server -WORKDIR /server -COPY --from=server --chown=app /server . - -# 4. Final command -ENV STATIC_FILE_PATH=/app -ENTRYPOINT ["npm", "run", "start:prod"] \ No newline at end of file diff --git a/deployment/frontend/package.json b/deployment/frontend/package.json deleted file mode 100644 index 30b95c3..0000000 --- a/deployment/frontend/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "frontend-server", - "version": "1.0.0", - "main": "server.js", - "scripts": { - "start": "ts-node server.ts", - "build": "tsc -b", - "start:prod": "node dist/server.js" - }, - "dependencies": { - "express": "^5.1.0", - "http-proxy-middleware": "^3.0.5" - }, - "devDependencies": { - "@types/express": "^4.17.21", - "@types/node": "^20.10.0", - "typescript": "^5.3.0", - "ts-node": "^10.9.0" - } -} diff --git a/deployment/frontend/server.ts b/deployment/frontend/server.ts deleted file mode 100644 index d358d9c..0000000 --- a/deployment/frontend/server.ts +++ /dev/null @@ -1,57 +0,0 @@ -import http from "http"; -import type { IncomingMessage } from "http"; -import type { Duplex } from "stream"; -import express from "express"; -import { createProxyMiddleware } from "http-proxy-middleware"; -import { resolve } from "path"; - -const app = express(); -/** Same default as Vite dev server: HTTPS reverse-proxy entry (API + WebSocket on /api). */ -const port = Number(process.env.PORT) || 8301; -const backendHost = process.env.BACKEND_HOST || "http://127.0.0.1:8300"; -const fileStorageHost = process.env.FILE_STORAGE_HOST || "http://localhost:8302"; -const filePath = process.env.STATIC_FILE_PATH || "."; - -// API + WebSocket (e.g. /api/chat/ws) — must attach upgrade on the HTTP server, not app.listen(). -const apiProxy = createProxyMiddleware({ - target: backendHost, - changeOrigin: true, - pathRewrite: { "^/api": "" }, - ws: true, -}); - -app.use("/api", apiProxy); - -app.use( - "/uploads/files", - createProxyMiddleware({ - target: fileStorageHost, - changeOrigin: true, - }), -); - -app.use(express.static(resolve(filePath))); - -app.use((_req, res) => { - res.sendFile(resolve(filePath, "index.html")); -}); - -const server = http.createServer(app); - -type ProxyWithUpgrade = ReturnType & { - upgrade?: (req: IncomingMessage, socket: Duplex, head: Buffer) => void; -}; - -server.on("upgrade", (req, socket, head) => { - const path = req.url?.split("?")[0] ?? ""; - const upgrade = (apiProxy as ProxyWithUpgrade).upgrade; - if (path.startsWith("/api") && upgrade) { - upgrade.call(apiProxy, req, socket, head); - } else { - socket.destroy(); - } -}); - -server.listen(port, () => { - console.log(`Server listening on http://localhost:${port} (API+WS → ${backendHost})`); -}); diff --git a/deployment/frontend/tsconfig.json b/deployment/frontend/tsconfig.json deleted file mode 100644 index c2fa00e..0000000 --- a/deployment/frontend/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "moduleResolution": "node", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "./dist", - "rootDir": "./", - "declaration": true, - "sourceMap": true - }, - "include": [ - "server.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -} diff --git a/deployment/haproxy.cfg b/deployment/haproxy.cfg deleted file mode 100644 index ced9a09..0000000 --- a/deployment/haproxy.cfg +++ /dev/null @@ -1,26 +0,0 @@ -global - maxconn 4096 - # Start as root to bind 80/443, then drop privileges. - user haproxy - group haproxy - -defaults - no log - mode tcp - timeout connect 5s - timeout client 2m - timeout server 2m - -frontend fe_http_80 - bind 0.0.0.0:80 - default_backend be_caddy_http - -backend be_caddy_http - server caddy_http 127.0.0.1:8080 send-proxy-v2 - -frontend fe_https_443 - bind 0.0.0.0:443 - default_backend be_caddy_https - -backend be_caddy_https - server caddy_https 127.0.0.1:8443 send-proxy-v2 diff --git a/deployment/healthcheck.py b/deployment/healthcheck.py deleted file mode 100644 index 213af11..0000000 --- a/deployment/healthcheck.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple health check script using built-in urllib -Replaces curl dependency in Docker health checks -""" - -import sys -import urllib.request -import os - -def main(): - port = os.getenv('PORT', '8300') - url = f'http://localhost:{port}/health' - - try: - with urllib.request.urlopen(url, timeout=10) as response: - if response.status == 200: - print("OK") - sys.exit(0) - else: - print(f"HTTP {response.status}") - sys.exit(1) - except Exception as e: - print(f"FAILED: {e}") - sys.exit(1) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/deployment/init-postgres-entrypoint.sh b/deployment/init-postgres-entrypoint.sh deleted file mode 100644 index 6dc8eb7..0000000 --- a/deployment/init-postgres-entrypoint.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# Custom entrypoint for PostgreSQL that processes the init template - -set -e - -# If this is the first run (data directory is empty), process the template -if [ ! -f /var/lib/postgresql/data/PG_VERSION ]; then - echo "Processing PostgreSQL init template..." - - # Substitute environment variables in the SQL template - envsubst < /docker-entrypoint-initdb.d/init-postgres.sql.template > /docker-entrypoint-initdb.d/init-postgres.sql - - echo "Template processing complete." -else - echo "PostgreSQL data directory already exists, skipping template processing." -fi - -# Execute the original PostgreSQL entrypoint -exec /usr/local/bin/docker-entrypoint.sh "$@" \ No newline at end of file diff --git a/deployment/init-postgres.sql.template b/deployment/init-postgres.sql.template deleted file mode 100644 index 93fc962..0000000 --- a/deployment/init-postgres.sql.template +++ /dev/null @@ -1,42 +0,0 @@ --- PostgreSQL initialization script for FromChat compliance architecture --- Creates separate databases and users for each service with minimal required permissions - --- Create users with passwords from environment variables --- Variables are substituted by envsubst before PostgreSQL runs this script -CREATE USER main_user WITH PASSWORD '${MAIN_DB_PASSWORD}'; -CREATE USER messaging_user WITH PASSWORD '${MESSAGING_DB_PASSWORD}'; -CREATE USER file_storage_user WITH PASSWORD '${FILE_STORAGE_DB_PASSWORD}'; - --- Create databases for each service -CREATE DATABASE fromchat_main OWNER main_user; -CREATE DATABASE fromchat_messaging OWNER messaging_user; -CREATE DATABASE fromchat_files OWNER file_storage_user; - --- Connect to main database and set up -\c fromchat_main -CREATE SCHEMA IF NOT EXISTS fromchat_main AUTHORIZATION main_user; -GRANT ALL PRIVILEGES ON DATABASE fromchat_main TO main_user; -GRANT ALL PRIVILEGES ON SCHEMA fromchat_main TO main_user; -GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA fromchat_main TO main_user; -ALTER DEFAULT PRIVILEGES IN SCHEMA fromchat_main GRANT ALL ON TABLES TO main_user; -ALTER DEFAULT PRIVILEGES IN SCHEMA fromchat_main GRANT ALL ON SEQUENCES TO main_user; - --- Connect to messaging database and set up -\c fromchat_messaging -CREATE SCHEMA IF NOT EXISTS fromchat_messaging AUTHORIZATION messaging_user; -GRANT ALL PRIVILEGES ON DATABASE fromchat_messaging TO messaging_user; -GRANT USAGE ON SCHEMA fromchat_messaging TO messaging_user; -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA fromchat_messaging TO messaging_user; -GRANT USAGE ON ALL SEQUENCES IN SCHEMA fromchat_messaging TO messaging_user; -ALTER DEFAULT PRIVILEGES IN SCHEMA fromchat_messaging GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO messaging_user; -ALTER DEFAULT PRIVILEGES IN SCHEMA fromchat_messaging GRANT USAGE ON SEQUENCES TO messaging_user; - --- Connect to files database and set up -\c fromchat_files -CREATE SCHEMA IF NOT EXISTS fromchat_files AUTHORIZATION file_storage_user; -GRANT ALL PRIVILEGES ON DATABASE fromchat_files TO file_storage_user; -GRANT USAGE ON SCHEMA fromchat_files TO file_storage_user; -GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA fromchat_files TO file_storage_user; -GRANT USAGE ON ALL SEQUENCES IN SCHEMA fromchat_files TO file_storage_user; -ALTER DEFAULT PRIVILEGES IN SCHEMA fromchat_files GRANT SELECT, INSERT, UPDATE ON TABLES TO file_storage_user; -ALTER DEFAULT PRIVILEGES IN SCHEMA fromchat_files GRANT USAGE ON SEQUENCES TO file_storage_user; \ No newline at end of file diff --git a/deployment/livekit.dev.yaml b/deployment/livekit.dev.yaml deleted file mode 100644 index 7a94a48..0000000 --- a/deployment/livekit.dev.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Local LiveKit SFU for FromChat development. -# Match keys in deployment/.env — see deployment/livekit.local.env.example -port: 8303 - -rtc: - tcp_port: 8304 - port_range_start: 50000 - port_range_end: 60000 - use_external_ip: false - -keys: - fromchat_dev: "local_dev_secret_must_be_at_least_32_characters_long" diff --git a/deployment/livekit.local.env.example b/deployment/livekit.local.env.example deleted file mode 100644 index 10ecc4b..0000000 --- a/deployment/livekit.local.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Append these lines to deployment/.env so the Python backend can mint LiveKit JWTs. -# Keys must match deployment/livekit.dev.yaml (keys.fromchat_dev). -# LAN_IP = this machine’s address on your LAN (phones/emulators use it to reach LiveKit). -LAN_IP=192.168.1.14 -LIVEKIT_API_KEY=fromchat_dev -LIVEKIT_API_SECRET=local_dev_secret_must_be_at_least_32_characters_long -LIVEKIT_URL=ws://192.168.1.14:8303 diff --git a/docs/COMPLIANCE_DECRYPTION.md b/docs/COMPLIANCE_DECRYPTION.md deleted file mode 100644 index 7f42c05..0000000 --- a/docs/COMPLIANCE_DECRYPTION.md +++ /dev/null @@ -1,197 +0,0 @@ -# Compliance Message Decryption Guide - -This guide explains how to decrypt encrypted messages for compliance and legal purposes using the secure offline compliance system. - -## Overview - -The application uses client-server encryption for direct messages (DMs). While regular users can only decrypt their own messages, compliance officers can decrypt any message for legal compliance purposes using a secure offline process. - -## Security Model - -- **Regular Users**: Can only decrypt messages encrypted with their own public keys -- **Compliance Officers**: Can decrypt any message using the compliance private key (stored offline) -- **No Server Access**: Compliance private keys are never stored on production servers -- **Audit Trail**: All compliance access is logged with timestamps and user IDs - -## Prerequisites - -### 1. Compliance Officer Access - -- Must be logged in as user ID 1 (system administrator) -- Requires valid JWT authentication token - -### 2. Air-Gapped Machine - -- A secure, offline computer for decryption -- Compliance private key stored securely -- Python environment with required dependencies - -### 3. Files Required - -- `compliance_keypair.txt` - Contains compliance X25519 keypair -- `scripts/compliance/decryption/main.py` — decryption tool entrypoint -- Message data extracted from the server - -## Step-by-Step Instructions - -### Step 1: Extract Message Data from Server - -**On the production server (as compliance officer):** - -1. Log in to the application as user ID 1 -2. Get your JWT token from browser developer tools: - - Open DevTools (F12) - - Go to Application → Local Storage - - Copy the `token` value -3. Extract message data using the API: - ```bash - curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \ - http://localhost:8300/api/dm/compliance/extract/MESSAGE_ID \ - > compliance_MESSAGE_ID.json - ``` - Replace `MESSAGE_ID` with the actual message ID you want to decrypt. -4. Verify the extraction was successful: - ```bash - cat compliance_MESSAGE_ID.json | jq . - ``` - Expected response: - -### Step 2: Transfer Data to Air-Gapped Machine - -**Securely transfer the JSON file to your air-gapped machine:** - -- Use encrypted USB drive -- Use secure file transfer protocol -- Never transfer over network if air-gapping is required - -### Step 3: Decrypt Message on Air-Gapped Machine - -**On the air-gapped machine:** - -1. Ensure you have the required files: - - `compliance_keypair.txt` (compliance private key) - - `scripts/compliance/decryption/main.py` (decryption tool) - - `compliance_MESSAGE_ID.json` (extracted message data) -2. Run the decryption: - ```bash - python scripts/compliance/decryption/main.py decrypt --input-file compliance_MESSAGE_ID.json - ``` -3. The script will output the decrypted message: - ``` - 🔓 Loading compliance data from: compliance_MESSAGE_ID.json - 📄 Loaded message ID: 123 - 📅 Timestamp: 2026-01-10T19:23:37.938054 - 👤 Sender: 456, Recipient: 789 - 🔐 Has compliance MEK: ✅ - 🔑 Loading compliance private key... - 🔓 Decrypting message content... - - ✅ DECRYPTION SUCCESSFUL - ================================================== - Message ID: 123 - From: User 456 - To: User 789 - Timestamp: 2026-01-10T19:23:37.938054 - Decrypted at: 2026-01-10T19:33:20.782656 - -------------------------------------------------- - MESSAGE CONTENT: - {"type":"text","data":{"content":"Your encrypted message here"}} - -------------------------------------------------- - ⚠️ This content has been accessed for compliance purposes - ``` - -## Message Format - -Decrypted messages contain the original message payload in JSON format: - -```json -{ - "type": "text", - "data": { - "content": "The actual message text", - "files": [...] // Optional file attachments - } -} -``` - -## Security Considerations - -### Key Management - -- **Compliance private key**: Never stored on production servers -- **Access control**: Only user ID 1 can extract messages -- **Audit logging**: All extractions are logged with timestamps - -### Data Handling - -- **Secure transfer**: Use encrypted channels for data transfer -- **Immediate destruction**: Delete decrypted content after review -- **No caching**: Don't store decrypted messages - -### Operational Security - -- **Air-gapped environment**: Use dedicated offline machine for decryption -- **Access controls**: Limit physical access to compliance officers -- **Regular audits**: Review access logs regularly - -## Troubleshooting - -### "Access denied" Error - -- Ensure you're logged in as user ID 1 -- Check that your JWT token is valid and not expired - -### "Message not found" Error - -- Verify the message ID exists -- Check that the message hasn't been deleted - -### Decryption Failures - -- Ensure `compliance_keypair.txt` is present and contains valid keys -- Check that the JSON file wasn't corrupted during transfer -- Verify Python environment has required cryptography dependencies - -### Network Errors - -- Ensure the server is running and accessible -- Check firewall and network connectivity -- Verify API endpoints are correctly configured - -## API Reference - -### Compliance Extraction Endpoint - -``` -GET /api/dm/compliance/extract/{message_id} -Authorization: Bearer -Response: JSON with encrypted message data -``` - -**Restrictions:** - -- Requires user ID 1 authentication -- Returns encrypted data only (no plaintext) -- Logs all access for audit purposes - -### Decryption Script - -```bash -python scripts/compliance/decryption/main.py decrypt --input-file -``` - -**Requirements:** - -- `compliance_keypair.txt` in current directory -- Valid JSON file from extraction API -- Python with cryptography library - -## Compliance Workflow Summary - -``` -1. Legal Request → 2. Compliance Officer → 3. Server Extraction → 4. Secure Transfer → 5. Offline Decryption → 6. Content Review → 7. Audit Logging - ↓ ↓ ↓ ↓ ↓ ↓ ↓ - Legal basis User ID 1 login API call with token Encrypted transfer Air-gapped machine Content analysis Access recorded -``` - -This ensures complete separation between production systems and compliance decryption, maintaining security while enabling legal access to encrypted communications \ No newline at end of file diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts deleted file mode 100644 index f4f86f3..0000000 --- a/frontend/electron/preload.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { contextBridge, ipcRenderer } from "electron"; -import type { ElectronInterface, Platform } from "../electron"; - -contextBridge.exposeInMainWorld("electronInterface", { - desktop: true, - platform: process.platform as Platform, - notifications: { - requestPermission: () => ipcRenderer.invoke('request-notification-permission'), - show: (options) => ipcRenderer.invoke('show-notification', options) - } -} satisfies ElectronInterface); \ No newline at end of file diff --git a/frontend/packages/fromchat-protocol/.gitignore b/frontend/packages/fromchat-protocol/.gitignore deleted file mode 100644 index 6e1dbeb..0000000 --- a/frontend/packages/fromchat-protocol/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules/ -dist/ -*.log -.DS_Store -package-lock.json - - diff --git a/frontend/packages/fromchat-protocol/.npmignore b/frontend/packages/fromchat-protocol/.npmignore deleted file mode 100644 index 606c52a..0000000 --- a/frontend/packages/fromchat-protocol/.npmignore +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 03f24d1..0000000 --- a/frontend/packages/fromchat-protocol/PUBLISHING.md +++ /dev/null @@ -1,171 +0,0 @@ -# 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 deleted file mode 100644 index baa9d0f..0000000 --- a/frontend/packages/fromchat-protocol/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# 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 deleted file mode 100644 index c457046..0000000 --- a/frontend/packages/fromchat-protocol/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "@fromchat/protocol", - "version": "1.0.0", - "description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.", - "main": "dist/index.js", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "require": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "prepublishOnly": "npm run build" - }, - "keywords": [ - "encryption", - "ecdh", - "e2ee", - "end-to-end-encryption", - "x25519", - "aes-gcm", - "hkdf" - ], - "author": "denis0001-dev", - "license": "GPL-3.0", - "repository": { - "type": "git", - "url": "https://github.com/Toolbox-io/FromChat.git", - "directory": "frontend/packages/fromchat-protocol" - }, - "bugs": { - "url": "https://github.com/Toolbox-io/FromChat/issues" - }, - "homepage": "https://github.com/Toolbox-io/FromChat#readme", - "dependencies": { - "tweetnacl": "^1.0.3" - }, - "devDependencies": { - "@types/node": "^25.0.2", - "typescript": "^5.0.0" - }, - "files": [ - "dist", - "README.md" - ], - "engines": { - "node": ">=24.0.0" - } -} \ No newline at end of file diff --git a/frontend/packages/fromchat-protocol/tsconfig.json b/frontend/packages/fromchat-protocol/tsconfig.json deleted file mode 100644 index 6ac621c..0000000 --- a/frontend/packages/fromchat-protocol/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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/calls.ts b/frontend/src/core/api/calls.ts deleted file mode 100644 index e1a726d..0000000 --- a/frontend/src/core/api/calls.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/webrtc.ts b/frontend/src/core/api/webrtc.ts deleted file mode 100644 index 81f7ff7..0000000 --- a/frontend/src/core/api/webrtc.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/config.ts b/frontend/src/core/config.ts deleted file mode 100644 index 6c0c166..0000000 --- a/frontend/src/core/config.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @fileoverview Application configuration constants - * @description Contains all configuration values used throughout the application - * @author Cursor - * @version 1.0.0 - */ - - -export const BASE_DOMAIN = import.meta.env.VITE_API_BASE_URL || "fromchat.ru"; -export const API_BASE_URL = `${location.host ? "" : `https://${BASE_DOMAIN}`}/api`; -export const API_WS_BASE_URL = `${location.host || BASE_DOMAIN}/api`; -export const PRODUCT_NAME = "FromChat"; -export const MINIMUM_WIDTH = 800; \ No newline at end of file diff --git a/package.json b/package.json index c514c21..cc7b920 100644 --- a/package.json +++ b/package.json @@ -3,40 +3,28 @@ "private": true, "version": "0.0.0", "type": "module", - "main": "frontend/build/electron/core/main.js", + "main": "build/electron/core/main.js", "description": "A 100% Open Source Messenger", "license": "GPL-3.0", "authors": "denis0001-dev", "scripts": { - "backend:run": "bash ./scripts/backend:run.sh", - "livekit:ensure": "bash ./scripts/livekit:ensure.sh", - "livekit:run": "bash ./scripts/livekit:run.sh", - "backend:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt", - "backend:reinstall": "rm -rf .venv && npm run backend:dependencies", - "backend:clean": "rm -rf backend/data", - "frontend:dev": "dotenv -e deployment/.env -- vite frontend", - "frontend:typecheck": "tsc --project frontend", - "frontend:build": "npm run frontend:typecheck && dotenv -e deployment/.env -- vite build frontend", + "frontend:dev": "vite", + "frontend:typecheck": "tsc --project tsconfig.json", + "frontend:build": "npm run frontend:typecheck && vite build", "frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev", "frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force --arch arm64,x64", - "frontend:preview": "vite preview frontend", + "frontend:preview": "vite preview", "frontend:dependencies": "npm install --ignore-scripts", - "frontend:clean": "rm -rf frontend/dist", - "dev": "concurrently 'npm run frontend:dev' 'npm run backend:run'", - "dev:electron": "concurrently 'npm run frontend:electron:dev' 'npm run backend:run'", + "frontend:clean": "rm -rf build", "build:electron": "npm run frontend:electron:build", "build": "npm run frontend:build && npm run build:electron", - "preview": "cd deployment && docker compose up --build --watch", - "preview:clean": "cd deployment && docker compose down -v --remove-orphans", - "clean": "npm run backend:clean && npm run frontend:clean && npm run preview:clean", - "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", + "preview": "docker compose -f docker-compose.yml up --build --watch", + "preview:clean": "docker compose -f docker-compose.yml down -v --remove-orphans", "prepare": "husky", - "generate:env": "bash ./scripts/generate:env.sh", - "deploy": "dotenv -e deployment/.env -- bash ./scripts/deploy.sh" + "install": "if [ ! -e .env ]; then cp .env.example .env; fi" }, "files": [ - "frontend/build/electron" + "build/electron" ], "devDependencies": { "@electron-forge/cli": "^7.9.0", @@ -53,7 +41,6 @@ "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", "autoprefixer": "^10.4.21", - "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", "electron": "^39.2.7", "husky": "^9.1.7", @@ -69,11 +56,7 @@ "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", @@ -89,6 +72,6 @@ "zustand": "^5.0.8" }, "config": { - "forge": "frontend/forge.config.ts" + "forge": "src/electron/forge.config.ts" } } diff --git a/frontend/plugins/optimizeCssModules.ts b/plugins/optimizeCssModules.ts similarity index 100% rename from frontend/plugins/optimizeCssModules.ts rename to plugins/optimizeCssModules.ts diff --git a/frontend/plugins/optimizeSvg.ts b/plugins/optimizeSvg.ts similarity index 100% rename from frontend/plugins/optimizeSvg.ts rename to plugins/optimizeSvg.ts diff --git a/scripts/backend:run.sh b/scripts/backend:run.sh deleted file mode 100644 index 573e161..0000000 --- a/scripts/backend:run.sh +++ /dev/null @@ -1,10 +0,0 @@ -cd backend -dotenv -e ../deployment/.env -- \ - ../.venv/bin/uvicorn main:app \ - --host 0.0.0.0 \ - --port 8300 \ - --reload \ - --reload-exclude './alembic' \ - --reload-exclude './alembic/*' \ - --reload-exclude './alembic/versions/*' \ - --access-log \ No newline at end of file diff --git a/scripts/compliance/decryption/assets/report.css b/scripts/compliance/decryption/assets/report.css deleted file mode 100644 index 7dd6743..0000000 --- a/scripts/compliance/decryption/assets/report.css +++ /dev/null @@ -1,347 +0,0 @@ -/* FromChat compliance bundle report styles (conversation-like, minimal JS) */ - -:root { - --bg: #0b0f14; - --panel: #0f1520; - --panel-2: #121a27; - --text: #e6edf3; - --muted: #9aa7b2; - --border: #223045; - --accent: #4f7cff; - --bubble-in: #131b28; - --bubble-out: #1a2540; - --shadow: rgba(0, 0, 0, 0.35); -} - -html, body { - height: 100%; -} - -body { - margin: 0; - background: var(--bg); - color: var(--text); - font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; -} - -a { - color: var(--accent); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -.wrap { - max-width: 980px; - margin: 0 auto; - padding: 22px 14px 64px; -} - -.topbar { - position: sticky; - top: 0; - z-index: 10; - backdrop-filter: blur(10px); - background: rgba(11, 15, 20, 0.75); - border-bottom: 1px solid rgba(34, 48, 69, 0.7); -} - -.topbar-inner { - max-width: 980px; - margin: 0 auto; - padding: 14px 14px; - display: flex; - flex-wrap: wrap; - gap: 12px; - align-items: center; - justify-content: space-between; -} - -.brand { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.brand-title { - font-size: 15px; - font-weight: 700; - letter-spacing: 0.2px; -} - -.brand-subtitle { - font-size: 12px; - color: var(--muted); - white-space: normal; - word-break: break-word; -} - -.tools { - display: flex; - gap: 10px; - align-items: center; -} - -.search { - width: min(420px, 55vw); - border: 1px solid var(--border); - border-radius: 12px; - background: rgba(255, 255, 255, 0.03); - padding: 9px 10px; - color: var(--text); - outline: none; -} - -.search:focus { - border-color: rgba(79, 124, 255, 0.65); - box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.18); -} - -.hint { - font-size: 12px; - color: var(--muted); -} - -.conversation { - margin-top: 16px; - border: 1px solid var(--border); - background: var(--panel); - border-radius: 16px; - overflow: hidden; - box-shadow: 0 12px 28px var(--shadow); -} - -.conv-header { - cursor: default; - padding: 14px 14px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - border-bottom: 1px solid rgba(34, 48, 69, 0.65); -} - -.conv-title { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.conv-title .line1 { - font-weight: 700; - font-size: 14px; -} - -.conv-title .line2 { - font-size: 12px; - color: var(--muted); -} - -.conv-meta { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; - color: var(--muted); - font-size: 12px; -} - -.pill { - border: 1px solid rgba(34, 48, 69, 0.9); - border-radius: 999px; - padding: 3px 8px; - background: rgba(255, 255, 255, 0.02); -} - -.messages { - padding: 12px 10px 14px; -} - -.day { - display: flex; - justify-content: center; - margin: 12px 0 10px; -} - -.day span { - font-size: 12px; - color: var(--muted); - border: 1px solid rgba(34, 48, 69, 0.8); - background: rgba(255, 255, 255, 0.02); - padding: 3px 10px; - border-radius: 999px; -} - -.message-container { - display: flex; - margin: 8px 0; - gap: 12px; - align-items: flex-start; -} - -.edit-tabs-vertical { - display: flex; - flex-direction: column; - min-width: 80px; - gap: 4px; -} - -.tab-vertical { - padding: 6px 4px; - cursor: pointer; - border: 1px solid rgba(34, 48, 69, 0.6); - border-radius: 6px; - background: rgba(255, 255, 255, 0.02); - text-align: center; - transition: background-color 0.15s ease; - display: flex; - flex-direction: column; - align-items: center; - gap: 1px; - min-height: 40px; -} - -.tab-vertical:hover { - background: rgba(79, 124, 255, 0.08); -} - -.tab-vertical.active { - background: var(--accent); - color: white; - border-color: var(--accent); -} - -.tab-label-vertical { - font-size: 10px; - font-weight: 700; - line-height: 1.1; -} - -.tab-time-vertical { - font-size: 8px; - opacity: 0.9; - line-height: 1.1; - white-space: nowrap; -} - -.bubble-area { - flex: 1; - min-width: 0; -} - -.bubble { - display: none; - max-width: min(720px, 92%); - border: 1px solid rgba(34, 48, 69, 0.9); - border-radius: 16px; - padding: 10px 10px 9px; - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); - background: var(--bubble-in); - border-top-left-radius: 6px; -} - -.bubble.active { - display: block; -} - -.bubble-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 10px; - margin-bottom: 6px; -} - -.who { - font-size: 12px; - color: var(--muted); -} - -.who strong { - color: var(--text); - font-weight: 700; -} - -.text { - white-space: pre-wrap; - word-break: break-word; - font-size: 14px; - line-height: 1.45; -} - -.msg-meta { - margin-top: 8px; - font-size: 11px; - color: var(--muted); - display: flex; - justify-content: space-between; - gap: 10px; -} - -.msg-meta-left { - white-space: nowrap; -} - -.msg-meta-right { - white-space: nowrap; - text-align: right; -} - -.attachments { - margin-top: 10px; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 10px; -} - -.att { - border: 1px solid rgba(34, 48, 69, 0.9); - border-radius: 12px; - padding: 10px; - background: rgba(255, 255, 255, 0.02); -} - -.att-name { - font-size: 13px; - font-weight: 700; - margin-bottom: 7px; -} - -.thumb { - width: 100%; - max-height: 260px; - object-fit: contain; - border-radius: 10px; - border: 1px solid rgba(34, 48, 69, 0.9); - background: rgba(0, 0, 0, 0.18); -} - -.att-actions { - margin-top: 8px; - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; -} - -.att-size { - font-size: 12px; - color: var(--muted); -} - - -.footer { - margin-top: 18px; - font-size: 12px; - color: var(--muted); - padding: 10px 2px; -} - -.hidden { - display: none !important; -} - diff --git a/scripts/compliance/decryption/assets/report.js b/scripts/compliance/decryption/assets/report.js deleted file mode 100644 index b8229f2..0000000 --- a/scripts/compliance/decryption/assets/report.js +++ /dev/null @@ -1,105 +0,0 @@ -/* Minimal JS for filtering messages in the static report. */ - -function normalizeText(s) { - return (s || "").toString().toLowerCase(); -} - -function filterReport(query) { - const q = normalizeText(query).trim(); - const conversations = document.querySelectorAll(".conversation"); - let anyVisible = false; - - conversations.forEach((conv) => { - const rows = conv.querySelectorAll("[data-search]"); - let visibleInConv = 0; - - rows.forEach((row) => { - const hay = normalizeText(row.getAttribute("data-search")); - const match = !q || hay.includes(q); - row.classList.toggle("hidden", !match); - if (match) visibleInConv += 1; - }); - - const convMatch = visibleInConv > 0; - conv.classList.toggle("hidden", !convMatch); - if (convMatch) anyVisible = true; - }); - - const hint = document.getElementById("filterHint"); - if (hint) { - hint.textContent = q - ? (anyVisible ? "Filtered" : "No matches") - : "Type to filter by text, user id, filename"; - } -} - -function setupEditHistoryTabs() { - document.querySelectorAll(".edit-tabs-vertical").forEach((tabsContainer) => { - const tabs = tabsContainer.querySelectorAll(".tab-vertical"); - - tabs.forEach((tab) => { - tab.addEventListener("click", () => { - const version = tab.getAttribute("data-version"); - const messageId = tab.getAttribute("data-message-id"); - - // Find the corresponding message container - const messageContainer = document.querySelector(`.message-container:has([data-message-id="${messageId}"])`); - if (!messageContainer) return; - - // Update tab states within this message - const allTabs = messageContainer.querySelectorAll(".tab-vertical"); - allTabs.forEach(t => t.classList.remove("active")); - tab.classList.add("active"); - - // Update bubble states within this message - const allBubbles = messageContainer.querySelectorAll(".bubble"); - allBubbles.forEach(bubble => { - bubble.classList.toggle("active", bubble.getAttribute("data-version") === version); - }); - }); - }); - }); -} - -function convertTimestampsToLocal() { - // Convert all timestamps to local timezone - document.querySelectorAll("[data-timestamp]").forEach((element) => { - const timestamp = element.getAttribute("data-timestamp"); - if (!timestamp) return; - - try { - // Parse the ISO timestamp - const date = new Date(timestamp.replace(" ", "T").replace("Z", "+00:00")); - - // Format in local timezone - const localTime = date.toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false - }); - - // Update the displayed text - element.textContent = localTime; - } catch (e) { - // If parsing fails, leave the original text - console.warn("Failed to parse timestamp:", timestamp); - } - }); -} - -document.addEventListener("DOMContentLoaded", () => { - const input = document.getElementById("searchInput"); - if (input) { - input.addEventListener("input", (e) => { - filterReport(e.target.value); - }); - } - - // Initialize edit history tabs - setupEditHistoryTabs(); - - // Convert timestamps to local timezone - convertTimestampsToLocal(); -}); - diff --git a/scripts/compliance/decryption/bundle_decrypt.py b/scripts/compliance/decryption/bundle_decrypt.py deleted file mode 100644 index 5e5f78e..0000000 --- a/scripts/compliance/decryption/bundle_decrypt.py +++ /dev/null @@ -1,523 +0,0 @@ -from __future__ import annotations - -import json -import os -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, List, Tuple - -from crypto import decrypt_file_bytes_from_meta, decrypt_message, load_compliance_private_key -from report_assets import write_assets -from utils import guess_is_image, html_escape, href_escape, parse_message_plaintext, safe_filename - - -@dataclass(frozen=True) -class Attachment: - filename: str - output_rel: str - size_bytes: int - is_image: bool - - -@dataclass(frozen=True) -class DecryptedMessage: - message_id: int - sender_id: int - sender_label: str - recipient_id: int - recipient_label: str - timestamp: str - text: str - attachments: List[Attachment] - edit_history: List['DecryptedEdit'] = None - - def __post_init__(self): - if self.edit_history is None: - object.__setattr__(self, 'edit_history', []) - - -@dataclass(frozen=True) -class DecryptedEdit: - edit_id: int - edited_at: str - edited_by_user_id: int - edited_by_username: str - previous_text: str - - -def _load_manifest(bundle_dir: Path) -> Dict[str, Any]: - manifest_path = bundle_dir / "bundle.json" - if not manifest_path.exists(): - raise RuntimeError(f"bundle.json not found in: {bundle_dir}") - return json.loads(manifest_path.read_text(encoding="utf-8")) - - -def _parse_timestamp_day(ts: str) -> str: - return (ts or "")[:10] if isinstance(ts, str) and len(ts) >= 10 else "" - - -def _format_ts(ts: str) -> str: - raw = (ts or "").strip() - if not raw: - return "" - try: - dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) - return dt.strftime("%d.%m.%Y %H:%M:%S") - except Exception: - return raw - - -def _format_time(ts: str) -> str: - raw = (ts or "").strip() - if not raw: - return "" - try: - dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) - return dt.strftime("%H:%M:%S") - except Exception: - return raw - - -def _format_day(ts: str) -> str: - raw = (ts or "").strip() - if not raw: - return "" - try: - dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) - return dt.strftime("%d.%m.%Y") - except Exception: - return _parse_timestamp_day(raw) - - -def _conversation_key(sender_id: int, recipient_id: int) -> Tuple[int, int]: - a, b = int(sender_id), int(recipient_id) - return (a, b) if a < b else (b, a) - - -def _best_username(username: str | None, display_name: str | None, user_id: int) -> str: - u = (username or "").strip() - if u: - return u - d = (display_name or "").strip() - if d: - return d - return f"user{user_id}" - - -def _format_user_label(username: str | None, display_name: str | None, user_id: int) -> str: - return f"{_best_username(username, display_name, user_id)} (#{user_id})" - - -def _format_bytes(n: int) -> str: - try: - size = float(int(n)) - except Exception: - return f"{n} B" - - units = ["B", "KB", "MB", "GB", "TB"] - unit = units[0] - for u in units: - unit = u - if size < 1024.0 or u == units[-1]: - break - size /= 1024.0 - - if unit == "B": - return f"{int(size)} B" - if size >= 100: - return f"{size:.0f} {unit}" - if size >= 10: - return f"{size:.1f} {unit}" - return f"{size:.2f} {unit}" - - -def _render_report( - out_dir: Path, - conversations: Dict[Tuple[int, int], List[DecryptedMessage]], - conversation_names: Dict[Tuple[int, int], Tuple[str, str]], - css_href: str, - js_src: str, -) -> None: - total_messages = sum(len(v) for v in conversations.values()) - now = datetime.now().strftime("%d.%m.%Y %H:%M:%S") - - parts: list[str] = [] - parts.append("") - parts.append("") - parts.append("") - parts.append("") - parts.append("") - parts.append("FromChat Compliance Bundle") - parts.append(f"") - parts.append(f"") - parts.append("") - parts.append("") - parts.append("
") - parts.append("
") - parts.append("
") - parts.append("
FromChat compliance bundle
") - parts.append(f"
Decrypted at: {html_escape(now)} • Messages: {total_messages}
") - parts.append("
") - parts.append("
") - parts.append("") - parts.append("
Type to filter by text, user id, filename
") - parts.append("
") - parts.append("
") - parts.append("
") - parts.append("
") - - for (left_id, right_id), msgs in sorted(conversations.items(), key=lambda x: x[0]): - msgs_sorted = sorted(msgs, key=lambda m: (m.timestamp, m.message_id)) - left_name, right_name = conversation_names.get((left_id, right_id), (str(left_id), str(right_id))) - conv_title = f"Conversation: {left_name} ↔ {right_name}" - conv_sub = f"{len(msgs_sorted)} message(s)" - parts.append(f"
") - parts.append("
") - parts.append("
") - parts.append(f"
{html_escape(conv_title)}
") - parts.append(f"
{html_escape(conv_sub)}
") - parts.append("
") - parts.append("
") - - parts.append("
") - current_day = "" - for m in msgs_sorted: - day = _format_day(m.timestamp) - if day and day != current_day: - current_day = day - parts.append("
") - parts.append(html_escape(day)) - parts.append("
") - - searchable = ( - f"{m.message_id} {m.sender_id} {m.sender_label} {m.recipient_id} {m.recipient_label} {m.timestamp} {m.text} " - + " ".join(a.filename for a in m.attachments) - ) - - # Create container for message with edit history - parts.append(f"
") - - # Edit history tabs (vertical on the left) - if m.edit_history: - parts.append("
") - - # Add current version as "Latest" (most recent, at top) - latest_timestamp = max(edit.edited_at for edit in m.edit_history) - latest_datetime = _format_day(latest_timestamp) + " " + _format_time(latest_timestamp) - parts.append(f"
") - parts.append("
Latest
") - parts.append(f"
{html_escape(latest_datetime)}
") - parts.append("
") - - # Add edit history tabs in reverse chronological order (most recent first) - for i, edit in enumerate(reversed(m.edit_history)): - version_num = len(m.edit_history) - i - tab_label = f"v{version_num}" - # Each version tab shows when that version was created - tab_timestamp = m.timestamp if version_num == 1 else m.edit_history[version_num-2].edited_at - tab_datetime = _format_day(tab_timestamp) + " " + _format_time(tab_timestamp) - parts.append(f"
") - parts.append(f"
{html_escape(tab_label)}
") - parts.append(f"
{html_escape(tab_datetime)}
") - parts.append("
") - - parts.append("
") # end tabs - - # Message bubble container - parts.append("
") - - # Current version bubble - parts.append(f"
") - parts.append("
") - parts.append( - f"
{html_escape(m.sender_label)} → {html_escape(m.recipient_label)}
" - ) - parts.append("
") - parts.append(f"
{html_escape(m.text)}
") - - if m.attachments: - parts.append("
") - for a in m.attachments: - rel = href_escape(a.output_rel) - parts.append("
") - parts.append(f"
{html_escape(a.filename)}
") - if a.is_image: - parts.append( - f"\"{html_escape(a.filename)}\"/" - ) - parts.append("
") - parts.append(f"Download") - parts.append(f"{html_escape(_format_bytes(a.size_bytes))}") - parts.append("
") - parts.append("
") - parts.append("
") - - parts.append("
") - parts.append(f"
#{m.message_id}
") - latest_edit_time = max(edit.edited_at for edit in m.edit_history) if m.edit_history else m.timestamp - parts.append(f"
{html_escape(_format_time(latest_edit_time))}
") - parts.append("
") - parts.append("
") - - # Edit history bubbles - for i, edit in enumerate(m.edit_history): - version_num = i + 1 - # Calculate the timestamp when this version was active - bubble_timestamp = m.timestamp if i == 0 else m.edit_history[i-1].edited_at - - parts.append(f"
") - parts.append("
") - parts.append( - f"
{html_escape(m.sender_label)} → {html_escape(m.recipient_label)}
" - ) - parts.append("
") - parts.append(f"
{html_escape(edit.previous_text)}
") - - if m.attachments: - parts.append("
") - for a in m.attachments: - rel = href_escape(a.output_rel) - parts.append("
") - parts.append(f"
{html_escape(a.filename)}
") - if a.is_image: - parts.append( - f"\"{html_escape(a.filename)}\"/" - ) - parts.append("
") - parts.append(f"Download") - parts.append(f"{html_escape(_format_bytes(a.size_bytes))}") - parts.append("
") - parts.append("
") - parts.append("
") - - parts.append("
") - parts.append(f"
#{m.message_id}
") - parts.append(f"
{html_escape(_format_time(bubble_timestamp))}
") - parts.append("
") - parts.append("
") - - parts.append("
") # end bubble-area - parts.append("
") # end message-container - - parts.append("
") - parts.append("
") - - parts.append("
⚠️ This content has been accessed for compliance purposes. Handle and destroy according to policy.
") - parts.append("
") - parts.append("") - - (out_dir / "index.html").write_text("\n".join(parts), encoding="utf-8") - - -def decrypt_bundle(bundle_dir: str, output_dir: str, *, key_file: str = "compliance_keypair.txt") -> str: - bundle_path = Path(bundle_dir).resolve() - out_path = Path(output_dir).resolve() - out_path.mkdir(parents=True, exist_ok=True) - - manifest = _load_manifest(bundle_path) - messages = manifest.get("messages") if isinstance(manifest, dict) else None - if not isinstance(messages, list) or not messages: - raise RuntimeError("bundle.json has no messages") - - compliance_private_key = load_compliance_private_key(key_file=key_file) - compliance_public_key = compliance_private_key.public_key() - - conversations: Dict[Tuple[int, int], List[DecryptedMessage]] = {} - conversation_names: Dict[Tuple[int, int], Tuple[str, str]] = {} - - for entry in messages: - if not isinstance(entry, dict): - continue - - message_id = entry.get("message_id") - msg_file = entry.get("message_data_file") - if not isinstance(message_id, int) or not isinstance(msg_file, str): - continue - - msg_abs = bundle_path / msg_file - message_data = json.loads(msg_abs.read_text(encoding="utf-8")) - if not isinstance(message_data, dict): - continue - - plaintext = decrypt_message(message_data, compliance_private_key, compliance_public_key) - parsed = parse_message_plaintext(plaintext) - text = parsed.get("text") or plaintext - - msg_out_dir = out_path / "messages" / str(message_id) - msg_files_out_dir = msg_out_dir / "files" - msg_files_out_dir.mkdir(parents=True, exist_ok=True) - - (msg_out_dir / "message.decrypted.txt").write_text(plaintext, encoding="utf-8") - (msg_out_dir / "message.decrypted.json").write_text( - json.dumps( - { - "message_id": message_id, - "sender_id": message_data.get("sender_id"), - "recipient_id": message_data.get("recipient_id"), - "timestamp": message_data.get("timestamp"), - "plaintext": plaintext, - "parsed": parsed, - }, - ensure_ascii=False, - indent=2, - ), - encoding="utf-8", - ) - - sender_id = int(message_data.get("sender_id") or 0) - recipient_id = int(message_data.get("recipient_id") or 0) - ts = str(message_data.get("timestamp") or "") - - sender_username = entry.get("sender_username") if isinstance(entry.get("sender_username"), str) else None - sender_display_name = entry.get("sender_display_name") if isinstance(entry.get("sender_display_name"), str) else None - recipient_username = entry.get("recipient_username") if isinstance(entry.get("recipient_username"), str) else None - recipient_display_name = ( - entry.get("recipient_display_name") if isinstance(entry.get("recipient_display_name"), str) else None - ) - - sender_label = _format_user_label(sender_username, sender_display_name, sender_id) - recipient_label = _format_user_label(recipient_username, recipient_display_name, recipient_id) - - # Process edit history - edit_history: list[DecryptedEdit] = [] - entry_edits = entry.get("edit_history") - if isinstance(entry_edits, list): - for edit_entry in entry_edits: - if not isinstance(edit_entry, dict): - continue - - edit_data_file = edit_entry.get("edit_data_file") - if not isinstance(edit_data_file, str): - continue - - edit_abs = bundle_path / edit_data_file - if not edit_abs.exists(): - continue - - edit_data = json.loads(edit_abs.read_text(encoding="utf-8")) - if not isinstance(edit_data, dict): - continue - - # Decrypt the previous version of the message - previous_message_data = { - "sender_id": sender_id, - "recipient_id": recipient_id, - "timestamp": edit_data.get("edited_at"), - "iv_b64": edit_data.get("previous_iv_b64"), - "ciphertext_b64": edit_data.get("previous_ciphertext_b64"), - "compliance_wrapped_mek_b64": edit_data.get("previous_compliance_wrapped_mek_b64"), - } - - try: - previous_plaintext = decrypt_message(previous_message_data, compliance_private_key, compliance_public_key) - previous_parsed = parse_message_plaintext(previous_plaintext) - previous_text = previous_parsed.get("text") or previous_plaintext - - # Save decrypted edit to output - edit_out_dir = msg_out_dir / "edits" - edit_out_dir.mkdir(parents=True, exist_ok=True) - edit_id = edit_data.get("edit_id") - - (edit_out_dir / f"edit_{edit_id}.decrypted.txt").write_text(previous_plaintext, encoding="utf-8") - (edit_out_dir / f"edit_{edit_id}.decrypted.json").write_text( - json.dumps( - { - "edit_id": edit_id, - "message_id": message_id, - "edited_at": edit_data.get("edited_at"), - "edited_by_user_id": edit_data.get("edited_by_user_id"), - "edited_by_username": edit_data.get("edited_by_username"), - "plaintext": previous_plaintext, - "parsed": previous_parsed, - }, - ensure_ascii=False, - indent=2, - ), - encoding="utf-8", - ) - - edit_history.append(DecryptedEdit( - edit_id=int(edit_id), - edited_at=str(edit_data.get("edited_at") or ""), - edited_by_user_id=int(edit_data.get("edited_by_user_id") or 0), - edited_by_username=str(edit_data.get("edited_by_username") or "unknown"), - previous_text=str(previous_text), - )) - except Exception as e: - print(f"Failed to decrypt edit {edit_entry.get('edit_id')}: {e}") - - attachments: list[Attachment] = [] - - entry_files = entry.get("files") - if not isinstance(entry_files, list): - entry_files = [] - - for fentry in entry_files: - if not isinstance(fentry, dict): - continue - meta_rel = fentry.get("meta_file") - enc_rel = fentry.get("encrypted_file") - if not isinstance(meta_rel, str) or not isinstance(enc_rel, str): - continue - - meta_abs = bundle_path / meta_rel - enc_abs = bundle_path / enc_rel - if not meta_abs.exists() or not enc_abs.exists(): - continue - - meta = json.loads(meta_abs.read_text(encoding="utf-8")) - if not isinstance(meta, dict): - continue - - encrypted_bytes = enc_abs.read_bytes() - decrypted_bytes = decrypt_file_bytes_from_meta(meta, encrypted_bytes, key_file=key_file) - - orig_name = str(meta.get("filename") or "file") - safe_name = safe_filename(orig_name) - out_file_abs = msg_files_out_dir / safe_name - if out_file_abs.exists(): - root, ext = os.path.splitext(safe_name) - out_file_abs = msg_files_out_dir / f"{root}_{meta.get('dm_file_id') or 'x'}{ext}" - - out_file_abs.write_bytes(decrypted_bytes) - - out_rel = os.path.relpath(out_file_abs, out_path) - attachments.append( - Attachment( - filename=orig_name, - output_rel=out_rel, - size_bytes=len(decrypted_bytes), - is_image=guess_is_image(orig_name), - ) - ) - - msg = DecryptedMessage( - message_id=int(message_id), - sender_id=sender_id, - sender_label=sender_label, - recipient_id=recipient_id, - recipient_label=recipient_label, - timestamp=ts, - text=str(text), - attachments=attachments, - edit_history=edit_history, - ) - - conv_key = _conversation_key(sender_id, recipient_id) - conversations.setdefault(conv_key, []).append(msg) - if conv_key not in conversation_names: - left_id, right_id = conv_key - if sender_id == left_id: - left_name = _best_username(sender_username, sender_display_name, left_id) - right_name = _best_username(recipient_username, recipient_display_name, right_id) - else: - left_name = _best_username(recipient_username, recipient_display_name, left_id) - right_name = _best_username(sender_username, sender_display_name, right_id) - conversation_names[conv_key] = (left_name, right_name) - - css_rel, js_rel = write_assets(out_path) - _render_report(out_path, conversations, conversation_names, css_rel, js_rel) - - return str(out_path / "index.html") - diff --git a/scripts/compliance/decryption/bundle_extract.py b/scripts/compliance/decryption/bundle_extract.py deleted file mode 100644 index da85e36..0000000 --- a/scripts/compliance/decryption/bundle_extract.py +++ /dev/null @@ -1,213 +0,0 @@ -from __future__ import annotations - -import json -import os -from datetime import datetime -from typing import Any, Dict, List - -from http_client import http_get_bytes, http_get_json, join_api_url -from utils import safe_filename - - -def _fetch_user_profile(api_base_url: str, token: str, user_id: int) -> Dict[str, Any]: - url = f"{api_base_url.rstrip('/')}/user/id/{user_id}" - data = http_get_json(url, token) - return data if isinstance(data, dict) else {} - - -def extract_single_message_to_bundle(api_base_url: str, token: str, message_id: int, bundle_root: str) -> Dict[str, Any]: - message_dir = os.path.join(bundle_root, "messages", str(message_id)) - files_dir = os.path.join(message_dir, "files") - os.makedirs(files_dir, exist_ok=True) - - extract_url = f"{api_base_url.rstrip('/')}/dm/compliance/extract/{message_id}" - payload = http_get_json(extract_url, token) - - raw_path = os.path.join(message_dir, "response.json") - with open(raw_path, "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - - data = payload.get("data") if isinstance(payload, dict) else None - if not isinstance(data, dict): - raise RuntimeError(f"Unexpected response format for message_id={message_id}: missing 'data' object") - - msg_path = os.path.join(message_dir, "message.json") - with open(msg_path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - sender_id = data.get("sender_id") - recipient_id = data.get("recipient_id") - if not isinstance(sender_id, int) or not isinstance(recipient_id, int): - raise RuntimeError(f"Extraction JSON missing sender_id/recipient_id for message_id={message_id}") - - sender_profile = _fetch_user_profile(api_base_url, token, sender_id) - recipient_profile = _fetch_user_profile(api_base_url, token, recipient_id) - sender_username = sender_profile.get("username") if isinstance(sender_profile.get("username"), str) else None - sender_display_name = sender_profile.get("display_name") if isinstance(sender_profile.get("display_name"), str) else None - recipient_username = recipient_profile.get("username") if isinstance(recipient_profile.get("username"), str) else None - recipient_display_name = ( - recipient_profile.get("display_name") if isinstance(recipient_profile.get("display_name"), str) else None - ) - - sender_pk_url = f"{api_base_url.rstrip('/')}/crypto/public-key/of/{sender_id}" - sender_pk_resp = http_get_json(sender_pk_url, token) - sender_public_key_b64 = sender_pk_resp.get("publicKey") - if not isinstance(sender_public_key_b64, str) or not sender_public_key_b64: - raise RuntimeError(f"Could not fetch sender public key for user_id={sender_id}") - - files = data.get("files") or [] - if not isinstance(files, list): - files = [] - - file_entries: list[Dict[str, Any]] = [] - - for fmeta in files: - if not isinstance(fmeta, dict): - continue - file_id = fmeta.get("id") - name = fmeta.get("name") or "file" - path = fmeta.get("path") - wrapped_mek_b64 = fmeta.get("wrapped_mek_b64") - nonce_b64 = fmeta.get("nonce_b64") - if not path or not isinstance(path, str): - continue - - safe_name = safe_filename(str(name)) - enc_filename = f"{message_id}_{file_id or 'x'}_{safe_name}.enc" - enc_abs = os.path.join(files_dir, enc_filename) - enc_rel = os.path.relpath(enc_abs, bundle_root) - - file_url = join_api_url(api_base_url, path) - file_bytes = http_get_bytes(file_url, token, timeout_seconds=60.0) - with open(enc_abs, "wb") as outf: - outf.write(file_bytes) - - envelope_compliance_mek = data.get("compliance_wrapped_mek_b64") - use_compliance_mek = ( - isinstance(envelope_compliance_mek, str) - and envelope_compliance_mek - and wrapped_mek_b64 == envelope_compliance_mek - ) - meta_out: Dict[str, Any] = { - "kind": "dm_file", - "message_id": data.get("message_id"), - "dm_file_id": file_id, - "filename": name, - "path": path, - "nonce_b64": nonce_b64, - "encrypted_file_local": enc_rel, - } - if use_compliance_mek: - meta_out["compliance_wrapped_mek_b64"] = wrapped_mek_b64 - else: - meta_out["wrapped_mek_b64"] = wrapped_mek_b64 - meta_out["wrap_context"] = "sender_wrap_key" - meta_out["wrap_public_key_b64"] = sender_public_key_b64 - meta_filename = f"{message_id}_{file_id or 'x'}_{safe_name}.meta.json" - meta_abs = os.path.join(files_dir, meta_filename) - meta_rel = os.path.relpath(meta_abs, bundle_root) - with open(meta_abs, "w", encoding="utf-8") as mf: - json.dump(meta_out, mf, ensure_ascii=False, indent=2) - - file_entries.append( - { - "dm_file_id": file_id, - "filename": name, - "encrypted_file": enc_rel, - "meta_file": meta_rel, - "size_bytes": len(file_bytes), - } - ) - - # Handle edit history - edit_history = data.get("edit_history") or [] - if not isinstance(edit_history, list): - edit_history = [] - - edit_history_entries: list[Dict[str, Any]] = [] - - for edit_entry in edit_history: - if not isinstance(edit_entry, dict): - continue - - edit_id = edit_entry.get("edit_id") - edit_timestamp = edit_entry.get("edited_at") - edited_by_user_id = edit_entry.get("edited_by_user_id") - edited_by_username = edit_entry.get("edited_by_username") - - if not isinstance(edit_id, int) or not isinstance(edit_timestamp, str): - continue - - # Create separate JSON file for each edit history entry - edit_data = { - "edit_id": edit_id, - "message_id": message_id, - "edited_at": edit_timestamp, - "edited_by_user_id": edited_by_user_id, - "edited_by_username": edited_by_username, - "previous_ciphertext_b64": edit_entry.get("previous_ciphertext_b64"), - "previous_iv_b64": edit_entry.get("previous_iv_b64"), - "previous_compliance_wrapped_mek_b64": edit_entry.get("previous_compliance_wrapped_mek_b64"), - } - - edit_filename = f"edit_{edit_id}.json" - edit_path = os.path.join(message_dir, "edits", edit_filename) - os.makedirs(os.path.dirname(edit_path), exist_ok=True) - edit_rel = os.path.relpath(edit_path, bundle_root) - - with open(edit_path, "w", encoding="utf-8") as f: - json.dump(edit_data, f, ensure_ascii=False, indent=2) - - edit_history_entries.append({ - "edit_id": edit_id, - "edit_data_file": edit_rel, - "edited_at": edit_timestamp, - "edited_by_user_id": edited_by_user_id, - "edited_by_username": edited_by_username, - }) - - return { - "message_id": message_id, - "message_data_file": os.path.relpath(msg_path, bundle_root), - "response_file": os.path.relpath(raw_path, bundle_root), - "sender_id": sender_id, - "sender_username": sender_username, - "sender_display_name": sender_display_name, - "recipient_id": recipient_id, - "recipient_username": recipient_username, - "recipient_display_name": recipient_display_name, - "timestamp": data.get("timestamp"), - "files": file_entries, - "edit_history": edit_history_entries, - } - - -def extract_bundle(api_base_url: str, token: str, message_ids: List[int], out_dir: str) -> str: - os.makedirs(os.path.join(out_dir, "messages"), exist_ok=True) - - seen: set[int] = set() - unique_ids: list[int] = [] - for mid in message_ids: - if mid not in seen: - seen.add(mid) - unique_ids.append(mid) - if not unique_ids: - raise RuntimeError("No message IDs provided") - - manifest: Dict[str, Any] = { - "bundle_version": 1, - "generated_at": datetime.now().isoformat(), - "api_base_url": api_base_url.rstrip("/"), - "messages": [], - } - - for mid in unique_ids: - entry = extract_single_message_to_bundle(api_base_url, token, mid, out_dir) - manifest["messages"].append(entry) - - manifest_path = os.path.join(out_dir, "bundle.json") - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f, ensure_ascii=False, indent=2) - - return manifest_path - diff --git a/scripts/compliance/decryption/cli.py b/scripts/compliance/decryption/cli.py deleted file mode 100644 index a60fbeb..0000000 --- a/scripts/compliance/decryption/cli.py +++ /dev/null @@ -1,512 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import sys -from dataclasses import dataclass -from datetime import datetime -from getpass import getpass -from typing import Any, Dict, List, Optional, Sequence, Tuple - -from bundle_decrypt import decrypt_bundle -from bundle_extract import extract_bundle -from crypto import derive_auth_secret -from http_client import http_get_json, http_post_json - - -class _Ansi: - RESET = "\033[0m" - BOLD = "\033[1m" - RED = "\033[31m" - GREEN = "\033[32m" - YELLOW = "\033[33m" - CYAN = "\033[36m" - MAGENTA = "\033[35m" - - -INDENT = 0 - - -def indent() -> None: - global INDENT - INDENT += 2 - - -def unindent() -> None: - global INDENT - INDENT = max(0, INDENT - 2) - - -def _pad() -> str: - return " " * INDENT - - -def _color(text: str, color: str) -> str: - return f"{color}{text}{_Ansi.RESET}" - - -def success(msg: str) -> None: - print(f"{_pad()}{_Ansi.GREEN}✓{_Ansi.RESET} {msg}") - - -def warning(msg: str) -> None: - print(f"{_pad()}{_Ansi.YELLOW}⚠{_Ansi.RESET} {msg}") - - -def error(msg: str) -> None: - print(f"{_pad()}{_Ansi.RED}✗{_Ansi.RESET} {msg}") - - -def step(msg: str) -> None: - print(f"{_pad()}{_Ansi.CYAN}{_Ansi.BOLD}→{_Ansi.RESET} {_Ansi.BOLD}{msg}{_Ansi.RESET}") - indent() - - -def substep(msg: str) -> None: - print(f"{_pad()}{_Ansi.GREEN}•{_Ansi.RESET} {msg}") - - -def _prompt(text: str, *, default: Optional[str] = None, secret: bool = False, icon: str = "bullet") -> str: - suffix = f" [{default}]" if default is not None and default != "" else "" - - if icon == "warning": - icon_str = f"{_Ansi.YELLOW}⚠{_Ansi.RESET}" - else: # default "bullet" - icon_str = f"{_Ansi.GREEN}•{_Ansi.RESET}" - - q = f"{_pad()}{icon_str} {text}{suffix}: " - while True: - v = (getpass(q) if secret else input(q)).strip() - if v: - return v - if default is not None: - return default - warning("Value is required.") - - -def _prompt_choice(*, default: str) -> str: - """ - Choice prompt in the style: - - \\n{indent}{dot} Your choice: (default X) - """ - q = f"\n{_pad()}{_Ansi.GREEN}•{_Ansi.RESET} Your choice: (default {default}): " - v = input(q).strip() - return v or default - - -def _choose_option(options: Sequence[str], *, default: str) -> str: - substep("Choose an option:") - indent() - try: - for opt in options: - substep(opt) - return _prompt_choice(default=default) - finally: - unindent() - - -def _prompt_bool(text: str, *, default: bool = True) -> bool: - suffix = " [Y/n]" if default else " [y/N]" - q = f"{_pad()}{_Ansi.GREEN}•{_Ansi.RESET} {text}{suffix}: " - while True: - v = input(q).strip().lower() - if not v: - return default - if v in {"y", "yes"}: - return True - if v in {"n", "no"}: - return False - warning("Please answer y/n.") - - -def _prompt_bool_required(text: str) -> bool: - """ - Ask a y/n question with no default (user must enter y or n). - """ - suffix = " [y/n]" - q = f"{_pad()}{_Ansi.GREEN}•{_Ansi.RESET} {text}{suffix}: " - while True: - v = input(q).strip().lower() - if v in {"y", "yes"}: - return True - if v in {"n", "no"}: - return False - warning("Please answer y/n.") - - -def _parse_message_ids(raw: str) -> List[int]: - tokens = [t.strip() for t in raw.replace(",", " ").split() if t.strip()] - out: list[int] = [] - for t in tokens: - if "-" in t: - a, b = t.split("-", 1) - start = int(a.strip()) - end = int(b.strip()) - if start <= end: - out.extend(list(range(start, end + 1))) - else: - out.extend(list(range(start, end - 1, -1))) - else: - out.append(int(t)) - seen: set[int] = set() - uniq: list[int] = [] - for x in out: - if x not in seen: - seen.add(x) - uniq.append(x) - return uniq - - -def _build_api_base(server: str, *, https: bool) -> str: - s = (server or "").strip() - if s.startswith("http://"): - s = s[len("http://") :] - if s.startswith("https://"): - s = s[len("https://") :] - scheme = "https" if https else "http" - return f"{scheme}://{s}/api" - - -@dataclass(frozen=True) -class _AuthResult: - api_base_url: str - token: str - did_login: bool - - -def _login(api_base_url: str, username: str, password: str) -> str: - derived = derive_auth_secret(username, password) - resp = http_post_json(f"{api_base_url.rstrip('/')}/login", {"username": username, "password": derived}) - token = resp.get("token") if isinstance(resp, dict) else None - if not isinstance(token, str) or not token: - raise RuntimeError("Login did not return a token") - return token - - -def _logout(api_base_url: str, token: str) -> None: - try: - http_get_json(f"{api_base_url.rstrip('/')}/logout", token) - except Exception: - # Must best-effort logout; don't mask original errors. - pass - - -def _resolve_bearer_token( - *, - token: Optional[str] = None, - jwt: Optional[str] = None, -) -> Optional[str]: - """CLI flag, deprecated --jwt alias, or FROMCHAT_API_TOKEN / FROMCHAT_TOKEN env.""" - if token and jwt: - raise SystemExit("Provide only one of --token or --jwt.") - explicit = (token or jwt or "").strip() - if explicit: - return explicit - for env_name in ("FROMCHAT_API_TOKEN", "FROMCHAT_TOKEN"): - env_val = os.environ.get(env_name, "").strip() - if env_val: - return env_val - return None - - -def _ensure_online_auth( - *, - server: Optional[str], - https: Optional[bool], - bearer_token: Optional[str], - username: Optional[str], - password: Optional[str], -) -> _AuthResult: - if not server: - server = _prompt("Server (host:port)", default="localhost:8301") - use_https = bool(https) if https is not None else _prompt_bool("Use HTTPS", default=True) - api_base_url = _build_api_base(server, https=use_https) - - if bearer_token and (username or password): - raise SystemExit("Provide either --token OR --username/--password, not both.") - - if bearer_token: - return _AuthResult(api_base_url=api_base_url, token=bearer_token, did_login=False) - - step("Authentication") - try: - if not username and password is None: - method = _choose_option( - ["1) Login + password", "2) API token (Bearer)"], - default="1", - ) - if method.strip() == "2": - token_in = _prompt("API token") - return _AuthResult(api_base_url=api_base_url, token=token_in.strip(), did_login=False) - - if not username: - username = _prompt("Username") - if password is None: - password = _prompt("Password", secret=True) - - token = _login(api_base_url, username, password) - return _AuthResult(api_base_url=api_base_url, token=token, did_login=True) - finally: - unindent() - - -def cmd_extract(args: argparse.Namespace) -> None: - if getattr(args, "https", False) and getattr(args, "http", False): - raise SystemExit("Choose only one: --https or --http") - - server = args.server - if not server: - server = _prompt("Server (host:port)", default="fromchat.ru") - - if args.https or args.http: - https_choice: Optional[bool] = True if args.https else False - else: - https_choice = _prompt_bool_required("Use HTTPS") - - bearer_token: Optional[str] = _resolve_bearer_token( - token=getattr(args, "token", None), - jwt=getattr(args, "jwt", None), - ) - username: Optional[str] = args.username - password: Optional[str] = args.password - used_password_login = bool(username or password is not None) - - message_ids: List[int] = [] - if getattr(args, "message_ids", None): - message_ids.extend(list(args.message_ids)) - if not message_ids: - message_ids = [] - - out_dir = args.out_dir - - last_err: Optional[BaseException] = None - for attempt in range(1, 6): - try: - auth = _ensure_online_auth( - server=server, - https=https_choice, - bearer_token=bearer_token, - username=username, - password=password, - ) - except Exception as e: - last_err = e - msg = str(e) - warning(msg) - if "HTTP 401" in msg or "HTTP 403" in msg: - if bearer_token and not used_password_login: - warning("Auth failed. Please enter a valid API token again.") - bearer_token = _prompt("API token") - else: - warning("Auth failed. Please enter username and password again.") - bearer_token = None - username = _prompt("Username") - password = _prompt("Password", secret=True) - used_password_login = True - continue - - bearer_token = None - username = None - password = None - used_password_login = False - if not _prompt_bool("Try again", default=True): - raise SystemExit(1) - continue - - if not message_ids: - raw = _prompt("Message IDs (space/comma, ranges like 1-5 supported)") - message_ids = _parse_message_ids(raw) - - if not out_dir: - out_dir = _prompt("Output directory", default="./tmp/compliance_bundle") - - step(f"Extracting {len(message_ids)} message(s)") - try: - manifest_path = extract_bundle(auth.api_base_url, auth.token, message_ids, out_dir) - success(f"Bundle created: {out_dir}") - success(f"Manifest: {manifest_path}") - return - except Exception as e: - last_err = e - msg = str(e) - if "HTTP 401" in msg or "HTTP 403" in msg: - warning(msg) - if bearer_token and not used_password_login: - warning("Auth failed. Please enter a valid API token again.") - bearer_token = _prompt("API token") - else: - warning("Auth failed. Please enter username and password again.") - bearer_token = None - username = _prompt("Username") - password = _prompt("Password", secret=True) - used_password_login = True - continue - else: - raise - finally: - unindent() - if auth.did_login: - _logout(auth.api_base_url, auth.token) - - if last_err: - raise SystemExit(str(last_err)) - raise SystemExit(1) - - -def cmd_decrypt_bundle(args: argparse.Namespace) -> None: - bundle_dir = args.bundle_dir or _prompt("Bundle directory (contains bundle.json)", default="./tmp/compliance_bundle") - output_dir = args.output_dir or _prompt("Output directory", default="./tmp/compliance_bundle_decrypted") - - # Try to load the compliance key, prompt for path if not found - key_file = "compliance_keypair.txt" - private_key_b64 = None - - try: - from crypto import load_compliance_private_key - load_compliance_private_key(key_file=key_file) - except FileNotFoundError: - warning(f"Compliance key file not found: {key_file}") - key_file = _prompt("Path to compliance_keypair.txt") - except Exception as e: - # If file exists but key can't be loaded, ask user to paste it - private_key_b64 = _prompt("Couldn't find the private key. Please enter the X25519 PRIVATE key (base64, 43 chars)", secret=False, icon="warning") - if not private_key_b64 or not private_key_b64.strip(): - raise RuntimeError("No private key provided") - - # Create a temporary key file - import tempfile - import os - temp_fd, temp_path = tempfile.mkstemp(suffix='.txt', prefix='compliance_key_') - try: - with os.fdopen(temp_fd, 'w') as f: - f.write(f"PRIVATE_KEY={private_key_b64.strip()}\n") - f.write("PUBLIC_KEY=dummy\n") # Not needed for decryption - key_file = temp_path - except Exception: - os.close(temp_fd) - raise - - step("Decrypting bundle") - try: - index_path = decrypt_bundle(bundle_dir, output_dir, key_file=key_file) - success(f"Bundle decrypted into: {output_dir}") - success(f"Report: {index_path}") - except Exception as e: - # Provide user-friendly error messages for common issues - if "InvalidTag" in str(type(e)) or "InvalidTag" in str(e): - error("Failed to decrypt bundle: Key mismatch - the bundle was encrypted with a different compliance key") - else: - error(f"Failed to decrypt bundle: {repr(e) if e else type(e).__name__}") - # Don't re-raise since we've already displayed the error - finally: - unindent() - - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Compliance Message Decryption Tool", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - extract_parser = subparsers.add_parser("extract", help="Extract messages + encrypted files from API (online)") - extract_parser.add_argument("--server", required=False, help="Server host:port (e.g. localhost:8301)") - extract_parser.add_argument("--https", action="store_true", help="Use HTTPS (default in interactive mode)") - extract_parser.add_argument("--http", action="store_true", help="Use HTTP") - extract_parser.add_argument( - "--token", - required=False, - help="API Bearer token (from login/register). Also FROMCHAT_API_TOKEN or FROMCHAT_TOKEN env.", - ) - extract_parser.add_argument("--jwt", required=False, help=argparse.SUPPRESS) - extract_parser.add_argument("--username", required=False, help="Login username (alternative to --token)") - extract_parser.add_argument("--password", required=False, help="Login password (will be prompted if omitted)") - extract_parser.add_argument("--message-ids", required=False, type=int, nargs="+", help="Message IDs to extract") - extract_parser.add_argument("--out-dir", required=False, help="Directory to write the extracted bundle") - extract_parser.set_defaults(func=cmd_extract) - - decrypt_bundle_parser = subparsers.add_parser("decrypt", help="Decrypt a bundle created by extract (offline)") - decrypt_bundle_parser.add_argument("--bundle-dir", required=False, help="Path to extracted bundle directory (contains bundle.json)") - decrypt_bundle_parser.add_argument("--output-dir", required=False, help="Directory to write decrypted output (HTML + files)") - decrypt_bundle_parser.set_defaults(func=cmd_decrypt_bundle) - - - return parser - - -def _run_full_interactive() -> None: - print(f"{_Ansi.MAGENTA}{_Ansi.BOLD}FromChat compliance tool{_Ansi.RESET}\n") - - step("Choose an action") - try: - choice = _choose_option( - [ - "1) Extract bundle from server", - "2) Decrypt bundle (offline)", - "0) Exit", - ], - default="1", - ) - finally: - unindent() - if choice == "0": - raise SystemExit(0) - - try: - if choice == "1": - step("Extract bundle from server") - try: - args = argparse.Namespace( - server=None, - https=False, - http=False, - token=None, - jwt=None, - username=None, - password=None, - message_ids=None, - out_dir=None, - ) - cmd_extract(args) - finally: - unindent() - elif choice == "2": - step("Decrypt bundle (offline)") - try: - args = argparse.Namespace(bundle_dir=None, output_dir=None) - cmd_decrypt_bundle(args) - finally: - unindent() - else: - warning("Unknown choice.") - except SystemExit: - raise - except Exception as e: - error(str(e)) - - -def main(argv: List[str] | None = None) -> None: - try: - parser = build_parser() - if argv is None and len(sys.argv) <= 1: - _run_full_interactive() - return - - args = parser.parse_args(argv) - if not getattr(args, "command", None): - _run_full_interactive() - return - - - try: - args.func(args) - except SystemExit: - raise - except Exception as e: - error(str(e)) - raise SystemExit(1) - except KeyboardInterrupt: - pass diff --git a/scripts/compliance/decryption/crypto.py b/scripts/compliance/decryption/crypto.py deleted file mode 100644 index 5678580..0000000 --- a/scripts/compliance/decryption/crypto.py +++ /dev/null @@ -1,173 +0,0 @@ -from __future__ import annotations - -import base64 -import os -from typing import Any, Dict, Iterable, Optional - -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey -from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from cryptography.hazmat.primitives.kdf.hkdf import HKDF - - -def load_compliance_private_key(key_file: str = "compliance_keypair.txt") -> X25519PrivateKey: - if not os.path.exists(key_file): - raise FileNotFoundError(f"Compliance key file not found: {key_file}") - - with open(key_file, "r", encoding="utf-8") as f: - content = f.read() - - private_key_b64: Optional[str] = None - for line in content.split("\n"): - line = line.strip() - # Look for PRIVATE_KEY= line or base64 lines that are exactly 43 chars (X25519 private key length when base64 encoded) - if line.startswith("PRIVATE_KEY="): - private_key_b64 = line.split("=", 1)[1].strip() - break - elif len(line) == 43 and line.endswith("=") and "=" in line: # Base64 X25519 private key - private_key_b64 = line - break - - if not private_key_b64: - raise ValueError(f"Could not find private key in {key_file}. Expected PRIVATE_KEY= line or 43-character base64 string.") - - private_key_bytes = base64.b64decode(private_key_b64) - return X25519PrivateKey.from_private_bytes(private_key_bytes) - - -def _hkdf_32(info: bytes) -> HKDF: - return HKDF( - algorithm=hashes.SHA256(), - length=32, - salt=b"\x00" * 16, - info=info, - ) - - -def derive_wrap_key_from_public_key_bytes(public_key_bytes: bytes, context: str) -> bytes: - return _hkdf_32(context.encode("utf-8")).derive(public_key_bytes) - - -def derive_compliance_wrap_key(compliance_public_key: X25519PublicKey) -> bytes: - return _hkdf_32(b"compliance_wrap_key").derive(compliance_public_key.public_bytes_raw()) - - -def decrypt_compliance_mek( - wrapped_mek_b64: str, - compliance_private_key: X25519PrivateKey, - compliance_public_key: X25519PublicKey, -) -> bytes: - wrap_key = derive_compliance_wrap_key(compliance_public_key) - wrapped_mek_bytes = base64.b64decode(wrapped_mek_b64) - nonce = wrapped_mek_bytes[:12] - ciphertext = wrapped_mek_bytes[12:] - aesgcm = AESGCM(wrap_key) - return aesgcm.decrypt(nonce, ciphertext, None) - - -def decrypt_wrapped_mek_with_public_key(wrapped_mek_b64: str, wrap_public_key_b64: str, wrap_context: str) -> bytes: - public_key_bytes = base64.b64decode(wrap_public_key_b64) - wrap_key = derive_wrap_key_from_public_key_bytes(public_key_bytes, wrap_context) - wrapped_mek_bytes = base64.b64decode(wrapped_mek_b64) - nonce = wrapped_mek_bytes[:12] - ciphertext = wrapped_mek_bytes[12:] - aesgcm = AESGCM(wrap_key) - return aesgcm.decrypt(nonce, ciphertext, None) - - -def first_present_key(data: Dict[str, Any], keys: Iterable[str]) -> Optional[str]: - for k in keys: - v = data.get(k) - if v is None: - continue - if isinstance(v, str) and v.strip() == "": - continue - return k - return None - - -def get_str(data: Dict[str, Any], keys: Iterable[str], label: str) -> str: - k = first_present_key(data, keys) - if not k: - raise ValueError(f"Missing {label}. Expected one of: {', '.join(keys)}") - v = data.get(k) - if not isinstance(v, str): - raise ValueError(f"Invalid {label}: expected string at '{k}', got {type(v).__name__}") - return v - - -def decrypt_message(envelope_data: Dict[str, Any], compliance_private_key: X25519PrivateKey, compliance_public_key: X25519PublicKey) -> str: - compliance_wrapped_mek = envelope_data.get("compliance_wrapped_mek_b64") - if not compliance_wrapped_mek: - raise ValueError("Message does not have compliance MEK") - - mek = decrypt_compliance_mek(compliance_wrapped_mek, compliance_private_key, compliance_public_key) - - nonce_b64 = envelope_data["iv_b64"] - ciphertext_b64 = envelope_data["ciphertext_b64"] - - nonce = base64.b64decode(nonce_b64) - ciphertext = base64.b64decode(ciphertext_b64) - - aesgcm = AESGCM(mek) - plaintext = aesgcm.decrypt(nonce, ciphertext, None) - return plaintext.decode("utf-8") - - -GCM_TAG_SIZE = 16 - - -def _unwrap_mek_from_meta(meta: Dict[str, Any], *, key_file: str) -> bytes: - nonce_b64 = get_str(meta, keys=["nonce_b64", "iv_b64", "nonce", "iv"], label="nonce/iv (base64)") - _ = base64.b64decode(nonce_b64) # validate early - - mek_key = first_present_key(meta, ["compliance_wrapped_mek_b64", "compliance_wrapped_mek"]) - if mek_key: - compliance_private_key = load_compliance_private_key(key_file=key_file) - compliance_public_key = compliance_private_key.public_key() - return decrypt_compliance_mek(str(meta[mek_key]), compliance_private_key, compliance_public_key) - - wrap_public_key_b64 = get_str( - meta, - keys=["wrap_public_key_b64", "wrap_public_key", "public_key_b64"], - label="wrap public key (base64)", - ) - wrap_context = get_str(meta, keys=["wrap_context"], label="wrap context") - wrapped_mek_b64 = get_str(meta, keys=["wrapped_mek_b64", "wrapped_mek"], label="wrapped MEK (base64)") - return decrypt_wrapped_mek_with_public_key(wrapped_mek_b64, wrap_public_key_b64, wrap_context) - - -def decrypt_file_bytes_from_meta(meta: Dict[str, Any], encrypted_bytes: bytes, *, key_file: str = "compliance_keypair.txt") -> bytes: - """ - Decrypt file ciphertext from [encrypt_message_to_file]: ``ciphertext || tag`` (tag last 16 bytes). - """ - if len(encrypted_bytes) < GCM_TAG_SIZE: - raise ValueError("Encrypted file is too short (missing GCM tag)") - - nonce_b64 = get_str(meta, keys=["nonce_b64", "iv_b64", "nonce", "iv"], label="nonce/iv (base64)") - nonce = base64.b64decode(nonce_b64) - mek = _unwrap_mek_from_meta(meta, key_file=key_file) - return AESGCM(mek).decrypt(nonce, encrypted_bytes, None) - - -def derive_auth_secret(username: str, password: str) -> str: - """ - Match frontend `deriveAuthSecret()`: - HKDF-SHA256 with: - - IKM: UTF-8 password - - salt: UTF-8 `fromchat.user:{username}` - - info: UTF-8 `auth-secret` - - length: 32 bytes - Output: base64 string. - """ - salt = f"fromchat.user:{(username or '').strip()}".encode("utf-8") - info = b"auth-secret" - hkdf = HKDF( - algorithm=hashes.SHA256(), - length=32, - salt=salt, - info=info, - ) - derived = hkdf.derive((password or "").encode("utf-8")) - return base64.b64encode(derived).decode("ascii") - diff --git a/scripts/compliance/decryption/http_client.py b/scripts/compliance/decryption/http_client.py deleted file mode 100644 index cb55c17..0000000 --- a/scripts/compliance/decryption/http_client.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any, Dict, Optional -from urllib import error, request -from urllib.parse import quote - - -def http_get_bytes(url: str, token: str, timeout_seconds: float = 30.0) -> bytes: - req = request.Request(url, method="GET") - req.add_header("Authorization", f"Bearer {token}") - try: - with request.urlopen(req, timeout=timeout_seconds) as r: - return r.read() - except error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else "" - raise RuntimeError(f"HTTP {e.code} for {url}: {body[:500]}") - - -def http_get_json(url: str, token: str, timeout_seconds: float = 30.0) -> Dict[str, Any]: - raw = http_get_bytes(url, token, timeout_seconds=timeout_seconds) - try: - return json.loads(raw.decode("utf-8")) - except Exception as e: - raise RuntimeError(f"Failed to parse JSON from {url}: {e}") - - -def http_post_json( - url: str, - body: Dict[str, Any], - *, - token: Optional[str] = None, - timeout_seconds: float = 30.0, -) -> Dict[str, Any]: - payload = json.dumps(body, ensure_ascii=False).encode("utf-8") - req = request.Request(url, method="POST", data=payload) - req.add_header("Content-Type", "application/json") - if token: - req.add_header("Authorization", f"Bearer {token}") - try: - with request.urlopen(req, timeout=timeout_seconds) as r: - raw = r.read() - except error.HTTPError as e: - body_txt = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else "" - raise RuntimeError(f"HTTP {e.code} for {url}: {body_txt[:500]}") - - try: - return json.loads(raw.decode("utf-8")) - except Exception as e: - raise RuntimeError(f"Failed to parse JSON from {url}: {e}") - - -def join_api_url(api_base_url: str, path: str) -> str: - """ - Join an API base URL (usually ends with '/api') with a path that may start with: - - '/api/...' - - '/uploads/...' - - 'uploads/...' - """ - base = api_base_url.rstrip("/") - p = (path or "").strip() - if p.startswith("http://") or p.startswith("https://"): - return p - - p_quoted = quote(p, safe="/:?&=%") - - if p.startswith("/api/"): - origin = base[:-4] if base.endswith("/api") else base - return origin.rstrip("/") + p_quoted - - if not p.startswith("/"): - p_quoted = "/" + p_quoted - return base + p_quoted - diff --git a/scripts/compliance/decryption/main.py b/scripts/compliance/decryption/main.py deleted file mode 100644 index 8da56ba..0000000 --- a/scripts/compliance/decryption/main.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -""" -FromChat compliance decryption tool entrypoint. - -Run: - python scripts/compliance/decryption/main.py ... -""" - -from __future__ import annotations - -import os -import sys - - -def main() -> None: - root_dir = os.path.dirname(os.path.abspath(__file__)) - if root_dir not in sys.path: - sys.path.insert(0, root_dir) - - from cli import main as cli_main - - cli_main() - - -if __name__ == "__main__": - main() - diff --git a/scripts/compliance/decryption/report_assets.py b/scripts/compliance/decryption/report_assets.py deleted file mode 100644 index db24a11..0000000 --- a/scripts/compliance/decryption/report_assets.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -def assets_source_dir() -> Path: - """ - Directory that stores static templates (css/js) for report generation. - - Layout: - scripts/compliance/decryption/ - main.py - assets/ - report.css - report.js - *.py - """ - root_dir = Path(__file__).resolve().parent - return root_dir / "assets" - - -def read_asset_text(name: str) -> str: - path = assets_source_dir() / name - return path.read_text(encoding="utf-8") - - -def write_assets(output_dir: Path) -> tuple[str, str]: - assets_dir = output_dir / "assets" - assets_dir.mkdir(parents=True, exist_ok=True) - - css_src = read_asset_text("report.css") - js_src = read_asset_text("report.js") - - css_rel = "assets/report.css" - js_rel = "assets/report.js" - - (assets_dir / "report.css").write_text(css_src, encoding="utf-8") - (assets_dir / "report.js").write_text(js_src, encoding="utf-8") - - return css_rel, js_rel - diff --git a/scripts/compliance/decryption/utils.py b/scripts/compliance/decryption/utils.py deleted file mode 100644 index 641e544..0000000 --- a/scripts/compliance/decryption/utils.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import json -import os -from typing import Any, Dict -from urllib.parse import quote - - -def safe_filename(name: str, max_len: int = 140) -> str: - base = "".join(c for c in (name or "") if c.isalnum() or c in " ._-()[]{}").strip() - base = base.replace(" ", " ") - base = base.replace("/", "_").replace("\\", "_") - if not base: - base = "file" - if len(base) > max_len: - base = base[:max_len].rstrip() - return base - - -def html_escape(text: str) -> str: - return ( - (text or "") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace('"', """) - .replace("'", "'") - ) - - -def href_escape(rel_path: str) -> str: - """ - Percent-encode a relative path for use in HTML href/src. - Keep slashes so nested paths work. - """ - return quote(rel_path, safe="/") - - -def guess_is_image(filename: str) -> bool: - ext = (os.path.splitext(filename or "")[1] or "").lower() - return ext in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"} - - -def parse_message_plaintext(plaintext: str) -> Dict[str, Any]: - """ - Best-effort parse of decrypted message JSON. - - Returns: - - kind: "json" | "text" - - text: best-effort human-readable text - - raw: original plaintext - - json: parsed object (if kind=="json") - """ - raw = plaintext or "" - try: - obj = json.loads(raw) - content = "" - if isinstance(obj, dict): - data = obj.get("data") - if isinstance(data, dict): - content_val = data.get("content") - if isinstance(content_val, str): - content = content_val - return {"kind": "json", "text": content or raw, "raw": raw, "json": obj} - except Exception: - return {"kind": "text", "text": raw, "raw": raw} - diff --git a/scripts/compliance/keypair.py b/scripts/compliance/keypair.py deleted file mode 100644 index 25b5221..0000000 --- a/scripts/compliance/keypair.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate compliance system X25519 keypair for offline air-gapped storage. - -This script generates an X25519 keypair for the compliance system. -The private key should be stored offline on an air-gapped machine. -Only the public key is provided to the messaging service via COMPLIANCE_PUBLIC_KEY env var. - -Usage: - python3 scripts/compliance/keypair.py - -Output: - - Prints the keypair to console - - Optionally saves to a file -""" - -import base64 -import sys -import os -from pathlib import Path -import argparse - -try: - from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey - from cryptography.hazmat.primitives import serialization -except ImportError: - print("Error: cryptography library required") - print("Install with: pip install cryptography") - sys.exit(1) - - -def generate_compliance_keypair(): - """ - Generate X25519 keypair for compliance system. - - Returns: - Tuple of (private_key_b64, public_key_b64) - """ - # Generate X25519 keypair - private_key = X25519PrivateKey.generate() - public_key = private_key.public_key() - - # Export keys - private_bytes = private_key.private_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PrivateFormat.Raw, - encryption_algorithm=serialization.NoEncryption() - ) - public_bytes = public_key.public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw - ) - - # Convert to base64 - private_b64 = base64.b64encode(private_bytes).decode('utf-8') - public_b64 = base64.b64encode(public_bytes).decode('utf-8') - - return private_b64, public_b64 - - -def main(): - """Generate and display compliance keypair.""" - parser = argparse.ArgumentParser( - description="Generate compliance system X25519 keypair" - ) - parser.add_argument( - "--save", - action="store_true", - help="Save keypair to compliance_keypair.txt file" - ) - parser.add_argument( - "--public-only", - action="store_true", - help="Output only the public key (for scripts)" - ) - parser.add_argument( - "--emit-key-lines", - action="store_true", - help="Print private key line then public key line to stdout only (no file; for generate:env.sh)", - ) - - args = parser.parse_args() - - private_b64, public_b64 = generate_compliance_keypair() - - if args.emit_key_lines: - print(private_b64) - print(public_b64) - return - - if args.public_only: - # Output only public key for script integration - print(public_b64) - else: - # Full interactive display - output = f""" -╔════════════════════════════════════════════════════════════════╗ -║ COMPLIANCE SYSTEM X25519 KEYPAIR ║ -║ (Generated for testing/development only) ║ -╚════════════════════════════════════════════════════════════════╝ - -PRIVATE KEY (STORE OFFLINE ON AIR-GAPPED MACHINE): -{private_b64} - -PUBLIC KEY (SET AS COMPLIANCE_PUBLIC_KEY ENV VAR): -{public_b64} - -CONFIGURATION: - For local development: - export COMPLIANCE_PUBLIC_KEY="{public_b64}" - - For Docker/docker-compose: - Add to deployment/.env: - COMPLIANCE_PUBLIC_KEY={public_b64} - - For production: - Generate on air-gapped machine, export public key only - Store private key offline in secure location - -⚠️ SECURITY WARNING: - - Keep the PRIVATE KEY offline on an air-gapped machine - - Only the PUBLIC KEY should be deployed to servers - - Never commit private key to version control - - For production, use cryptographically secure key generation -""" - - print(output) - - # Handle file saving - if args.save: - script_dir = Path(__file__).parent - project_root = script_dir.parent.parent - output_file = project_root / "compliance_keypair.txt" - - full_output = f"""COMPLIANCE SYSTEM X25519 KEYPAIR -Generated: {__import__('datetime').datetime.now().isoformat()} -================================================================================ - -PRIVATE KEY (STORE OFFLINE ON AIR-GAPPED MACHINE): -{private_b64} - -PUBLIC KEY (SET AS COMPLIANCE_PUBLIC_KEY ENV VAR): -{public_b64} - -================================================================================ -⚠️ SECURITY WARNING: - - Keep the PRIVATE KEY offline on an air-gapped machine - - Only the PUBLIC KEY should be deployed to servers - - Never commit private key to version control -""" - - with open(output_file, 'w') as f: - f.write(full_output) - - print(f"✓ Keypair saved to: {output_file}", file=sys.stderr) - - -if __name__ == "__main__": - main() diff --git a/scripts/compliance/offline-python3.12.tar.gz b/scripts/compliance/offline-python3.12.tar.gz deleted file mode 100644 index 4080799..0000000 Binary files a/scripts/compliance/offline-python3.12.tar.gz and /dev/null differ diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100755 index f55bcf8..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")" -exec ../.venv/bin/python3 deploy/main.py "$@" diff --git a/scripts/deploy/__init__.py b/scripts/deploy/__init__.py deleted file mode 100644 index 7783d98..0000000 --- a/scripts/deploy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""FromChat deployment orchestration (Docker build, pussh, rsync, remote systemd).""" diff --git a/scripts/deploy/compose_build.py b/scripts/deploy/compose_build.py deleted file mode 100644 index eca6b84..0000000 --- a/scripts/deploy/compose_build.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Parse docker-compose JSON and run image builds.""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -from deploy.paths import ProjectPaths -import deploy.ui as ui -from deploy.util import ( - compute_inputs_hash, - dedupe_preserve, - local_image_layer_fp, - read_file_if_exists, - sanitize_ref, -) - - -def remote_project_name(server: str, deploy_path: str) -> str: - r = subprocess.run( - ["ssh", server, f"dirname {deploy_path}/deployment/docker-compose.yml"], - capture_output=True, - text=True, - ) - compose_dir = r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else f"{deploy_path}/deployment" - r2 = subprocess.run(["ssh", server, f"basename {compose_dir}"], capture_output=True, text=True) - if r2.returncode == 0 and r2.stdout.strip(): - return r2.stdout.strip() - return "deployment" - - -@dataclass -class PushableService: - service: str - image_tag: str - dockerfile: Path - build_context: Path - build_target: str - input_hash: str - - -class ComposeBuildPhase: - def __init__( - self, - paths: ProjectPaths, - *, - project_name: str, - platform: str, - use_docker_build: bool, - ) -> None: - self._paths = paths - self._project_name = project_name - self._platform = platform - self._use_docker_build = use_docker_build - - def load_compose_json(self, deployment_dir: Path) -> dict: - env = os.environ.copy() - env["COMPOSE_PROFILES"] = "production" - p = subprocess.run( - ["docker", "compose", "-f", "docker-compose.yml", "config", "--format", "json"], - cwd=deployment_dir, - capture_output=True, - text=True, - env=env, - ) - if p.returncode != 0: - ui.error("docker compose config --format json failed (needs Docker Compose v2.10+)") - sys.exit(1) - return json.loads(p.stdout) - - def list_services(self, deployment_dir: Path) -> list[str]: - env = os.environ.copy() - env["COMPOSE_PROFILES"] = "production" - p = subprocess.run( - ["docker", "compose", "-f", "docker-compose.yml", "config", "--services"], - cwd=deployment_dir, - capture_output=True, - text=True, - env=env, - ) - if p.returncode != 0: - return [] - return [s.strip() for s in p.stdout.splitlines() if s.strip()] - - def collect_pushable(self, compose: dict, services: list[str]) -> list[PushableService]: - deployment_dir = self._paths.deployment_dir - project_root = self._paths.project_root - out: list[PushableService] = [] - svc_map = compose.get("services") or {} - for service in services: - spec = svc_map.get(service) - if not isinstance(spec, dict): - continue - build = spec.get("build") - if not isinstance(build, dict): - continue - image_tag = f"{self._project_name}-{service}:latest" - dockerfile_rel = (build.get("dockerfile") or "").strip() - context_rel = (build.get("context") or "").strip() - build_target = (build.get("target") or "").strip() - if not context_rel: - context_rel = ".." - if context_rel == "..": - build_context = project_root - elif context_rel.startswith("/"): - build_context = Path(context_rel) - else: - build_context = deployment_dir / context_rel - if dockerfile_rel: - if dockerfile_rel.startswith("/"): - dockerfile = Path(dockerfile_rel) - elif context_rel == ".." or build_context == project_root: - dockerfile = project_root / dockerfile_rel - else: - dockerfile = build_context / dockerfile_rel - else: - cand_a = deployment_dir / f"Dockerfile.{service}" - cand_b = deployment_dir / service / "Dockerfile" - if cand_a.is_file(): - dockerfile = cand_a - elif cand_b.is_file(): - dockerfile = cand_b - else: - ui.error(f"Could not determine Dockerfile for {service}") - sys.exit(1) - if not self._paths.input_hash_script.is_file(): - ui.error(f"Missing {self._paths.input_hash_script} (needed for dependency hashing)") - sys.exit(1) - h = compute_inputs_hash( - build_context, - dockerfile, - hash_script=self._paths.input_hash_script, - ) - if not h: - ui.error(f"Failed to compute input hash for {service}") - sys.exit(1) - out.append( - PushableService( - service=service, - image_tag=image_tag, - dockerfile=dockerfile, - build_context=build_context, - build_target=build_target, - input_hash=h, - ) - ) - return out - - def plan_builds(self, pushable: list[PushableService]) -> tuple[list[PushableService], list[str]]: - """Return (to_build, built_images_after) — built_images empty until build runs.""" - cache_root = self._paths.local_image_cache_dir - cache_root.mkdir(parents=True, exist_ok=True) - to_build: list[PushableService] = [] - for ps in pushable: - key = sanitize_ref(ps.image_tag) - cache_file = cache_root / key / "input.sha256" - prev = read_file_if_exists(cache_file).strip() - fp = local_image_layer_fp(ps.image_tag) - if prev and prev == ps.input_hash and fp: - continue - to_build.append(ps) - return to_build, [] - - def run_builds(self, to_build: list[PushableService]) -> list[str]: - if not to_build: - ui.success("Build skipped (no Docker inputs changed)") - return [] - ui.step(f"Building {len(to_build)} service(s)") - deployment_dir = self._paths.deployment_dir - env = os.environ.copy() - env["COMPOSE_PROJECT_NAME"] = self._project_name - env["COMPOSE_PROFILES"] = "production" - if self._use_docker_build: - cmd = [ - "docker", - "compose", - "-f", - "docker-compose.yml", - "--profile", - "production", - "build", - *[p.service for p in to_build], - ] - if subprocess.run(cmd, cwd=deployment_dir, env=env).returncode != 0: - ui.error("docker compose build failed") - sys.exit(1) - else: - for ps in to_build: - ui.substep(f"Building {ps.service} -> {ps.image_tag}...") - args = [ - "docker", - "buildx", - "build", - "--platform", - self._platform, - "--file", - str(ps.dockerfile), - "--tag", - ps.image_tag, - "--output=type=docker", - "--provenance=false", - "--sbom=false", - ] - if ps.build_target: - args.extend(["--target", ps.build_target]) - args.append(str(ps.build_context)) - if subprocess.run(args).returncode != 0: - ui.error(f"Build failed for {ps.service}") - sys.exit(1) - built: list[str] = [] - for ps in to_build: - key = sanitize_ref(ps.image_tag) - d = self._paths.local_image_cache_dir / key - d.mkdir(parents=True, exist_ok=True) - (d / "input.sha256").write_text(ps.input_hash, encoding="utf-8") - built.append(ps.image_tag) - ui.success(f"Build complete! {len(built)} image(s) built") - return built - - -def classify_push_and_external( - compose: dict, - project_name: str, - local_tags: set[str], - service_order: list[str], -) -> tuple[list[str], list[str]]: - services = compose.get("services") or {} - push_images: list[str] = [] - external: list[str] = [] - for name in service_order: - spec = services.get(name) - if not isinstance(spec, dict): - continue - image_from = (spec.get("image") or "").strip() - build = spec.get("build") - has_build = isinstance(build, dict) - if image_from: - if not has_build: - external.append(image_from) - else: - push_images.append(image_from) - else: - tag = f"{project_name}-{name}:latest" - if tag in local_tags: - push_images.append(tag) - return dedupe_preserve(push_images), dedupe_preserve(external) - - -def verify_built_subset_push(built: list[str], push_images: list[str], ui: object) -> None: - matching = sum(1 for bi in built if bi in push_images) - missing = [bi for bi in built if bi not in push_images] - not_built = [di for di in push_images if di not in built] - if len(built) != matching: - ui.error(f"Mismatch between built images ({len(built)}) and detected built images ({matching}).") - if missing: - print(f" Built but not detected: {' '.join(missing)}") - if not_built: - print(f" Detected but not built (external images): {' '.join(not_built)}") - print("Aborting to avoid pushing incorrect images.") - sys.exit(1) - - -def images_to_push_intersection(push_images: list[str], built: list[str]) -> list[str]: - out: list[str] = [] - for pi in push_images: - if pi in built: - out.append(pi) - return dedupe_preserve(out) diff --git a/scripts/deploy/config.py b/scripts/deploy/config.py deleted file mode 100644 index 68e406a..0000000 --- a/scripts/deploy/config.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Load deployment/.env and CLI into settings.""" - -from __future__ import annotations - -import os -import platform -import sys -from dataclasses import dataclass -from pathlib import Path - -from dotenv import load_dotenv - -from deploy.paths import ProjectPaths - - -@dataclass -class DeploySettings: - server: str - repo_name: str - deploy_path: str - platform: str - host_arch: str - platform_arch: str - use_docker_build: bool - paths: ProjectPaths - - -def _machine_arch() -> str: - m = platform.machine().lower() - if m in ("arm64", "aarch64"): - return "arm64" - if m in ("x86_64", "amd64", "i386", "i686"): - return "amd64" - return m - - -def load_settings(paths: ProjectPaths, argv: list[str]) -> DeploySettings: - if paths.env_file.is_file(): - load_dotenv(paths.env_file, override=False) - - server = (argv[1] if len(argv) > 1 else None) or os.environ.get("DEPLOYMENT_SERVER", "") - server = server.strip() - if not server: - sys.stderr.write( - "Server not specified. Usage: deploy.sh [user@host] [deployment_path] [platform]\n" - f" Or set DEPLOYMENT_SERVER in {paths.env_file} or as an environment variable\n\n" - "Example:\n" - " deploy.sh user@example.com /home/user/fromchat linux/arm64\n" - f" Or add to {paths.env_file}: DEPLOYMENT_SERVER=user@example.com\n" - ) - raise SystemExit(1) - - repo_name = "FromChat" - deploy_path = f"~/actions-runner/_work/{repo_name}/{repo_name}" - docker_platform = "linux/arm64" - - host_arch = _machine_arch() - platform_arch = docker_platform.split("/", 1)[-1] - use_docker_build = bool(host_arch and host_arch == platform_arch) - - return DeploySettings( - server=server, - repo_name=repo_name, - deploy_path=deploy_path, - platform=docker_platform, - host_arch=host_arch, - platform_arch=platform_arch, - use_docker_build=use_docker_build, - paths=paths, - ) diff --git a/scripts/deploy/docker_local.py b/scripts/deploy/docker_local.py deleted file mode 100644 index 657a6e1..0000000 --- a/scripts/deploy/docker_local.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Local Docker daemon, Docker Desktop, and buildx setup.""" - -from __future__ import annotations - -import subprocess -import sys -import time - -import deploy.ui as ui - - -BUILDER_NAME = "fromchat-builder" - - -def ensure_daemon() -> None: - if _daemon_ok(): - return - ui.warning("Docker daemon is not running") - if not _start_desktop(): - ui.error("Failed to start Docker Desktop. Please start it manually and try again.") - sys.exit(1) - - -def _daemon_ok() -> bool: - return subprocess.run(["docker", "info"], capture_output=True).returncode == 0 - - -def _start_desktop() -> bool: - ui.substep("Starting Docker Desktop...") - if subprocess.run(["docker", "desktop", "start"], capture_output=True).returncode != 0: - return False - ui.substep("Waiting for Docker to start...", end="") - sys.stdout.flush() - max_wait = 60 - waited = 0 - while waited < max_wait: - if _daemon_ok(): - print() - return True - time.sleep(2) - waited += 2 - print(".", end="", flush=True) - print() - return False - - -def ensure_buildx(use_compose_build: bool) -> None: - if use_compose_build: - return - if subprocess.run(["docker", "buildx", "version"], capture_output=True).returncode != 0: - ui.error("Docker buildx not available. Install Docker Desktop.") - sys.exit(1) - _setup_builder() - - -def _setup_builder() -> None: - ui.step("Setting up buildx builder") - name = BUILDER_NAME - exists = subprocess.run(["docker", "buildx", "inspect", name], capture_output=True).returncode == 0 - if exists: - if subprocess.run(["docker", "buildx", "use", name], capture_output=True).returncode != 0: - ui.substep("Recreating builder...") - subprocess.run(["docker", "buildx", "rm", name], capture_output=True) - exists = False - elif subprocess.run(["docker", "buildx", "inspect", name], capture_output=True).returncode != 0: - ui.substep("Recreating builder (inspection failed)...") - subprocess.run(["docker", "buildx", "rm", name], capture_output=True) - exists = False - if not exists: - ui.substep("Creating builder with persistent cache...") - subprocess.run( - [ - "docker", - "buildx", - "create", - "--name", - name, - "--driver", - "docker-container", - "--driver-opt", - "image=moby/buildkit:latest", - "--use", - "--bootstrap", - ], - capture_output=True, - ) - subprocess.run(["docker", "buildx", "use", name], capture_output=True) diff --git a/scripts/deploy/main.py b/scripts/deploy/main.py deleted file mode 100644 index 883d13c..0000000 --- a/scripts/deploy/main.py +++ /dev/null @@ -1,90 +0,0 @@ -"""CLI entry: build Docker images, pussh, rsync, restart remote systemd.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -_SCRIPTS = Path(__file__).resolve().parent.parent -if str(_SCRIPTS) not in sys.path: - sys.path.insert(0, str(_SCRIPTS)) - -from deploy.compose_build import ( # noqa: E402 - ComposeBuildPhase, - classify_push_and_external, - images_to_push_intersection, - remote_project_name, - verify_built_subset_push, -) -import deploy.ui as ui # noqa: E402 -from deploy.config import load_settings # noqa: E402 -import deploy.docker_local as docker_local # noqa: E402 -from deploy.paths import ProjectPaths # noqa: E402 -from deploy.ssh_auth import SshAuth # noqa: E402 -from deploy.transfer import DeployTransfer # noqa: E402 -from deploy.util import local_docker_image_tags # noqa: E402 - - -def main() -> None: - paths = ProjectPaths.from_deploy_package() - settings = load_settings(paths, sys.argv) - ui.banner() - creds = SshAuth(settings.server).authenticate() - - project_name = remote_project_name(settings.server, settings.deploy_path) - - ui.build_banner() - docker_local.ensure_daemon() - docker_local.ensure_buildx(settings.use_docker_build) - - ui.step("Detecting services") - build_phase = ComposeBuildPhase( - paths, - project_name=project_name, - platform=settings.platform, - use_docker_build=settings.use_docker_build, - ) - deployment_dir = paths.deployment_dir - services = build_phase.list_services(deployment_dir) - if not services: - ui.error("No services found in docker-compose.yml") - raise SystemExit(1) - - compose_json = build_phase.load_compose_json(deployment_dir) - pushable = build_phase.collect_pushable(compose_json, services) - to_build, _ = build_phase.plan_builds(pushable) - built_images = build_phase.run_builds(to_build) - - ui.deploy_banner(settings.server) - - transfer = DeployTransfer(paths) - transfer.ensure_pussh() - - push_images, external_images = classify_push_and_external( - compose_json, - project_name, - local_docker_image_tags(), - services, - ) - - verify_built_subset_push(built_images, push_images, ui) - - if not push_images and not external_images: - ui.error(f"No images found in docker-compose.yml or built locally for project {project_name}") - raise SystemExit(1) - - to_push = images_to_push_intersection(push_images, built_images) - transfer.pussh_images(creds, to_push) - transfer.pull_external_on_server(creds, external_images) - - transfer.rsync_deployment(creds, settings.deploy_path) - transfer.copy_env_prod(creds, settings.deploy_path) - deploy_resolved = transfer.sync_firebase_cert(creds, settings.deploy_path) - transfer.run_remote_systemd(creds, deploy_resolved) - - print() - ui.success("Deployment complete!") - - -if __name__ == "__main__": - main() diff --git a/scripts/deploy/paths.py b/scripts/deploy/paths.py deleted file mode 100644 index 86abf22..0000000 --- a/scripts/deploy/paths.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Resolved filesystem paths for the Web repo.""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - - -@dataclass(frozen=True) -class ProjectPaths: - """Root and well-known directories (Web repo root = project root).""" - - project_root: Path - scripts_dir: Path - deployment_dir: Path - env_file: Path - local_cache_root: Path - local_image_cache_dir: Path - input_hash_script: Path - - @classmethod - def from_deploy_package(cls) -> ProjectPaths: - deploy_dir = Path(__file__).resolve().parent - scripts_dir = deploy_dir.parent - project_root = scripts_dir.parent - deployment_dir = project_root / "deployment" - return cls( - project_root=project_root, - scripts_dir=scripts_dir, - deployment_dir=deployment_dir, - env_file=deployment_dir / ".env", - local_cache_root=project_root / ".deploy-cache", - local_image_cache_dir=project_root / ".deploy-cache" / "images", - input_hash_script=scripts_dir / "docker_inputs_hash.py", - ) diff --git a/scripts/deploy/ssh_auth.py b/scripts/deploy/ssh_auth.py deleted file mode 100644 index 6c190c3..0000000 --- a/scripts/deploy/ssh_auth.py +++ /dev/null @@ -1,109 +0,0 @@ -"""SSH key agent and optional sudo password for remote.""" - -from __future__ import annotations - -import getpass -import os -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -import deploy.ui as ui - - -@dataclass -class SshCredentials: - server: str - sudo_password: str - - -class SshAuth: - def __init__(self, server: str) -> None: - self._server = server - - def authenticate(self) -> SshCredentials: - ui.step("Authentication") - self._ensure_agent() - key_file = Path.home() / ".ssh" / "id_rsa" - self._ensure_key_file(key_file) - self._ensure_key_in_agent(key_file) - self._verify_key_auth(key_file) - sudo_password = self._prompt_sudo() - return SshCredentials(server=self._server, sudo_password=sudo_password) - - def _ensure_agent(self) -> None: - if os.environ.get("SSH_AUTH_SOCK"): - return - subprocess.run(["ssh-agent", "-s"], capture_output=True, check=False) - - def _ensure_key_file(self, key_file: Path) -> None: - if not key_file.is_file(): - ui.error(f"SSH key not found at {key_file}") - sys.stderr.write( - " Please generate an SSH key pair first:\n" - " ssh-keygen -t rsa -b 4096 -C 'your_email@example.com'\n" - ) - raise SystemExit(1) - - def _ensure_key_in_agent(self, key_file: Path) -> None: - loaded = False - r = subprocess.run(["ssh-add", "-l"], capture_output=True, text=True) - if r.returncode == 0: - fp_r = subprocess.run( - ["ssh-keygen", "-lf", str(key_file)], - capture_output=True, - text=True, - ) - if fp_r.returncode == 0: - parts = fp_r.stdout.strip().split() - fingerprint = parts[1] if len(parts) > 1 else "" - if fingerprint and fingerprint in r.stdout: - loaded = True - if not loaded: - ui.substep("Adding SSH key to agent...") - if subprocess.run(["ssh-add", str(key_file)], capture_output=True).returncode != 0: - ui.error("Failed to add SSH key to agent. Check your key passphrase.") - raise SystemExit(1) - - def _verify_key_auth(self, key_file: Path) -> None: - pub = key_file.with_suffix(key_file.suffix + ".pub") - ok = subprocess.run( - [ - "ssh", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=10", - "-o", - "StrictHostKeyChecking=no", - self._server, - "echo 'SSH key works'", - ], - capture_output=True, - ).returncode - if ok == 0: - return - ui.error(f"SSH key authentication failed for {self._server}") - sys.stderr.write( - f' Copy your public key to the server, then re-run deploy:\n ssh-copy-id -i "{pub}" "{self._server}"\n\n' - " Or manually append this key to ~/.ssh/authorized_keys on the server:\n" - ) - if pub.is_file(): - sys.stderr.write(f" {pub.read_text(encoding='utf-8', errors='replace').strip()}\n") - raise SystemExit(1) - - def _prompt_sudo(self) -> str: - while True: - pw = getpass.getpass(" • Sudo password: ") - if not pw: - ui.warning("No password provided - assuming passwordless sudo") - return "" - chk = subprocess.run( - ["ssh", self._server, "sudo", "-S", "-v"], - input=(pw + "\n").encode(), - capture_output=True, - ) - if chk.returncode == 0: - return pw - ui.error("Invalid password, please try again") diff --git a/scripts/deploy/transfer.py b/scripts/deploy/transfer.py deleted file mode 100644 index e8206ab..0000000 --- a/scripts/deploy/transfer.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Image pussh, rsync deployment, Firebase cert, remote systemd.""" - -from __future__ import annotations - -import shlex -import subprocess -import sys -import tempfile -from pathlib import Path - -from deploy.paths import ProjectPaths -from deploy.ssh_auth import SshCredentials -import deploy.ui as ui - -UNREGISTRY_IMAGE = "ghcr.io/psviderski/unregistry" - -REMOTE_SYSTEMD_SCRIPT = r"""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" "$REMOTE_DEPLOY_PATH/backend" -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 - -COMPOSE_PROFILES=production docker compose down --remove-orphans > /dev/null 2>&1 || true - -sudo_cmd cp -f "$REMOTE_DEPLOY_PATH/deployment/fromchat.service" /etc/systemd/system/fromchat.service -sudo_cmd systemctl daemon-reload -sudo_cmd systemctl restart fromchat - -sleep 3 -if ! systemctl is-active --quiet fromchat; then - echo "❌ Service failed to start" - sudo_cmd journalctl --no-pager -xeu fromchat -n 30 - exit 1 -fi -""" - - -class DeployTransfer: - def __init__(self, paths: ProjectPaths) -> None: - self._paths = paths - - def ensure_pussh(self) -> None: - if subprocess.run(["docker", "pussh", "--help"], capture_output=True).returncode != 0: - ui.error("docker pussh plugin not installed") - print(" Install: npm run install:pussh") - - def ensure_unregistry(self, creds: SshCredentials) -> None: - check = ( - "sudo docker images --format '{{.Repository}}:{{.Tag}}' | " - f"grep -q '^{UNREGISTRY_IMAGE}$'" - ) - if subprocess.run(["ssh", creds.server, check], capture_output=True).returncode == 0: - return - ui.substep("Pulling unregistry image (one-time setup)...") - if creds.sudo_password: - inner = f"echo {shlex.quote(creds.sudo_password)} | sudo -S -p '' docker pull {UNREGISTRY_IMAGE}" - else: - inner = f"sudo docker pull {UNREGISTRY_IMAGE}" - subprocess.run(["ssh", creds.server, inner]) - - def pussh_images(self, creds: SshCredentials, images: list[str]) -> None: - ui.step("Transferring images") - if not images: - ui.success("Skipping image push (nothing was rebuilt this run)") - return - self.ensure_unregistry(creds) - for image in images: - ui.substep(f"Pushing {image}...") - if subprocess.run(["docker", "pussh", image, creds.server]).returncode != 0: - ui.error(f"Failed to push {image}") - raise SystemExit(1) - print() - - def pull_external_on_server(self, creds: SshCredentials, images: list[str]) -> None: - if not images: - return - ui.step("Pulling external images on server") - for image in images: - ui.substep(f"Pulling {image}...") - if creds.sudo_password: - inner = f"echo {shlex.quote(creds.sudo_password)} | sudo -S -p '' docker pull {shlex.quote(image)}" - else: - inner = f"sudo docker pull {shlex.quote(image)}" - if subprocess.run(["ssh", creds.server, inner]).returncode != 0: - ui.error(f"Failed to pull {image} on server") - raise SystemExit(1) - print() - - def prepare_remote_dirs(self, creds: SshCredentials, deploy_path: str) -> None: - dp = deploy_path - d_dep = shlex.quote(f"{dp}/deployment") - d_back = shlex.quote(f"{dp}/backend") - if creds.sudo_password: - pw = shlex.quote(creds.sudo_password) - script = f"""set -e -echo {pw} | sudo -S -p '' mkdir -p {d_dep} {d_back} 2>/dev/null || true -echo {pw} | sudo -S -p '' chown -R $(whoami):$(whoami) {d_dep} {d_back} 2>/dev/null || true -""" - subprocess.run(["ssh", creds.server, "bash"], input=script.encode(), capture_output=True) - else: - subprocess.run( - [ - "ssh", - creds.server, - f"sudo mkdir -p {d_dep} {d_back} && sudo chown -R $(whoami):$(whoami) {d_dep} {d_back}", - ], - capture_output=True, - ) - - def rsync_deployment(self, creds: SshCredentials, deploy_path: str) -> None: - ui.step("Transferring deployment files") - self.prepare_remote_dirs(creds, deploy_path) - project_root = self._paths.project_root - deployment_dir = self._paths.deployment_dir - ui.substep("Copying deployment directory...") - gl = subprocess.run( - ["git", "ls-files", "--others", "--ignored", "--exclude-standard", "deployment/"], - cwd=project_root, - capture_output=True, - text=True, - ) - lines = [ln.replace("deployment/", "", 1) for ln in gl.stdout.splitlines() if ln.strip()] - with tempfile.NamedTemporaryFile("w", suffix="-rsync-exclude", delete=False, encoding="utf-8") as tf: - exclude_path = Path(tf.name) - tf.write("\n".join(lines)) - try: - rsync = subprocess.run( - [ - "rsync", - "-avz", - "--delete", - f"--exclude-from={exclude_path}", - f"{deployment_dir}/", - f"{creds.server}:{deploy_path}/deployment/", - ], - cwd=project_root, - capture_output=True, - text=True, - ) - if rsync.returncode != 0: - ui.error("Rsync failed. Error output:") - for line in (rsync.stderr or rsync.stdout or "").splitlines(): - print(f" {line}") - ui.error("Failed to copy deployment directory") - raise SystemExit(1) - finally: - exclude_path.unlink(missing_ok=True) - - def copy_env_prod(self, creds: SshCredentials, deploy_path: str) -> None: - prod = self._paths.deployment_dir / ".env.prod" - if prod.is_file(): - ui.substep("Copying .env.prod to .env...") - if subprocess.run(["scp", str(prod), f"{creds.server}:{deploy_path}/deployment/.env"], capture_output=True).returncode != 0: - ui.warning("Failed to copy .env.prod to .env") - else: - ui.warning(".env.prod not found in deployment directory") - - def resolve_deploy_path_on_server(self, server: str, deploy_path: str) -> str: - r = subprocess.run( - ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", server, f"eval echo {deploy_path}"], - capture_output=True, - text=True, - ) - out = r.stdout.strip() - return out if out else deploy_path - - def firebase_cert_path(self) -> Path: - return self._paths.project_root / "backend" / "firebase-cert.json" - - def cleanup_remote_firebase_dir(self, creds: SshCredentials, deploy_path_resolved: str) -> None: - d = deploy_path_resolved - if creds.sudo_password: - pw = shlex.quote(creds.sudo_password) - script = f"""set -e -D={shlex.quote(d)} -C="$D/backend/firebase-cert.json" -mkdir -p "$D/backend" 2>/dev/null || true -if [ -d "$C" ]; then - echo {pw} | sudo -S -p '' rm -rf "$C" -fi -echo {pw} | sudo -S -p '' chown -R "$(whoami):$(whoami)" "$D/backend" 2>/dev/null || true -""" - subprocess.run(["ssh", creds.server, "bash"], input=script.encode(), capture_output=True) - else: - q = shlex.quote(d) - subprocess.run( - [ - "ssh", - creds.server, - f"D={q}; C=\"$D/backend/firebase-cert.json\"; mkdir -p \"$D/backend\"; " - f'if [ -d "$C" ]; then sudo rm -rf "$C" 2>/dev/null || rm -rf "$C"; fi; ' - f'sudo chown -R $(whoami):$(whoami) "$D/backend" 2>/dev/null || true', - ], - capture_output=True, - ) - - def _wait_firebase_loop(self, cert: Path) -> None: - while True: - if cert.is_file(): - return - if cert.is_dir(): - print( - f" ⚠ {cert} is a directory. Delete it and save the Firebase service account JSON as a file at that exact path." - ) - elif cert.exists(): - print(f" ⚠ {cert} exists but is not a regular file.") - else: - print(f" ⚠ Missing {cert} (Firebase service account JSON for FCM).") - print(" Fix this, then press Enter to check again (Ctrl+C to abort deploy).") - input() - - def sync_firebase_cert(self, creds: SshCredentials, deploy_path: str) -> str: - ui.substep( - "Firebase service account (runtime bind-mount: backend/firebase-cert.json)..." - ) - resolved = self.resolve_deploy_path_on_server(creds.server, deploy_path) - self.cleanup_remote_firebase_dir(creds, resolved) - cert = self.firebase_cert_path() - self._wait_firebase_loop(cert) - remote = f"{resolved}/backend/firebase-cert.json" - self.scp_firebase(creds, cert, remote) - return resolved - - def scp_firebase(self, creds: SshCredentials, cert: Path, remote_path: str) -> None: - ui.substep("Copying backend/firebase-cert.json...") - r = subprocess.run( - ["scp", str(cert), f"{creds.server}:{remote_path}"], - capture_output=True, - text=True, - ) - if r.returncode != 0: - ui.error("Failed to copy firebase-cert.json to server") - print(f" Target: {creds.server}:{remote_path}", file=sys.stderr) - err = (r.stderr or r.stdout or "").strip() - if err: - for line in err.splitlines(): - print(f" {line}", file=sys.stderr) - else: - print(" (scp produced no output.)", file=sys.stderr) - raise SystemExit(1) - subprocess.run( - ["ssh", creds.server, f"chmod 600 {shlex.quote(remote_path)}"], - capture_output=True, - ) - t = subprocess.run( - ["ssh", creds.server, f"test -f {shlex.quote(remote_path)}"], - capture_output=True, - ) - if t.returncode != 0: - ui.error(f"Server path is not a regular file after copy: {remote_path}") - raise SystemExit(1) - - def run_remote_systemd(self, creds: SshCredentials, deploy_path_resolved: str) -> None: - ui.step("Deploying on server") - pw = creds.sudo_password - dp = deploy_path_resolved - remote_cmd = f"SUDO_PASSWORD={shlex.quote(pw)} DEPLOY_PATH={shlex.quote(dp)} bash -s" - r = subprocess.run( - ["ssh", creds.server, remote_cmd], - input=REMOTE_SYSTEMD_SCRIPT.encode(), - text=False, - ) - if r.returncode != 0: - raise SystemExit(r.returncode) diff --git a/scripts/deploy/ui.py b/scripts/deploy/ui.py deleted file mode 100644 index 15fdc2f..0000000 --- a/scripts/deploy/ui.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Terminal output (Rich), matching previous deploy.sh style.""" - -from __future__ import annotations - -from rich.console import Console -from rich.text import Text - -_console = Console(highlight=False) - -def banner() -> None: - _console.print() - _console.print(Text("🚀 Deployment", style="bold magenta")) - _console.print() - - -def build_banner() -> None: - _console.print() - _console.print(Text("🔨 Building Docker images", style="bold magenta")) - _console.print() - - -def deploy_banner(server: str) -> None: - _console.print() - _console.print(Text(f"🚀 Deploying to {server}", style="bold magenta")) - _console.print() - - -def info(msg: str) -> None: - _console.print(Text("ℹ ", style="blue"), msg, sep="") - - -def success(msg: str) -> None: - _console.print(Text("✓ ", style="green"), msg, sep="") - - -def warning(msg: str) -> None: - _console.print(Text("⚠ ", style="yellow"), msg, sep="") - - -def error(msg: str) -> None: - _console.print(Text("✗ ", style="red"), msg, sep="") - - -def step(msg: str) -> None: - _console.print(Text("→ ", style="bold cyan"), Text(msg, style="bold"), sep="") - - -def substep(msg: str, *, end: str = "\n") -> None: - _console.print(Text(" • ", style="green"), msg, sep="", end=end) diff --git a/scripts/deploy/util.py b/scripts/deploy/util.py deleted file mode 100644 index e46d86d..0000000 --- a/scripts/deploy/util.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Small helpers: hashing, dedupe, cache keys.""" - -from __future__ import annotations - -import hashlib -import subprocess -import sys -from pathlib import Path - - -def sanitize_ref(ref: str) -> str: - s = ref.replace("/", "_").replace(":", "__").replace("@", "__at__") - return s - - -def dedupe_preserve(items: list[str]) -> list[str]: - seen: set[str] = set() - out: list[str] = [] - for x in items: - if x not in seen: - seen.add(x) - out.append(x) - return out - - -def read_file_if_exists(path: Path) -> str: - if path.is_file(): - return path.read_text(encoding="utf-8", errors="replace") - return "" - - -def local_image_layer_fp(image: str) -> str: - def inspect_layers(ref: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["docker", "image", "inspect", "-f", "{{json .RootFS.Layers}}", ref], - capture_output=True, - text=True, - ) - - p = inspect_layers(image) - if p.returncode != 0: - # Docker Desktop occasionally ends up in a state where repo:tag exists in `docker images` - # but `docker image inspect repo:tag` fails. Inspecting by content-addressed ID works. - id_p = subprocess.run( - ["docker", "images", "--no-trunc", "--format", "{{.ID}}", image], - capture_output=True, - text=True, - ) - image_id = (id_p.stdout or "").strip() - if not image_id: - return "" - p = inspect_layers(image_id) - if p.returncode != 0: - return "" - - return hashlib.sha256(p.stdout.encode()).hexdigest() - - -def compute_inputs_hash( - context: Path, - dockerfile: Path, - *, - hash_script: Path, - python_exe: str | None = None, -) -> str: - exe = python_exe or sys.executable - p = subprocess.run( - [exe, str(hash_script), "--context", str(context), "--dockerfile", str(dockerfile)], - capture_output=True, - text=True, - ) - if p.returncode != 0: - return "" - return p.stdout.strip() - - -def local_docker_image_tags() -> set[str]: - p = subprocess.run( - ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], - capture_output=True, - text=True, - ) - if p.returncode != 0: - return set() - return {line.strip() for line in p.stdout.splitlines() if line.strip()} diff --git a/scripts/docker_inputs_hash.py b/scripts/docker_inputs_hash.py deleted file mode 100644 index c1add15..0000000 --- a/scripts/docker_inputs_hash.py +++ /dev/null @@ -1,369 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import fnmatch -import hashlib -import json -import os -import shlex -import sys -from dataclasses import dataclass -from pathlib import Path - - -def _sha256_bytes(data: bytes) -> str: - h = hashlib.sha256() - h.update(data) - return h.hexdigest() - - -def _sha256_file(path: Path) -> str: - h = hashlib.sha256() - with path.open("rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - h.update(chunk) - return h.hexdigest() - - -def _read_text(path: Path) -> str: - return path.read_text(encoding="utf-8", errors="replace") - - -@dataclass(frozen=True) -class DockerIgnoreRule: - pattern: str - negated: bool - anchored: bool - directory_only: bool - - -def _read_dockerignore_rules(context: Path) -> list[DockerIgnoreRule]: - p = context / ".dockerignore" - if not p.exists() or not p.is_file(): - return [] - - rules: list[DockerIgnoreRule] = [] - for raw in _read_text(p).splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - negated = line.startswith("!") - if negated: - line = line[1:].lstrip() - anchored = line.startswith("/") - if anchored: - line = line[1:] - directory_only = line.endswith("/") - if directory_only: - line = line[:-1] - if not line: - continue - rules.append( - DockerIgnoreRule( - pattern=line, - negated=negated, - anchored=anchored, - directory_only=directory_only, - ) - ) - return rules - - -def _dockerignore_matches(rule: DockerIgnoreRule, rel_posix: str, is_dir: bool) -> bool: - if rule.directory_only and not is_dir: - return False - - rel = rel_posix.lstrip("./") - if rule.anchored: - # Anchored to context root. - candidates = [rel] - else: - # Unanchored patterns match anywhere: try both full rel and basename. - base = rel.rsplit("/", 1)[-1] - candidates = [rel, base] - - # Dockerignore supports ** globs; fnmatch handles this well enough for our use. - for c in candidates: - if fnmatch.fnmatch(c, rule.pattern): - return True - # Also allow matching directory prefixes for patterns like "dist" against "foo/dist/bar". - if not rule.anchored and "/" in rel: - if fnmatch.fnmatch(rel, f"*/{rule.pattern}") or fnmatch.fnmatch(rel, f"**/{rule.pattern}"): - return True - return False - - -def _is_ignored_by_dockerignore(rules: list[DockerIgnoreRule], rel_posix: str, is_dir: bool) -> bool: - ignored = False - for r in rules: - if _dockerignore_matches(r, rel_posix=rel_posix, is_dir=is_dir): - ignored = not r.negated - return ignored - - -def _dockerfile_logical_lines(dockerfile_text: str) -> list[str]: - """ - Join backslash-continued lines and drop full-line comments. - """ - out: list[str] = [] - buf: list[str] = [] - for raw in dockerfile_text.splitlines(): - line = raw.rstrip() - if not buf: - stripped = line.lstrip() - if stripped.startswith("#") or stripped == "": - continue - buf.append(line) - if line.endswith("\\"): - buf[-1] = buf[-1][:-1].rstrip() - continue - joined = " ".join(x.strip() for x in buf if x.strip()) - buf = [] - if joined: - out.append(joined) - if buf: - joined = " ".join(x.strip() for x in buf if x.strip()) - if joined: - out.append(joined) - return out - - -@dataclass(frozen=True) -class CopyAdd: - sources: tuple[str, ...] - from_stage: bool - - -def _parse_copy_add_args_shellform(args: list[str]) -> CopyAdd | None: - # flags: --from=, --chown=, --chmod=, --link, --parents, --exclude=... etc. - from_stage = False - rest: list[str] = [] - for a in args: - if a.startswith("--from=") or a == "--from": - from_stage = True - continue - if a.startswith("--"): - continue - rest.append(a) - if len(rest) < 2: - return None - # last is dest - srcs = tuple(rest[:-1]) - return CopyAdd(sources=srcs, from_stage=from_stage) - - -def _parse_copy_add_args_jsonform(json_text: str) -> CopyAdd | None: - try: - arr = json.loads(json_text) - except Exception: - return None - if not isinstance(arr, list) or len(arr) < 2: - return None - # last is dest - srcs = tuple(x for x in arr[:-1] if isinstance(x, str)) - if not srcs: - return None - return CopyAdd(sources=srcs, from_stage=False) - - -def _parse_copy_add(line: str) -> CopyAdd | None: - upper = line.lstrip().upper() - if not (upper.startswith("COPY ") or upper.startswith("ADD ")): - return None - - # Keep original casing for paths. - keyword, rest = line.split(None, 1) - rest = rest.strip() - - # JSON form starts with '[' - if rest.startswith("["): - parsed = _parse_copy_add_args_jsonform(rest) - if parsed: - return parsed - return None - - # shell form - try: - parts = shlex.split(rest, posix=True) - except Exception: - return None - return _parse_copy_add_args_shellform(parts) - - -def _looks_like_remote(src: str) -> bool: - s = src.lower() - return s.startswith("http://") or s.startswith("https://") - - -def _is_glob(p: str) -> bool: - return any(ch in p for ch in ["*", "?", "["]) - - -def _iter_files_under(path: Path) -> list[Path]: - if not path.exists(): - return [] - if path.is_file(): - return [path] - files: list[Path] = [] - for root, _, filenames in os.walk(path): - for name in filenames: - files.append(Path(root) / name) - return files - - -def _collect_sources(context: Path, dockerfile_path: Path) -> list[Path]: - text = _read_text(dockerfile_path) - logical = _dockerfile_logical_lines(text) - dockerignore_rules = _read_dockerignore_rules(context) - - paths: list[Path] = [] - for ln in logical: - parsed = _parse_copy_add(ln) - if not parsed: - continue - if parsed.from_stage: - continue - for src in parsed.sources: - if _looks_like_remote(src): - continue - if src.startswith("/"): - # Absolute COPY sources aren't valid for local context; ignore to avoid surprises. - continue - # Docker allows ".", "./foo", etc. - src_norm = src.lstrip("./") - if src_norm == "": - src_norm = "." - - if _is_glob(src_norm): - # Expand within context - for root, _, filenames in os.walk(context): - root_p = Path(root) - rel_root = root_p.relative_to(context).as_posix() - for fn in filenames: - rel = f"{rel_root}/{fn}" if rel_root != "." else fn - if _is_ignored_by_dockerignore(dockerignore_rules, rel_posix=rel, is_dir=False): - continue - if fnmatch.fnmatch(rel, src_norm) or fnmatch.fnmatch(fn, src_norm): - paths.append(context / rel) - continue - - p = (context / src_norm).resolve() - # Ensure stays within context - try: - p.relative_to(context.resolve()) - except Exception: - continue - for fp in _iter_files_under(p): - try: - rel = fp.resolve().relative_to(context.resolve()).as_posix() - except Exception: - continue - if _is_ignored_by_dockerignore(dockerignore_rules, rel_posix=rel, is_dir=fp.is_dir()): - continue - paths.append(fp) - - # Always include the Dockerfile itself (and preserve stable ordering via sort later) - paths.append(dockerfile_path.resolve()) - return paths - - -def compute_inputs_hash(context: Path, dockerfile_path: Path) -> str: - files = _collect_sources(context=context, dockerfile_path=dockerfile_path) - # Deduplicate by resolved path - uniq: dict[str, Path] = {} - for p in files: - uniq[str(p)] = p - - # Stable sort by path relative to context when possible, else absolute - ctx_resolved = context.resolve() - def sort_key(p: Path) -> str: - try: - return p.resolve().relative_to(ctx_resolved).as_posix() - except Exception: - return p.resolve().as_posix() - - sorted_files = sorted(uniq.values(), key=sort_key) - - h = hashlib.sha256() - for p in sorted_files: - rp: str - try: - rp = p.resolve().relative_to(ctx_resolved).as_posix() - except Exception: - rp = p.resolve().as_posix() - h.update(rp.encode("utf-8", errors="strict")) - h.update(b"\0") - if p.is_file(): - h.update(_sha256_file(p).encode("ascii")) - else: - h.update(b"NONFILE") - h.update(b"\n") - - return h.hexdigest() - - -def compute_inputs_debug(context: Path, dockerfile_path: Path) -> tuple[str, list[tuple[str, str]]]: - """ - Returns (inputs_hash, [(rel_path, sha256_of_file_contents), ...]) with dockerignore applied. - """ - files = _collect_sources(context=context, dockerfile_path=dockerfile_path) - uniq: dict[str, Path] = {} - for p in files: - uniq[str(p.resolve())] = p.resolve() - - ctx_resolved = context.resolve() - - def rel_or_abs(p: Path) -> str: - try: - return p.resolve().relative_to(ctx_resolved).as_posix() - except Exception: - return p.resolve().as_posix() - - sorted_files = sorted(uniq.values(), key=lambda p: rel_or_abs(p)) - - items: list[tuple[str, str]] = [] - for p in sorted_files: - rp = rel_or_abs(p) - if p.is_file(): - items.append((rp, _sha256_file(p))) - else: - items.append((rp, "NONFILE")) - - return compute_inputs_hash(context=context, dockerfile_path=dockerfile_path), items - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--context", required=True, help="Build context directory") - ap.add_argument("--dockerfile", required=True, help="Dockerfile path") - ap.add_argument( - "--debug-list", - action="store_true", - help="Print the included file list (relpath|sha256) to stderr", - ) - args = ap.parse_args() - - context = Path(args.context).resolve() - dockerfile = Path(args.dockerfile).resolve() - - if not context.exists() or not context.is_dir(): - print(f"Context not found or not a directory: {context}", file=sys.stderr) - return 2 - if not dockerfile.exists() or not dockerfile.is_file(): - print(f"Dockerfile not found: {dockerfile}", file=sys.stderr) - return 2 - - if args.debug_list: - h, items = compute_inputs_debug(context=context, dockerfile_path=dockerfile) - for rp, sh in items: - print(f"{rp}|{sh}", file=sys.stderr) - print(h) - else: - print(compute_inputs_hash(context=context, dockerfile_path=dockerfile)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) - diff --git a/scripts/generate:env.sh b/scripts/generate:env.sh deleted file mode 100755 index 9d88f0f..0000000 --- a/scripts/generate:env.sh +++ /dev/null @@ -1,309 +0,0 @@ -#!/bin/bash -# ============================================================================= -# _ENV_TEMPLATE: one KEY=value per line. Use for stdin prompts. Use -# only where a dedicated step is needed. Any $(command) here runs when -# this script executes (after cd "$ROOT"). Piped stdin order: four lines -# (TURN_USERNAME, TURN_SECRET, DEPLOYMENT_SERVER, RELEASES_TOKEN), -# then commit (y/n), then deployment output directory (blank = deployment), then -# writes /.env and /compliance_keypair.txt (default dir: deployment); then -# if each target exists, backup prompt [Y/n] (Enter = yes; only n/no skips). -# Nothing is written until commit=y (including compliance_keypair.txt). Backups after commit=y, default yes. -# Backups use .<6-char sha256>.bak (same contents reuse one file). If that -# name exists with different content, full 64-char hash is used before .bak. -# Template is read from fd 3 so stdin stays free. -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$ROOT" - -VENV_PY="${ROOT}/.venv/bin/python3" -ENV_PATH="" -COMPLIANCE_TXT="" -_COMPLIANCE_PRIVATE_B64="" -_COMPLIANCE_PUBLIC_B64="" - -_ENV_TEMPLATE="$(cat < -TURN_USERNAME= -TURN_SECRET= -DEPLOYMENT_SERVER= -POSTGRES_PASSWORD=$(openssl rand -hex 8 -MESSAGE_RETENTION_DAYS=180 -EOF -)" - -# --- colors (key = light blue, = gray, value = purple) --- -NC=$'\033[0m' -GRAY=$'\033[38;5;245m' -BLUE=$'\033[38;5;81m' -PURPLE=$'\033[38;5;141m' -RED=$'\033[38;5;203m' -LIME=$'\033[38;5;154m' -ORANGE=$'\033[38;5;208m' -YELLOW=$'\033[38;5;226m' -CHECK=$'\033[38;5;154m' -WARN_ICON=$'\xe2\x9a\xa0' - -_abort_on_int() { - printf '\n\n%b%s %s%b\n' "$YELLOW" "$WARN_ICON" "Aborted." "$NC" >&2 - exit 130 -} -trap _abort_on_int INT - -# Buffered .env lines (written only after commit) -declare -a ENV_LINES=() - -# label + label_color | KEY=value (KEY light blue, = gray, value purple) -print_kv_row() { - local label="$1" label_c="$2" key="$3" val="$4" - printf '%b%s%b %b|%b %b%s%b%b=%b%s%b\n' \ - "$label_c" "$label" "$NC" "$GRAY" "$NC" \ - "$BLUE" "$key" "$NC" "$GRAY" "$PURPLE" "$val" "$NC" -} - -print_validation_error() { - printf '%b%s %s%b\n' "$RED" "$WARN_ICON" "$1" "$NC" >&2 -} - -# Append one logical line to ENV_LINES (shell-safe quoting for .env file) -buffer_env_line() { - local key="$1" val="$2" - local line - if [[ "$val" == *'"'* ]] || [[ "$val" == *' '* ]] || [[ "$val" == *'#'* ]] || [[ "$val" == *'='* ]] || [[ -z "$val" ]]; then - local esc="${val//\\/\\\\}" - esc="${esc//\"/\\\"}" - line=$(printf '%s="%s"' "$key" "$esc") - else - line=$(printf '%s=%s' "$key" "$val") - fi - ENV_LINES+=("$line") -} - -validate_ipv4() { - local ip="$1" _IFS=$IFS IFS=. - local -a oct=($ip) - IFS="$_IFS" - [[ ${#oct[@]} -eq 4 ]] || return 1 - local x - for x in "${oct[@]}"; do - [[ "$x" =~ ^[0-9]+$ ]] || return 1 - (( 10#$x >= 0 && 10#$x <= 255 )) || return 1 - done - return 0 -} - -validate_deployment_server() { - local v="$1" - [[ -n "$v" ]] || return 1 - validate_ipv4 "$v" -} - -validate_set_value() { - local key="$1" val="$2" - [[ -n "$val" ]] || return 1 - case "$key" in - DEPLOYMENT_SERVER) validate_deployment_server "$val" ;; - *) ;; - esac -} - -validation_hint() { - case "$1" in - DEPLOYMENT_SERVER) - printf '%s' "Expected a valid IPv4 address (e.g. 192.168.1.1), four octets 0–255." - ;; - *) - printf '%s' "Value must not be empty." - ;; - esac -} - -prompt_set() { - local key="$1" - local val="" - while true; do - printf '%b%s%b %b|%b %b%s%b%b=%b' \ - "$ORANGE" "user input" "$NC" "$GRAY" "$NC" "$BLUE" "$key" "$NC" "$GRAY" "$NC" >&2 - IFS= read -r val || true - if validate_set_value "$key" "$val"; then - buffer_env_line "$key" "$val" - break - fi - print_validation_error "$(validation_hint "$key")" - if [[ ! -t 0 ]]; then - printf '%s\n' "generate:env: invalid value for ${key} (piped stdin); aborting." >&2 - exit 1 - fi - done -} - -# Backup path: {src}.{short-hash}.bak, or {src}.{full-hash}.bak on short-hash collision -_do_backup_copy() { - local src="$1" - local full short dest - full="$(openssl dgst -sha256 -r <"$src" | awk '{print $1}')" - short="${full:0:6}" - dest="${src}.${short}.bak" - if [[ -f "$dest" ]]; then - if cmp -s "$src" "$dest"; then - print_kv_row "backup" "$GRAY" "backup_unchanged" "$dest" - return 0 - fi - dest="${src}.${full}.bak" - if [[ -f "$dest" ]] && cmp -s "$src" "$dest"; then - print_kv_row "backup" "$GRAY" "backup_unchanged" "$dest" - return 0 - fi - fi - cp "$src" "$dest" -} - -run_gen_compliance() { - local tmp - tmp="$(mktemp "${TMPDIR:-/tmp}/fromchat-compliance.XXXXXX")" - "$VENV_PY" scripts/compliance/keypair.py --emit-key-lines "$tmp" - { - IFS= read -r _COMPLIANCE_PRIVATE_B64 - IFS= read -r _COMPLIANCE_PUBLIC_B64 - } <"$tmp" - rm -f "$tmp" - if [[ -z "$_COMPLIANCE_PRIVATE_B64" || -z "$_COMPLIANCE_PUBLIC_B64" ]]; then - echo "generate:env: compliance keypair generation failed" >&2 - exit 1 - fi - print_kv_row "generated " "$LIME" "COMPLIANCE_PUBLIC_KEY" "$_COMPLIANCE_PUBLIC_B64" - buffer_env_line "COMPLIANCE_PUBLIC_KEY" "$_COMPLIANCE_PUBLIC_B64" -} - -_write_compliance_keypair_txt() { - [[ -n "$_COMPLIANCE_PRIVATE_B64" && -n "$_COMPLIANCE_PUBLIC_B64" ]] || return 0 - [[ -n "$COMPLIANCE_TXT" ]] || return 0 - mkdir -p "$(dirname "$COMPLIANCE_TXT")" - local ts - ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" - cat >"$COMPLIANCE_TXT" <) - prompt_set "$key" - ;; - \) - run_gen_compliance - ;; - *) - if [[ "$rhs" == \&2 - exit 1 - fi - print_kv_row "generated " "$LIME" "$key" "$rhs" - buffer_env_line "$key" "$rhs" - ;; - esac -} - -read_yes() { - local prompt="$1" - local a - printf '%b%s%b' "$GRAY" "$prompt" "$NC" >&2 - IFS= read -r a || true - [[ "${a:-}" =~ ^[yY]([eE][sS])?$ ]] -} - -# Backups: safe default yes — only explicit n/no skips; Enter, y/yes, or anything else → backup -read_yes_default_yes() { - local prompt="$1" a - printf '%b%s%b' "$GRAY" "$prompt" "$NC" >&2 - IFS= read -r a || true - a="${a#"${a%%[![:space:]]*}"}" - a="${a%"${a##*[![:space:]]}"}" - [[ "$a" =~ ^[nN]([oO])?$ ]] && return 1 - return 0 -} - -# Sets global named by $1 to trimmed read line or default $2; $3 = stderr label. -prompt_output_path() { - local _out_var="$1" _default="$2" _label="$3" _line - printf '%b%s%b ' "$GRAY" "$_label" "$NC" >&2 - printf '[%s]: ' "$_default" >&2 - IFS= read -r _line || true - _line="${_line#"${_line%%[![:space:]]*}"}" - _line="${_line%"${_line##*[![:space:]]}"}" - if [[ -z "$_line" ]]; then - printf -v "$_out_var" '%s' "$_default" - else - printf -v "$_out_var" '%s' "$_line" - fi -} - -# --- main: build buffer only --- -exec 3<<< "$_ENV_TEMPLATE" -while IFS= read -r line <&3 || [[ -n "$line" ]]; do - process_line "$line" -done -exec 3<&- - -printf '\n' >&2 -if ! read_yes "Write generated files? [y/N]: "; then - printf '%bAborted (no commit).%b\n' "$RED" "$NC" >&2 - exit 1 -fi - -prompt_output_path DEPLOY_OUTPUT_DIR "deployment" "Deployment output directory (under repo)" -DEPLOY_OUTPUT_DIR="${DEPLOY_OUTPUT_DIR%/}" -if [[ "$DEPLOY_OUTPUT_DIR" != /* ]]; then - DEPLOY_OUTPUT_DIR="${ROOT}/${DEPLOY_OUTPUT_DIR}" -fi -ENV_PATH="${DEPLOY_OUTPUT_DIR}/.env" -COMPLIANCE_TXT="${DEPLOY_OUTPUT_DIR}/compliance_keypair.txt" - -if [[ -f "$ENV_PATH" ]] && read_yes_default_yes "File exists: ${ENV_PATH}. Create backup before overwrite? [Y/n]: "; then - _do_backup_copy "$ENV_PATH" -fi - -if [[ -f "$COMPLIANCE_TXT" ]] && read_yes_default_yes "File exists: ${COMPLIANCE_TXT}. Create backup before overwrite? [Y/n]: "; then - _do_backup_copy "$COMPLIANCE_TXT" -fi - -mkdir -p "$(dirname "$ENV_PATH")" -printf '%s\n' "${ENV_LINES[@]}" >"$ENV_PATH" -_write_compliance_keypair_txt - -printf '\n%b✓ env written to %s%b\n' "$CHECK" "$ENV_PATH" "$NC" -if [[ -n "$_COMPLIANCE_PUBLIC_B64" ]]; then - printf '%b✓ compliance keypair written to %s%b\n' "$CHECK" "$COMPLIANCE_TXT" "$NC" -fi diff --git a/scripts/install:pussh.sh b/scripts/install:pussh.sh deleted file mode 100755 index 361f9ed..0000000 --- a/scripts/install:pussh.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/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/livekit/ensure.py b/scripts/livekit/ensure.py deleted file mode 100644 index 4777f8b..0000000 --- a/scripts/livekit/ensure.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 -""" -Download the LiveKit server binary for the current OS/arch from the latest GitHub release. - -macOS: GitHub releases often omit darwin assets. If `livekit-server` is missing, this script -runs `brew install livekit` automatically (Homebrew must be installed). - -Linux / Windows: download the matching .tar.gz / .zip from the latest release. -""" -from __future__ import annotations - -import json -import os -import platform -import shutil -import stat -import subprocess -import sys -import tarfile -import urllib.request -import zipfile -from pathlib import Path - -REPO = "livekit/livekit" -API_LATEST = f"https://api.github.com/repos/{REPO}/releases/latest" - - -def repo_root() -> Path: - return Path(__file__).resolve().parents[2] - - -def tools_dir(root: Path) -> Path: - return root / ".tools" / "livekit" - - -def platform_triple() -> tuple[str, str, str]: - """Returns (os_name, arch, archive_ext). archive_ext is tar.gz or zip.""" - system = platform.system().lower() - machine = platform.machine().lower() - if system == "darwin": - os_name = "darwin" - arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" - return os_name, arch, "tar.gz" - if system == "linux": - os_name = "linux" - if machine in ("aarch64", "arm64"): - arch = "arm64" - elif machine in ("armv7l", "armv7"): - arch = "armv7" - else: - arch = "amd64" - return os_name, arch, "tar.gz" - if system == "windows": - os_name = "windows" - arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" - return os_name, arch, "zip" - raise SystemExit(f"Unsupported OS: {system!r}") - - -def fetch_latest_release() -> dict: - req = urllib.request.Request( - API_LATEST, - headers={"Accept": "application/vnd.github+json", "User-Agent": "fromchat-livekit-ensure"}, - ) - with urllib.request.urlopen(req, timeout=120) as resp: - return json.load(resp) - - -def pick_asset(assets: list[dict], filename: str) -> dict | None: - for a in assets: - if a.get("name") == filename: - return a - return None - - -def download(url: str, dest: Path) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - req = urllib.request.Request(url, headers={"User-Agent": "fromchat-livekit-ensure"}) - with urllib.request.urlopen(req, timeout=300) as resp: - dest.write_bytes(resp.read()) - - -def chmod_plus_x(path: Path) -> None: - if path.suffix.lower() == ".exe" or platform.system().lower() == "windows": - return - mode = path.stat().st_mode - path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - -def find_server_binary(extract_dir: Path) -> Path | None: - for name in ("livekit-server", "livekit-server.exe"): - for p in extract_dir.rglob(name): - if p.is_file(): - return p - return None - - -def resolve_macos_binary(td: Path) -> str | None: - w = shutil.which("livekit-server") - if w: - return w - # Homebrew default locations (Apple Silicon / Intel) - for candidate in ( - Path("/opt/homebrew/bin/livekit-server"), - Path("/usr/local/bin/livekit-server"), - ): - if candidate.is_file(): - return str(candidate) - return None - - -def find_brew() -> str | None: - w = shutil.which("brew") - if w: - return w - for candidate in ("/opt/homebrew/bin/brew", "/usr/local/bin/brew"): - p = Path(candidate) - if p.is_file(): - return str(p) - return None - - -def install_livekit_via_homebrew() -> bool: - brew = find_brew() - if not brew: - print( - "Homebrew not found. Install it from https://brew.sh then re-run this task.", - file=sys.stderr, - ) - return False - print("Installing LiveKit via Homebrew (brew install livekit) …", file=sys.stderr) - result = subprocess.run( - [brew, "install", "livekit"], - check=False, - ) - if result.returncode != 0: - print("brew install livekit failed.", file=sys.stderr) - return False - return True - - -def main() -> int: - root = repo_root() - td = tools_dir(root) - td.mkdir(parents=True, exist_ok=True) - - os_name, arch, ext = platform_triple() - - # macOS: GitHub release assets often omit darwin; use Homebrew (auto-install if needed). - if os_name == "darwin": - mac_bin = resolve_macos_binary(td) - if not mac_bin: - if not install_livekit_via_homebrew(): - return 1 - mac_bin = resolve_macos_binary(td) - if not mac_bin: - print( - "livekit-server still not found after brew install. " - "Open a new terminal or run: hash -r", - file=sys.stderr, - ) - return 1 - (td / ".version").write_text("system\n", encoding="utf-8") - print(f"Using LiveKit server: {mac_bin}", file=sys.stderr) - print(mac_bin) - return 0 - - release = fetch_latest_release() - tag = release.get("tag_name") or "" - if not tag.startswith("v"): - print("Unexpected release tag", tag, file=sys.stderr) - return 1 - ver = tag[1:] - assets = release.get("assets") or [] - - if ext == "zip": - archive_name = f"livekit_{ver}_{os_name}_{arch}.zip" - else: - archive_name = f"livekit_{ver}_{os_name}_{arch}.tar.gz" - - version_file = td / ".version" - bin_hint = td / ("livekit-server.exe" if ext == "zip" else "livekit-server") - - if ( - version_file.is_file() - and bin_hint.is_file() - and version_file.read_text(encoding="utf-8").strip() == tag - ): - print(f"LiveKit {tag} already present at {bin_hint}", file=sys.stderr) - print(str(bin_hint)) - return 0 - - asset = pick_asset(assets, archive_name) - if not asset: - print( - f"No GitHub asset {archive_name!r} for {tag}. See https://github.com/{REPO}/releases", - file=sys.stderr, - ) - return 1 - - url = asset["browser_download_url"] - staging = td / "_staging" - if staging.exists(): - shutil.rmtree(staging) - staging.mkdir(parents=True) - - archive = staging / asset["name"] - print(f"Downloading LiveKit {tag}: {asset['name']} …", file=sys.stderr) - download(url, archive) - - extract_dir = staging / "extract" - extract_dir.mkdir() - - if archive_name.endswith(".tar.gz"): - with tarfile.open(archive, "r:gz") as tf: - tf.extractall(extract_dir) - elif archive_name.endswith(".zip"): - with zipfile.ZipFile(archive, "r") as zf: - zf.extractall(extract_dir) - else: - print(f"Unsupported archive: {archive_name}", file=sys.stderr) - return 1 - - binary = find_server_binary(extract_dir) - if not binary: - print("Could not find livekit-server binary after extract.", file=sys.stderr) - return 1 - - if bin_hint.exists(): - bin_hint.unlink() - shutil.move(str(binary), str(bin_hint)) - chmod_plus_x(bin_hint) - - shutil.rmtree(staging) - version_file.write_text(tag + "\n", encoding="utf-8") - print(f"Installed LiveKit {tag} → {bin_hint}", file=sys.stderr) - print(str(bin_hint)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/livekit:ensure.sh b/scripts/livekit:ensure.sh deleted file mode 100644 index 420fd2e..0000000 --- a/scripts/livekit:ensure.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Ensures a LiveKit server binary is available (downloads from GitHub on Linux/Windows; -# on macOS runs `brew install livekit` if needed — see scripts/livekit/ensure.py). -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -exec python3 "$ROOT/scripts/livekit/ensure.py" diff --git a/scripts/livekit:run.sh b/scripts/livekit:run.sh deleted file mode 100644 index 9945d18..0000000 --- a/scripts/livekit:run.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Start LiveKit with deployment/livekit.dev.yaml (after ensure.py). -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$ROOT" - -BIN="$(python3 "$ROOT/scripts/livekit/ensure.py" | tail -1)" -CONFIG="$ROOT/deployment/livekit.dev.yaml" - -if [[ ! -f "$CONFIG" ]]; then - echo "Missing $CONFIG" >&2 - exit 1 -fi - -echo "Starting LiveKit: $BIN --config $CONFIG" >&2 -exec "$BIN" --config "$CONFIG" diff --git a/scripts/transfer:unregistry.sh b/scripts/transfer:unregistry.sh deleted file mode 100755 index 8e5b8c5..0000000 --- a/scripts/transfer:unregistry.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/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" - - diff --git a/frontend/electron.d.ts b/src/electron/electron.d.ts similarity index 100% rename from frontend/electron.d.ts rename to src/electron/electron.d.ts diff --git a/frontend/forge.config.ts b/src/electron/forge.config.ts similarity index 96% rename from frontend/forge.config.ts rename to src/electron/forge.config.ts index b96f865..4280750 100644 --- a/frontend/forge.config.ts +++ b/src/electron/forge.config.ts @@ -6,7 +6,7 @@ export default { packagerConfig: { asar: true, }, - outDir: "frontend/build/electron/forge", + outDir: "build/electron/forge", rebuildConfig: {}, makers: [ { diff --git a/frontend/electron/main.ts b/src/electron/main.ts similarity index 88% rename from frontend/electron/main.ts rename to src/electron/main.ts index dc58b07..38da357 100644 --- a/frontend/electron/main.ts +++ b/src/electron/main.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, Notification, ipcMain } from 'electron'; import path from "path"; -import type { NotificationShowOptions } from '../electron.d.ts'; +import type { NotificationShowOptions } from "./electron.d.ts"; let mainWindow: BrowserWindow | null = null; @@ -23,7 +23,7 @@ app.whenReady().then(() => { if (process.env.VITE_DEV_SERVER_URL) { mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL); } else { - mainWindow.loadFile('frontend/build/electron/dist/index.html'); + mainWindow.loadFile('build/electron/dist/index.html'); } // Handle notification permission requests @@ -35,7 +35,7 @@ app.whenReady().then(() => { }); // Handle showing notifications - ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => { + ipcMain.handle("show-notification", async (_event, options: NotificationShowOptions) => { if (Notification.isSupported()) { try { const notification = new Notification({ diff --git a/src/electron/preload.ts b/src/electron/preload.ts new file mode 100644 index 0000000..50b98b7 --- /dev/null +++ b/src/electron/preload.ts @@ -0,0 +1,11 @@ +import { contextBridge, ipcRenderer } from "electron"; +import type { ElectronInterface, NotificationShowOptions, Platform } from "./electron.d.ts"; + +contextBridge.exposeInMainWorld("electronInterface", { + desktop: true, + platform: process.platform as Platform, + notifications: { + requestPermission: () => ipcRenderer.invoke("request-notification-permission"), + show: (options: NotificationShowOptions) => ipcRenderer.invoke("show-notification", options) + } +} satisfies ElectronInterface); \ No newline at end of file diff --git a/frontend/index.html b/src/index.html similarity index 69% rename from frontend/index.html rename to src/index.html index e0e69ad..69da601 100644 --- a/frontend/index.html +++ b/src/index.html @@ -4,10 +4,11 @@ Loading... - +
- + - \ No newline at end of file + + diff --git a/frontend/src/App.tsx b/src/main/App.tsx similarity index 100% rename from frontend/src/App.tsx rename to src/main/App.tsx diff --git a/frontend/src/Electron.tsx b/src/main/Electron.tsx similarity index 100% rename from frontend/src/Electron.tsx rename to src/main/Electron.tsx diff --git a/frontend/src/core/DeletedUserAvatar.tsx b/src/main/core/DeletedUserAvatar.tsx similarity index 100% rename from frontend/src/core/DeletedUserAvatar.tsx rename to src/main/core/DeletedUserAvatar.tsx diff --git a/frontend/src/core/api/account/devices.ts b/src/main/core/api/account/devices.ts similarity index 100% rename from frontend/src/core/api/account/devices.ts rename to src/main/core/api/account/devices.ts diff --git a/frontend/src/core/api/account/index.ts b/src/main/core/api/account/index.ts similarity index 98% rename from frontend/src/core/api/account/index.ts rename to src/main/core/api/account/index.ts index 9b9fdbc..eba0226 100644 --- a/frontend/src/core/api/account/index.ts +++ b/src/main/core/api/account/index.ts @@ -142,7 +142,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis const pair = generateX25519KeyPair(); currentPublicKey = pair.publicKey; currentPrivateKey = pair.privateKey; - await uploadPublicKey(currentPublicKey, token); + await uploadPublicKey(pair.publicKey, token); const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); await uploadBackupBlob(encodeBlob(newBlob), token); } @@ -159,7 +159,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis const pair = generateX25519KeyPair(); currentPublicKey = pair.publicKey; currentPrivateKey = pair.privateKey; - await uploadPublicKey(currentPublicKey, token); + await uploadPublicKey(pair.publicKey, token); const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); await uploadBackupBlob(encodeBlob(encBlob), token); diff --git a/frontend/src/core/api/account/profile.ts b/src/main/core/api/account/profile.ts similarity index 100% rename from frontend/src/core/api/account/profile.ts rename to src/main/core/api/account/profile.ts diff --git a/frontend/src/core/api/chats/dm.ts b/src/main/core/api/chats/dm.ts similarity index 100% rename from frontend/src/core/api/chats/dm.ts rename to src/main/core/api/chats/dm.ts diff --git a/frontend/src/core/api/chats/general.ts b/src/main/core/api/chats/general.ts similarity index 100% rename from frontend/src/core/api/chats/general.ts rename to src/main/core/api/chats/general.ts diff --git a/frontend/src/core/api/crypto.ts b/src/main/core/api/crypto.ts similarity index 100% rename from frontend/src/core/api/crypto.ts rename to src/main/core/api/crypto.ts diff --git a/frontend/src/core/api/crypto/backup.ts b/src/main/core/api/crypto/backup.ts similarity index 100% rename from frontend/src/core/api/crypto/backup.ts rename to src/main/core/api/crypto/backup.ts diff --git a/frontend/src/core/api/crypto/identity.ts b/src/main/core/api/crypto/identity.ts similarity index 100% rename from frontend/src/core/api/crypto/identity.ts rename to src/main/core/api/crypto/identity.ts diff --git a/frontend/src/core/api/crypto/prekeys.ts b/src/main/core/api/crypto/prekeys.ts similarity index 100% rename from frontend/src/core/api/crypto/prekeys.ts rename to src/main/core/api/crypto/prekeys.ts diff --git a/frontend/src/core/api/dm.ts b/src/main/core/api/dm.ts similarity index 96% rename from frontend/src/core/api/dm.ts rename to src/main/core/api/dm.ts index 443e059..0b3485b 100644 --- a/frontend/src/core/api/dm.ts +++ b/src/main/core/api/dm.ts @@ -89,7 +89,7 @@ export async function getTransportPublicKey(): Promise { } try { - const response = await fetch(`${API_BASE_URL}/api/dm/key/transport/public`); + const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data: TransportKey = await response.json(); @@ -163,7 +163,7 @@ export async function sendEncryptedDM( await encryptMessageForTransport(plaintext); // Send to server - const response = await fetch(`${API_BASE_URL}/api/dm/send`, { + const response = await fetch(`${API_BASE_URL}/dm/send`, { method: "POST", headers: { "Content-Type": "application/json", @@ -195,7 +195,7 @@ export async function getEncryptedConversation( offset: number = 0 ): Promise { try { - const url = new URL(`${API_BASE_URL}/api/dm/conversation/${otherUserId}`); + const url = new URL(`${API_BASE_URL}/dm/conversation/${otherUserId}`); url.searchParams.append("limit", String(limit)); url.searchParams.append("offset", String(offset)); @@ -217,7 +217,7 @@ export async function getEncryptedConversation( */ export async function deleteEncryptedDM(messageId: number, token: string): Promise { try { - const response = await fetch(`${API_BASE_URL}/api/dm/${messageId}`, { + const response = await fetch(`${API_BASE_URL}/dm/${messageId}`, { method: "DELETE", headers: getAuthHeaders(token, true) }); diff --git a/frontend/src/core/api/dmApi.ts b/src/main/core/api/dmApi.ts similarity index 100% rename from frontend/src/core/api/dmApi.ts rename to src/main/core/api/dmApi.ts diff --git a/frontend/src/core/api/files.ts b/src/main/core/api/files.ts similarity index 100% rename from frontend/src/core/api/files.ts rename to src/main/core/api/files.ts diff --git a/frontend/src/core/api/index.ts b/src/main/core/api/index.ts similarity index 93% rename from frontend/src/core/api/index.ts rename to src/main/core/api/index.ts index dbeb1be..566b9a1 100644 --- a/frontend/src/core/api/index.ts +++ b/src/main/core/api/index.ts @@ -9,7 +9,6 @@ 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"; @@ -33,7 +32,6 @@ const api = { blocklist: moderationBlocklist, users: moderationUsers }, - calls: callsModule, files: filesModule, push: pushModule }; @@ -44,7 +42,6 @@ export const chats = api.chats; export const user = api.user; export const crypto = api.crypto; export const moderation = api.moderation; -export const calls = api.calls; export const files = api.files; export const push = api.push; diff --git a/frontend/src/core/api/messaging.ts b/src/main/core/api/messaging.ts similarity index 100% rename from frontend/src/core/api/messaging.ts rename to src/main/core/api/messaging.ts diff --git a/frontend/src/core/api/moderation.ts b/src/main/core/api/moderation.ts similarity index 100% rename from frontend/src/core/api/moderation.ts rename to src/main/core/api/moderation.ts diff --git a/frontend/src/core/api/moderation/blocklist.ts b/src/main/core/api/moderation/blocklist.ts similarity index 100% rename from frontend/src/core/api/moderation/blocklist.ts rename to src/main/core/api/moderation/blocklist.ts diff --git a/frontend/src/core/api/moderation/users.ts b/src/main/core/api/moderation/users.ts similarity index 100% rename from frontend/src/core/api/moderation/users.ts rename to src/main/core/api/moderation/users.ts diff --git a/frontend/src/core/api/profileApi.ts b/src/main/core/api/profileApi.ts similarity index 100% rename from frontend/src/core/api/profileApi.ts rename to src/main/core/api/profileApi.ts diff --git a/frontend/src/core/api/push.ts b/src/main/core/api/push.ts similarity index 100% rename from frontend/src/core/api/push.ts rename to src/main/core/api/push.ts diff --git a/frontend/src/core/api/user/auth.ts b/src/main/core/api/user/auth.ts similarity index 98% rename from frontend/src/core/api/user/auth.ts rename to src/main/core/api/user/auth.ts index 9f9e7a4..09a7756 100644 --- a/frontend/src/core/api/user/auth.ts +++ b/src/main/core/api/user/auth.ts @@ -142,7 +142,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis const pair = generateX25519KeyPair(); currentPublicKey = pair.publicKey; currentPrivateKey = pair.privateKey; - await uploadPublicKey(currentPublicKey, token); + await uploadPublicKey(pair.publicKey, token); const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); await uploadBackupBlob(encodeBlob(newBlob), token); } @@ -159,7 +159,7 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis const pair = generateX25519KeyPair(); currentPublicKey = pair.publicKey; currentPrivateKey = pair.privateKey; - await uploadPublicKey(currentPublicKey, token); + await uploadPublicKey(pair.publicKey, token); const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); await uploadBackupBlob(encodeBlob(encBlob), token); diff --git a/frontend/src/core/api/user/devices.ts b/src/main/core/api/user/devices.ts similarity index 100% rename from frontend/src/core/api/user/devices.ts rename to src/main/core/api/user/devices.ts diff --git a/frontend/src/core/api/user/profile.ts b/src/main/core/api/user/profile.ts similarity index 100% rename from frontend/src/core/api/user/profile.ts rename to src/main/core/api/user/profile.ts diff --git a/frontend/src/core/api/user/search.ts b/src/main/core/api/user/search.ts similarity index 100% rename from frontend/src/core/api/user/search.ts rename to src/main/core/api/user/search.ts diff --git a/frontend/src/core/api/users.ts b/src/main/core/api/users.ts similarity index 100% rename from frontend/src/core/api/users.ts rename to src/main/core/api/users.ts diff --git a/frontend/src/core/avatarGradient.ts b/src/main/core/avatarGradient.ts similarity index 100% rename from frontend/src/core/avatarGradient.ts rename to src/main/core/avatarGradient.ts diff --git a/frontend/src/core/calls/e2eeWorker.ts b/src/main/core/calls/e2eeWorker.ts similarity index 100% rename from frontend/src/core/calls/e2eeWorker.ts rename to src/main/core/calls/e2eeWorker.ts diff --git a/frontend/src/core/calls/encryption.ts b/src/main/core/calls/encryption.ts similarity index 100% rename from frontend/src/core/calls/encryption.ts rename to src/main/core/calls/encryption.ts diff --git a/frontend/src/core/calls/signaling.ts b/src/main/core/calls/signaling.ts similarity index 100% rename from frontend/src/core/calls/signaling.ts rename to src/main/core/calls/signaling.ts diff --git a/frontend/src/core/calls/webrtc.ts b/src/main/core/calls/webrtc.ts similarity index 98% rename from frontend/src/core/calls/webrtc.ts rename to src/main/core/calls/webrtc.ts index 1edc0f8..bd14188 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/src/main/core/calls/webrtc.ts @@ -83,33 +83,13 @@ export class WebRTCCall { * Initializes the peer connection with proper ICE servers and sets up event listeners */ async initialize(): Promise { - const iceServers = await this.getIceServers(); - - // Create peer connection with proper ICE servers this.peerConnection = new RTCPeerConnection({ - iceServers + iceServers: DEFAULT_ICE_SERVERS }); this.setupEventListeners(); } - /** - * Gets ICE servers from backend with fallback - */ - private async getIceServers(): Promise { - try { - const token = api.user.auth.getAuthToken(); - if (!token) throw new Error("No auth token"); - const data = await api.calls.iceServers(token); - return data.iceServers || []; - } catch (error) { - console.warn("Failed to fetch ICE servers:", error); - } - - // Fallback to STUN only if backend fails - return DEFAULT_ICE_SERVERS; - } - /** * Sets up all peer connection event listeners */ diff --git a/frontend/src/core/components/AlertDialog.tsx b/src/main/core/components/AlertDialog.tsx similarity index 100% rename from frontend/src/core/components/AlertDialog.tsx rename to src/main/core/components/AlertDialog.tsx diff --git a/frontend/src/core/components/Input.tsx b/src/main/core/components/Input.tsx similarity index 100% rename from frontend/src/core/components/Input.tsx rename to src/main/core/components/Input.tsx diff --git a/frontend/src/core/components/Quote.tsx b/src/main/core/components/Quote.tsx similarity index 100% rename from frontend/src/core/components/Quote.tsx rename to src/main/core/components/Quote.tsx diff --git a/frontend/src/core/components/RichTextArea.tsx b/src/main/core/components/RichTextArea.tsx similarity index 100% rename from frontend/src/core/components/RichTextArea.tsx rename to src/main/core/components/RichTextArea.tsx diff --git a/frontend/src/core/components/SearchBar.tsx b/src/main/core/components/SearchBar.tsx similarity index 100% rename from frontend/src/core/components/SearchBar.tsx rename to src/main/core/components/SearchBar.tsx diff --git a/frontend/src/core/components/SplitButton.tsx b/src/main/core/components/SplitButton.tsx similarity index 100% rename from frontend/src/core/components/SplitButton.tsx rename to src/main/core/components/SplitButton.tsx diff --git a/frontend/src/core/components/StatusBadge.tsx b/src/main/core/components/StatusBadge.tsx similarity index 100% rename from frontend/src/core/components/StatusBadge.tsx rename to src/main/core/components/StatusBadge.tsx diff --git a/frontend/src/core/components/StyledDialog.tsx b/src/main/core/components/StyledDialog.tsx similarity index 100% rename from frontend/src/core/components/StyledDialog.tsx rename to src/main/core/components/StyledDialog.tsx diff --git a/frontend/src/core/components/VerifyButton.tsx b/src/main/core/components/VerifyButton.tsx similarity index 100% rename from frontend/src/core/components/VerifyButton.tsx rename to src/main/core/components/VerifyButton.tsx diff --git a/frontend/src/core/components/css/alert-dialog.module.scss b/src/main/core/components/css/alert-dialog.module.scss similarity index 100% rename from frontend/src/core/components/css/alert-dialog.module.scss rename to src/main/core/components/css/alert-dialog.module.scss diff --git a/frontend/src/core/components/css/searchBar.module.scss b/src/main/core/components/css/searchBar.module.scss similarity index 100% rename from frontend/src/core/components/css/searchBar.module.scss rename to src/main/core/components/css/searchBar.module.scss diff --git a/frontend/src/core/components/css/split-button.module.scss b/src/main/core/components/css/split-button.module.scss similarity index 100% rename from frontend/src/core/components/css/split-button.module.scss rename to src/main/core/components/css/split-button.module.scss diff --git a/frontend/src/core/components/css/styled-dialog.module.scss b/src/main/core/components/css/styled-dialog.module.scss similarity index 100% rename from frontend/src/core/components/css/styled-dialog.module.scss rename to src/main/core/components/css/styled-dialog.module.scss diff --git a/src/main/core/config.ts b/src/main/core/config.ts new file mode 100644 index 0000000..f981a5d --- /dev/null +++ b/src/main/core/config.ts @@ -0,0 +1,60 @@ +/** + * @fileoverview Application configuration constants + * @description Contains all configuration values used throughout the application + * @author Cursor + * @version 1.0.0 + */ + +const DEFAULT_API_BASE_URL = import.meta.env.DEV + ? "http://localhost:8300" + : "https://api.fromchat.ru"; + +function resolveApiBaseUrl(): string { + return import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL; +} + +function stripUrlProtocol(value: string): string { + return value.replace(/^https?:\/\//, "").replace(/\/$/, ""); +} + +function resolveWsHost(apiBaseUrl: string): string { + const explicit = import.meta.env.VITE_API_WS_BASE_URL; + if (explicit) { + return stripUrlProtocol(explicit); + } + + if (apiBaseUrl.startsWith("/")) { + const path = apiBaseUrl.replace(/\/$/, ""); + if (typeof window !== "undefined") { + return `${window.location.host}${path}`; + } + return `localhost:8301${path}`; + } + + try { + return new URL(apiBaseUrl).host; + } catch { + return stripUrlProtocol(apiBaseUrl); + } +} + +function resolveWsProtocol(apiBaseUrl: string): "ws:" | "wss:" { + if (apiBaseUrl.startsWith("https:")) { + return "wss:"; + } + if (typeof window !== "undefined" && window.location.protocol === "https:") { + return "wss:"; + } + return "ws:"; +} + +export const BASE_DOMAIN = "fromchat.ru"; +export const API_BASE_URL = resolveApiBaseUrl(); +export const API_WS_BASE_URL = resolveWsHost(API_BASE_URL); + +export function getChatWebSocketUrl(): string { + return `${resolveWsProtocol(API_BASE_URL)}//${API_WS_BASE_URL}/chat/ws`; +} + +export const PRODUCT_NAME = "FromChat"; +export const MINIMUM_WIDTH = 800; diff --git a/frontend/src/core/electron/electron.scss b/src/main/core/electron/electron.scss similarity index 100% rename from frontend/src/core/electron/electron.scss rename to src/main/core/electron/electron.scss diff --git a/frontend/src/core/electron/electron.ts b/src/main/core/electron/electron.ts similarity index 100% rename from frontend/src/core/electron/electron.ts rename to src/main/core/electron/electron.ts diff --git a/frontend/src/core/hooks/useCombinedRefs.ts b/src/main/core/hooks/useCombinedRefs.ts similarity index 100% rename from frontend/src/core/hooks/useCombinedRefs.ts rename to src/main/core/hooks/useCombinedRefs.ts diff --git a/frontend/src/core/hooks/useDownloadAppScreen.tsx b/src/main/core/hooks/useDownloadAppScreen.tsx similarity index 100% rename from frontend/src/core/hooks/useDownloadAppScreen.tsx rename to src/main/core/hooks/useDownloadAppScreen.tsx diff --git a/frontend/src/core/hooks/useWindowSize.ts b/src/main/core/hooks/useWindowSize.ts similarity index 100% rename from frontend/src/core/hooks/useWindowSize.ts rename to src/main/core/hooks/useWindowSize.ts diff --git a/frontend/src/core/init.ts b/src/main/core/init.ts similarity index 91% rename from frontend/src/core/init.ts rename to src/main/core/init.ts index d69295b..a59aa57 100644 --- a/frontend/src/core/init.ts +++ b/src/main/core/init.ts @@ -7,7 +7,7 @@ import { PRODUCT_NAME } from "./config"; import { enableMapSet } from "immer"; -import type { Platform } from "../../electron.d"; +import type { Platform } from "../../electron/electron.d"; function detectPlatform(): Platform { const userAgent = navigator.userAgent.toLowerCase(); diff --git a/frontend/src/core/legal/LegalInlineLinks.tsx b/src/main/core/legal/LegalInlineLinks.tsx similarity index 100% rename from frontend/src/core/legal/LegalInlineLinks.tsx rename to src/main/core/legal/LegalInlineLinks.tsx diff --git a/frontend/src/core/legal/LegalMarkdownPage.tsx b/src/main/core/legal/LegalMarkdownPage.tsx similarity index 100% rename from frontend/src/core/legal/LegalMarkdownPage.tsx rename to src/main/core/legal/LegalMarkdownPage.tsx diff --git a/frontend/src/core/legal/LegalPageShell.tsx b/src/main/core/legal/LegalPageShell.tsx similarity index 100% rename from frontend/src/core/legal/LegalPageShell.tsx rename to src/main/core/legal/LegalPageShell.tsx diff --git a/frontend/src/core/legal/fcDirective.ts b/src/main/core/legal/fcDirective.ts similarity index 95% rename from frontend/src/core/legal/fcDirective.ts rename to src/main/core/legal/fcDirective.ts index 8a708c6..d375d5e 100644 --- a/frontend/src/core/legal/fcDirective.ts +++ b/src/main/core/legal/fcDirective.ts @@ -2,6 +2,8 @@ * Parses `` directives before section headers. */ +import { API_BASE_URL } from "@/core/config"; + export interface FcSectionDirective { shape: string; icon: string; @@ -70,7 +72,7 @@ export function parseLegalMarkdown(markdown: string): { preamble: string; sectio } export function staticIconUrl(icon: string): string { - return `/api/static/icons/${encodeURIComponent(icon)}.webp`; + return `${API_BASE_URL}/static/icons/${encodeURIComponent(icon)}.webp`; } /** Maps legal-doc icon keys to Material Symbols names (Google Fonts). */ diff --git a/frontend/src/core/legal/legal.module.scss b/src/main/core/legal/legal.module.scss similarity index 100% rename from frontend/src/core/legal/legal.module.scss rename to src/main/core/legal/legal.module.scss diff --git a/frontend/src/core/legal/legalDocumentLoader.ts b/src/main/core/legal/legalDocumentLoader.ts similarity index 94% rename from frontend/src/core/legal/legalDocumentLoader.ts rename to src/main/core/legal/legalDocumentLoader.ts index 185001b..362652c 100644 --- a/frontend/src/core/legal/legalDocumentLoader.ts +++ b/src/main/core/legal/legalDocumentLoader.ts @@ -1,10 +1,11 @@ import { delay } from "@/utils/utils"; +import { API_BASE_URL } from "@/core/config"; export type LegalDocumentKind = "privacy" | "terms"; export const LEGAL_DOCUMENT_PATH: Record = { - privacy: "/api/static/PRIVACY.md", - terms: "/api/static/TERMS.md", + privacy: `${API_BASE_URL}/static/PRIVACY.md`, + terms: `${API_BASE_URL}/static/TERMS.md`, }; const RETRY_WINDOW_MS = 5000; diff --git a/frontend/src/core/legal/legalLinks.ts b/src/main/core/legal/legalLinks.ts similarity index 100% rename from frontend/src/core/legal/legalLinks.ts rename to src/main/core/legal/legalLinks.ts diff --git a/frontend/src/core/legal/materialShapes.generated.ts b/src/main/core/legal/materialShapes.generated.ts similarity index 100% rename from frontend/src/core/legal/materialShapes.generated.ts rename to src/main/core/legal/materialShapes.generated.ts diff --git a/frontend/src/core/legal/materialShapes.ts b/src/main/core/legal/materialShapes.ts similarity index 100% rename from frontend/src/core/legal/materialShapes.ts rename to src/main/core/legal/materialShapes.ts diff --git a/frontend/src/core/onlineStatusManager.ts b/src/main/core/onlineStatusManager.ts similarity index 100% rename from frontend/src/core/onlineStatusManager.ts rename to src/main/core/onlineStatusManager.ts diff --git a/frontend/src/core/profileLinks.ts b/src/main/core/profileLinks.ts similarity index 100% rename from frontend/src/core/profileLinks.ts rename to src/main/core/profileLinks.ts diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/src/main/core/push-notifications/push-notifications.ts similarity index 100% rename from frontend/src/core/push-notifications/push-notifications.ts rename to src/main/core/push-notifications/push-notifications.ts diff --git a/frontend/src/core/push-notifications/service-worker.ts b/src/main/core/push-notifications/service-worker.ts similarity index 100% rename from frontend/src/core/push-notifications/service-worker.ts rename to src/main/core/push-notifications/service-worker.ts diff --git a/frontend/src/core/types.d.ts b/src/main/core/types.d.ts similarity index 99% rename from frontend/src/core/types.d.ts rename to src/main/core/types.d.ts index 0c8128c..16e14af 100644 --- a/frontend/src/core/types.d.ts +++ b/src/main/core/types.d.ts @@ -264,10 +264,6 @@ export interface DmEncryptedJSON { } } -export interface IceServersResponse { - iceServers: RTCIceServer[]; -} - // --------------- // WebSocket types // --------------- diff --git a/frontend/src/core/typingManager.ts b/src/main/core/typingManager.ts similarity index 100% rename from frontend/src/core/typingManager.ts rename to src/main/core/typingManager.ts diff --git a/frontend/src/core/updateManager.ts b/src/main/core/updateManager.ts similarity index 100% rename from frontend/src/core/updateManager.ts rename to src/main/core/updateManager.ts diff --git a/frontend/src/core/userDisplay.ts b/src/main/core/userDisplay.ts similarity index 100% rename from frontend/src/core/userDisplay.ts rename to src/main/core/userDisplay.ts diff --git a/frontend/src/core/websocket.ts b/src/main/core/websocket.ts similarity index 98% rename from frontend/src/core/websocket.ts rename to src/main/core/websocket.ts index 1ea1689..fb9404c 100644 --- a/frontend/src/core/websocket.ts +++ b/src/main/core/websocket.ts @@ -5,7 +5,7 @@ * @version 1.0.0 */ -import { API_WS_BASE_URL } from "./config"; +import { getChatWebSocketUrl } from "./config"; import type { WebSocketMessage } from "./types"; import { delay } from "@/utils/utils"; import { CallSignalingHandler } from "./calls/signaling"; @@ -26,12 +26,7 @@ interface HttpError extends Error { * @private */ function create(): WebSocket { - let prefix = "ws://"; - if (location.protocol.includes("https")) { - prefix = "wss://"; - } - - return new WebSocket(`${prefix}${API_WS_BASE_URL}/chat/ws`); + return new WebSocket(getChatWebSocketUrl()); } /** diff --git a/frontend/src/css/_colors.scss b/src/main/css/_colors.scss similarity index 100% rename from frontend/src/css/_colors.scss rename to src/main/css/_colors.scss diff --git a/frontend/src/css/_components.scss b/src/main/css/_components.scss similarity index 100% rename from frontend/src/css/_components.scss rename to src/main/css/_components.scss diff --git a/frontend/src/css/_material.scss b/src/main/css/_material.scss similarity index 100% rename from frontend/src/css/_material.scss rename to src/main/css/_material.scss diff --git a/frontend/src/css/fonts/material-symbols.scss b/src/main/css/fonts/material-symbols.scss similarity index 100% rename from frontend/src/css/fonts/material-symbols.scss rename to src/main/css/fonts/material-symbols.scss diff --git a/frontend/src/css/fonts/material-symbols.woff2 b/src/main/css/fonts/material-symbols.woff2 similarity index 100% rename from frontend/src/css/fonts/material-symbols.woff2 rename to src/main/css/fonts/material-symbols.woff2 diff --git a/frontend/src/css/fonts/montserrat.scss b/src/main/css/fonts/montserrat.scss similarity index 100% rename from frontend/src/css/fonts/montserrat.scss rename to src/main/css/fonts/montserrat.scss diff --git a/frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459W1hyyTh89ZNpQ.woff2 b/src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459W1hyyTh89ZNpQ.woff2 similarity index 100% rename from frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459W1hyyTh89ZNpQ.woff2 rename to src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459W1hyyTh89ZNpQ.woff2 diff --git a/frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WRhyyTh89ZNpQ.woff2 b/src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WRhyyTh89ZNpQ.woff2 similarity index 100% rename from frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WRhyyTh89ZNpQ.woff2 rename to src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WRhyyTh89ZNpQ.woff2 diff --git a/frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WZhyyTh89ZNpQ.woff2 b/src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WZhyyTh89ZNpQ.woff2 similarity index 100% rename from frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WZhyyTh89ZNpQ.woff2 rename to src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WZhyyTh89ZNpQ.woff2 diff --git a/frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WdhyyTh89ZNpQ.woff2 b/src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WdhyyTh89ZNpQ.woff2 similarity index 100% rename from frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WdhyyTh89ZNpQ.woff2 rename to src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WdhyyTh89ZNpQ.woff2 diff --git a/frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2 b/src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2 similarity index 100% rename from frontend/src/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2 rename to src/main/css/fonts/montserrat/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2 diff --git a/frontend/src/css/style.scss b/src/main/css/style.scss similarity index 100% rename from frontend/src/css/style.scss rename to src/main/css/style.scss diff --git a/frontend/src/images/default-avatar.png b/src/main/images/default-avatar.png similarity index 100% rename from frontend/src/images/default-avatar.png rename to src/main/images/default-avatar.png diff --git a/frontend/src/images/linux.svg b/src/main/images/linux.svg similarity index 100% rename from frontend/src/images/linux.svg rename to src/main/images/linux.svg diff --git a/frontend/src/images/logo.svg b/src/main/images/logo.svg similarity index 100% rename from frontend/src/images/logo.svg rename to src/main/images/logo.svg diff --git a/frontend/src/images/logo_square.svg b/src/main/images/logo_square.svg similarity index 100% rename from frontend/src/images/logo_square.svg rename to src/main/images/logo_square.svg diff --git a/frontend/src/images/mac.svg b/src/main/images/mac.svg similarity index 100% rename from frontend/src/images/mac.svg rename to src/main/images/mac.svg diff --git a/frontend/src/images/max.svg b/src/main/images/max.svg similarity index 100% rename from frontend/src/images/max.svg rename to src/main/images/max.svg diff --git a/frontend/src/images/screenshots/dm.png b/src/main/images/screenshots/dm.png similarity index 100% rename from frontend/src/images/screenshots/dm.png rename to src/main/images/screenshots/dm.png diff --git a/frontend/src/images/screenshots/general-chat.png b/src/main/images/screenshots/general-chat.png similarity index 100% rename from frontend/src/images/screenshots/general-chat.png rename to src/main/images/screenshots/general-chat.png diff --git a/frontend/src/images/telegram.svg b/src/main/images/telegram.svg similarity index 100% rename from frontend/src/images/telegram.svg rename to src/main/images/telegram.svg diff --git a/frontend/src/images/windows.svg b/src/main/images/windows.svg similarity index 100% rename from frontend/src/images/windows.svg rename to src/main/images/windows.svg diff --git a/frontend/src/main.tsx b/src/main/main.tsx similarity index 100% rename from frontend/src/main.tsx rename to src/main/main.tsx diff --git a/frontend/src/pages/ProtectedRoute.tsx b/src/main/pages/ProtectedRoute.tsx similarity index 100% rename from frontend/src/pages/ProtectedRoute.tsx rename to src/main/pages/ProtectedRoute.tsx diff --git a/frontend/src/pages/auth/Auth.tsx b/src/main/pages/auth/Auth.tsx similarity index 100% rename from frontend/src/pages/auth/Auth.tsx rename to src/main/pages/auth/Auth.tsx diff --git a/frontend/src/pages/auth/AuthPage.tsx b/src/main/pages/auth/AuthPage.tsx similarity index 100% rename from frontend/src/pages/auth/AuthPage.tsx rename to src/main/pages/auth/AuthPage.tsx diff --git a/frontend/src/pages/auth/AuthTextField.tsx b/src/main/pages/auth/AuthTextField.tsx similarity index 100% rename from frontend/src/pages/auth/AuthTextField.tsx rename to src/main/pages/auth/AuthTextField.tsx diff --git a/frontend/src/pages/auth/LoginForm.tsx b/src/main/pages/auth/LoginForm.tsx similarity index 100% rename from frontend/src/pages/auth/LoginForm.tsx rename to src/main/pages/auth/LoginForm.tsx diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/src/main/pages/auth/RegisterForm.tsx similarity index 100% rename from frontend/src/pages/auth/RegisterForm.tsx rename to src/main/pages/auth/RegisterForm.tsx diff --git a/frontend/src/pages/auth/auth.module.scss b/src/main/pages/auth/auth.module.scss similarity index 100% rename from frontend/src/pages/auth/auth.module.scss rename to src/main/pages/auth/auth.module.scss diff --git a/frontend/src/pages/chat/css/ChatInput.module.scss b/src/main/pages/chat/css/ChatInput.module.scss similarity index 100% rename from frontend/src/pages/chat/css/ChatInput.module.scss rename to src/main/pages/chat/css/ChatInput.module.scss diff --git a/frontend/src/pages/chat/css/EmojiMenu.module.scss b/src/main/pages/chat/css/EmojiMenu.module.scss similarity index 100% rename from frontend/src/pages/chat/css/EmojiMenu.module.scss rename to src/main/pages/chat/css/EmojiMenu.module.scss diff --git a/frontend/src/pages/chat/css/Message.module.scss b/src/main/pages/chat/css/Message.module.scss similarity index 100% rename from frontend/src/pages/chat/css/Message.module.scss rename to src/main/pages/chat/css/Message.module.scss diff --git a/frontend/src/pages/chat/css/MessageContextMenu.module.scss b/src/main/pages/chat/css/MessageContextMenu.module.scss similarity index 100% rename from frontend/src/pages/chat/css/MessageContextMenu.module.scss rename to src/main/pages/chat/css/MessageContextMenu.module.scss diff --git a/frontend/src/pages/chat/css/TypingIndicators.module.scss b/src/main/pages/chat/css/TypingIndicators.module.scss similarity index 100% rename from frontend/src/pages/chat/css/TypingIndicators.module.scss rename to src/main/pages/chat/css/TypingIndicators.module.scss diff --git a/frontend/src/pages/chat/css/callWindow.module.scss b/src/main/pages/chat/css/callWindow.module.scss similarity index 100% rename from frontend/src/pages/chat/css/callWindow.module.scss rename to src/main/pages/chat/css/callWindow.module.scss diff --git a/frontend/src/pages/chat/css/changePasswordDialog.module.scss b/src/main/pages/chat/css/changePasswordDialog.module.scss similarity index 100% rename from frontend/src/pages/chat/css/changePasswordDialog.module.scss rename to src/main/pages/chat/css/changePasswordDialog.module.scss diff --git a/frontend/src/pages/chat/css/deleted-user-avatar.module.scss b/src/main/pages/chat/css/deleted-user-avatar.module.scss similarity index 100% rename from frontend/src/pages/chat/css/deleted-user-avatar.module.scss rename to src/main/pages/chat/css/deleted-user-avatar.module.scss diff --git a/frontend/src/pages/chat/css/layout.module.scss b/src/main/pages/chat/css/layout.module.scss similarity index 100% rename from frontend/src/pages/chat/css/layout.module.scss rename to src/main/pages/chat/css/layout.module.scss diff --git a/frontend/src/pages/chat/css/left-panel.module.scss b/src/main/pages/chat/css/left-panel.module.scss similarity index 100% rename from frontend/src/pages/chat/css/left-panel.module.scss rename to src/main/pages/chat/css/left-panel.module.scss diff --git a/frontend/src/pages/chat/css/profile-dialog.module.scss b/src/main/pages/chat/css/profile-dialog.module.scss similarity index 100% rename from frontend/src/pages/chat/css/profile-dialog.module.scss rename to src/main/pages/chat/css/profile-dialog.module.scss diff --git a/frontend/src/pages/chat/css/reply-preview.module.scss b/src/main/pages/chat/css/reply-preview.module.scss similarity index 100% rename from frontend/src/pages/chat/css/reply-preview.module.scss rename to src/main/pages/chat/css/reply-preview.module.scss diff --git a/frontend/src/pages/chat/css/right-panel.module.scss b/src/main/pages/chat/css/right-panel.module.scss similarity index 100% rename from frontend/src/pages/chat/css/right-panel.module.scss rename to src/main/pages/chat/css/right-panel.module.scss diff --git a/frontend/src/pages/chat/css/settings-dialog.module.scss b/src/main/pages/chat/css/settings-dialog.module.scss similarity index 100% rename from frontend/src/pages/chat/css/settings-dialog.module.scss rename to src/main/pages/chat/css/settings-dialog.module.scss diff --git a/frontend/src/pages/chat/css/suspension-dialog.module.scss b/src/main/pages/chat/css/suspension-dialog.module.scss similarity index 100% rename from frontend/src/pages/chat/css/suspension-dialog.module.scss rename to src/main/pages/chat/css/suspension-dialog.module.scss diff --git a/frontend/src/pages/chat/hooks/useCall.ts b/src/main/pages/chat/hooks/useCall.ts similarity index 100% rename from frontend/src/pages/chat/hooks/useCall.ts rename to src/main/pages/chat/hooks/useCall.ts diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/src/main/pages/chat/hooks/useDM.ts similarity index 100% rename from frontend/src/pages/chat/hooks/useDM.ts rename to src/main/pages/chat/hooks/useDM.ts diff --git a/frontend/src/pages/chat/hooks/useProfile.ts b/src/main/pages/chat/hooks/useProfile.ts similarity index 100% rename from frontend/src/pages/chat/hooks/useProfile.ts rename to src/main/pages/chat/hooks/useProfile.ts diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/src/main/pages/chat/ui/ChatPage.tsx similarity index 100% rename from frontend/src/pages/chat/ui/ChatPage.tsx rename to src/main/pages/chat/ui/ChatPage.tsx diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/src/main/pages/chat/ui/ProfileDialog.tsx similarity index 100% rename from frontend/src/pages/chat/ui/ProfileDialog.tsx rename to src/main/pages/chat/ui/ProfileDialog.tsx diff --git a/frontend/src/pages/chat/ui/SuspensionDialog.tsx b/src/main/pages/chat/ui/SuspensionDialog.tsx similarity index 100% rename from frontend/src/pages/chat/ui/SuspensionDialog.tsx rename to src/main/pages/chat/ui/SuspensionDialog.tsx diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/src/main/pages/chat/ui/left/ChatHeader.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/ChatHeader.tsx rename to src/main/pages/chat/ui/left/ChatHeader.tsx diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/src/main/pages/chat/ui/left/LeftPanel.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/LeftPanel.tsx rename to src/main/pages/chat/ui/left/LeftPanel.tsx diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/src/main/pages/chat/ui/left/UnifiedChatsList.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx rename to src/main/pages/chat/ui/left/UnifiedChatsList.tsx diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/src/main/pages/chat/ui/left/UsernameSearch.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/UsernameSearch.tsx rename to src/main/pages/chat/ui/left/UsernameSearch.tsx diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/src/main/pages/chat/ui/left/settings/AccountPanel.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx rename to src/main/pages/chat/ui/left/settings/AccountPanel.tsx diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/src/main/pages/chat/ui/left/settings/ChangePasswordDialog.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx rename to src/main/pages/chat/ui/left/settings/ChangePasswordDialog.tsx diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/src/main/pages/chat/ui/left/settings/DevicesPanel.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx rename to src/main/pages/chat/ui/left/settings/DevicesPanel.tsx diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/src/main/pages/chat/ui/left/settings/NotificationsPanel.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx rename to src/main/pages/chat/ui/left/settings/NotificationsPanel.tsx diff --git a/frontend/src/pages/chat/ui/left/settings/SecurityPanel.tsx b/src/main/pages/chat/ui/left/settings/SecurityPanel.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/settings/SecurityPanel.tsx rename to src/main/pages/chat/ui/left/settings/SecurityPanel.tsx diff --git a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx b/src/main/pages/chat/ui/left/settings/SettingsDialog.tsx similarity index 100% rename from frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx rename to src/main/pages/chat/ui/left/settings/SettingsDialog.tsx diff --git a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx b/src/main/pages/chat/ui/right/ChatInputWrapper.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx rename to src/main/pages/chat/ui/right/ChatInputWrapper.tsx diff --git a/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx b/src/main/pages/chat/ui/right/ChatMainHeader.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/ChatMainHeader.tsx rename to src/main/pages/chat/ui/right/ChatMainHeader.tsx diff --git a/frontend/src/pages/chat/ui/right/ChatMessages.tsx b/src/main/pages/chat/ui/right/ChatMessages.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/ChatMessages.tsx rename to src/main/pages/chat/ui/right/ChatMessages.tsx diff --git a/frontend/src/pages/chat/ui/right/EmojiMenu.tsx b/src/main/pages/chat/ui/right/EmojiMenu.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/EmojiMenu.tsx rename to src/main/pages/chat/ui/right/EmojiMenu.tsx diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/src/main/pages/chat/ui/right/Message.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/Message.tsx rename to src/main/pages/chat/ui/right/Message.tsx diff --git a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx b/src/main/pages/chat/ui/right/MessageContextMenu.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/MessageContextMenu.tsx rename to src/main/pages/chat/ui/right/MessageContextMenu.tsx diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/src/main/pages/chat/ui/right/MessagePanelRenderer.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx rename to src/main/pages/chat/ui/right/MessagePanelRenderer.tsx diff --git a/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx b/src/main/pages/chat/ui/right/OnlineIndicator.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/OnlineIndicator.tsx rename to src/main/pages/chat/ui/right/OnlineIndicator.tsx diff --git a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx b/src/main/pages/chat/ui/right/OnlineStatus.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/OnlineStatus.tsx rename to src/main/pages/chat/ui/right/OnlineStatus.tsx diff --git a/frontend/src/pages/chat/ui/right/RightPanel.tsx b/src/main/pages/chat/ui/right/RightPanel.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/RightPanel.tsx rename to src/main/pages/chat/ui/right/RightPanel.tsx diff --git a/frontend/src/pages/chat/ui/right/TypingIndicator.tsx b/src/main/pages/chat/ui/right/TypingIndicator.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/TypingIndicator.tsx rename to src/main/pages/chat/ui/right/TypingIndicator.tsx diff --git a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx b/src/main/pages/chat/ui/right/calls/CallWindow.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/calls/CallWindow.tsx rename to src/main/pages/chat/ui/right/calls/CallWindow.tsx diff --git a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx b/src/main/pages/chat/ui/right/calls/MinimizedCallBar.tsx similarity index 100% rename from frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx rename to src/main/pages/chat/ui/right/calls/MinimizedCallBar.tsx diff --git a/frontend/src/pages/chat/ui/right/emojiData.ts b/src/main/pages/chat/ui/right/emojiData.ts similarity index 100% rename from frontend/src/pages/chat/ui/right/emojiData.ts rename to src/main/pages/chat/ui/right/emojiData.ts diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/src/main/pages/chat/ui/right/panels/DMPanel.ts similarity index 100% rename from frontend/src/pages/chat/ui/right/panels/DMPanel.ts rename to src/main/pages/chat/ui/right/panels/DMPanel.ts diff --git a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts b/src/main/pages/chat/ui/right/panels/MessagePanel.ts similarity index 100% rename from frontend/src/pages/chat/ui/right/panels/MessagePanel.ts rename to src/main/pages/chat/ui/right/panels/MessagePanel.ts diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/src/main/pages/chat/ui/right/panels/PublicChatPanel.ts similarity index 100% rename from frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts rename to src/main/pages/chat/ui/right/panels/PublicChatPanel.ts diff --git a/frontend/src/pages/download-app/DownloadAppPage.tsx b/src/main/pages/download-app/DownloadAppPage.tsx similarity index 100% rename from frontend/src/pages/download-app/DownloadAppPage.tsx rename to src/main/pages/download-app/DownloadAppPage.tsx diff --git a/frontend/src/pages/download-app/download-app.module.scss b/src/main/pages/download-app/download-app.module.scss similarity index 100% rename from frontend/src/pages/download-app/download-app.module.scss rename to src/main/pages/download-app/download-app.module.scss diff --git a/frontend/src/pages/home/DownloadDialog.tsx b/src/main/pages/home/DownloadDialog.tsx similarity index 100% rename from frontend/src/pages/home/DownloadDialog.tsx rename to src/main/pages/home/DownloadDialog.tsx diff --git a/frontend/src/pages/home/HomeFooter.tsx b/src/main/pages/home/HomeFooter.tsx similarity index 100% rename from frontend/src/pages/home/HomeFooter.tsx rename to src/main/pages/home/HomeFooter.tsx diff --git a/frontend/src/pages/home/HomeHeader.tsx b/src/main/pages/home/HomeHeader.tsx similarity index 100% rename from frontend/src/pages/home/HomeHeader.tsx rename to src/main/pages/home/HomeHeader.tsx diff --git a/frontend/src/pages/home/HomePage.tsx b/src/main/pages/home/HomePage.tsx similarity index 99% rename from frontend/src/pages/home/HomePage.tsx rename to src/main/pages/home/HomePage.tsx index 3d57772..b5b1ded 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/src/main/pages/home/HomePage.tsx @@ -13,6 +13,7 @@ import { HomeFooter } from "@/pages/home/HomeFooter"; import { SplitButton } from "@/core/components/SplitButton"; import { DownloadDialog } from "@/pages/home/DownloadDialog"; import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/pages/home/os"; +import { API_BASE_URL } from "@/core/config"; interface FeatureSectionProps { title: ReactNode; @@ -75,7 +76,7 @@ export default function HomePage() { setDialogOpen(true); const link = document.createElement("a"); - link.href = `/api/download/${os}`; + link.href = `${API_BASE_URL}/download/${os}`; link.download = ""; link.style.display = "none"; document.body.appendChild(link); diff --git a/frontend/src/pages/home/_home-shared.scss b/src/main/pages/home/_home-shared.scss similarity index 100% rename from frontend/src/pages/home/_home-shared.scss rename to src/main/pages/home/_home-shared.scss diff --git a/frontend/src/pages/home/download-dialog.module.scss b/src/main/pages/home/download-dialog.module.scss similarity index 100% rename from frontend/src/pages/home/download-dialog.module.scss rename to src/main/pages/home/download-dialog.module.scss diff --git a/frontend/src/pages/home/home-footer.module.scss b/src/main/pages/home/home-footer.module.scss similarity index 100% rename from frontend/src/pages/home/home-footer.module.scss rename to src/main/pages/home/home-footer.module.scss diff --git a/frontend/src/pages/home/home-header.module.scss b/src/main/pages/home/home-header.module.scss similarity index 100% rename from frontend/src/pages/home/home-header.module.scss rename to src/main/pages/home/home-header.module.scss diff --git a/frontend/src/pages/home/home.module.scss b/src/main/pages/home/home.module.scss similarity index 100% rename from frontend/src/pages/home/home.module.scss rename to src/main/pages/home/home.module.scss diff --git a/frontend/src/pages/home/homeLinks.tsx b/src/main/pages/home/homeLinks.tsx similarity index 100% rename from frontend/src/pages/home/homeLinks.tsx rename to src/main/pages/home/homeLinks.tsx diff --git a/frontend/src/pages/home/os.ts b/src/main/pages/home/os.ts similarity index 100% rename from frontend/src/pages/home/os.ts rename to src/main/pages/home/os.ts diff --git a/frontend/src/pages/legal/LegalPages.tsx b/src/main/pages/legal/LegalPages.tsx similarity index 100% rename from frontend/src/pages/legal/LegalPages.tsx rename to src/main/pages/legal/LegalPages.tsx diff --git a/frontend/src/pages/not-found/NotFoundPage.tsx b/src/main/pages/not-found/NotFoundPage.tsx similarity index 100% rename from frontend/src/pages/not-found/NotFoundPage.tsx rename to src/main/pages/not-found/NotFoundPage.tsx diff --git a/frontend/src/pages/not-found/not-found.module.scss b/src/main/pages/not-found/not-found.module.scss similarity index 100% rename from frontend/src/pages/not-found/not-found.module.scss rename to src/main/pages/not-found/not-found.module.scss diff --git a/frontend/src/state/call.ts b/src/main/state/call.ts similarity index 100% rename from frontend/src/state/call.ts rename to src/main/state/call.ts diff --git a/frontend/src/state/chat.ts b/src/main/state/chat.ts similarity index 100% rename from frontend/src/state/chat.ts rename to src/main/state/chat.ts diff --git a/frontend/src/state/presence.ts b/src/main/state/presence.ts similarity index 100% rename from frontend/src/state/presence.ts rename to src/main/state/presence.ts diff --git a/frontend/src/state/profile.ts b/src/main/state/profile.ts similarity index 100% rename from frontend/src/state/profile.ts rename to src/main/state/profile.ts diff --git a/frontend/src/state/types.ts b/src/main/state/types.ts similarity index 100% rename from frontend/src/state/types.ts rename to src/main/state/types.ts diff --git a/frontend/src/state/user.ts b/src/main/state/user.ts similarity index 100% rename from frontend/src/state/user.ts rename to src/main/state/user.ts diff --git a/frontend/src/utils/crypto/fromchatInit.ts b/src/main/utils/crypto/fromchatInit.ts similarity index 100% rename from frontend/src/utils/crypto/fromchatInit.ts rename to src/main/utils/crypto/fromchatInit.ts diff --git a/frontend/src/utils/material.tsx b/src/main/utils/material.tsx similarity index 100% rename from frontend/src/utils/material.tsx rename to src/main/utils/material.tsx diff --git a/frontend/src/utils/notification.ts b/src/main/utils/notification.ts similarity index 100% rename from frontend/src/utils/notification.ts rename to src/main/utils/notification.ts diff --git a/frontend/src/utils/utils.ts b/src/main/utils/utils.ts similarity index 100% rename from frontend/src/utils/utils.ts rename to src/main/utils/utils.ts diff --git a/frontend/src/vite-env.d.ts b/src/main/vite-env.d.ts similarity index 100% rename from frontend/src/vite-env.d.ts rename to src/main/vite-env.d.ts diff --git a/frontend/packages/fromchat-protocol/src/backup/backup.ts b/src/protocol/backup/backup.ts similarity index 100% rename from frontend/packages/fromchat-protocol/src/backup/backup.ts rename to src/protocol/backup/backup.ts diff --git a/frontend/packages/fromchat-protocol/src/crypto/asymmetric.ts b/src/protocol/crypto/asymmetric.ts similarity index 100% rename from frontend/packages/fromchat-protocol/src/crypto/asymmetric.ts rename to src/protocol/crypto/asymmetric.ts diff --git a/frontend/packages/fromchat-protocol/src/crypto/index.ts b/src/protocol/crypto/index.ts similarity index 100% rename from frontend/packages/fromchat-protocol/src/crypto/index.ts rename to src/protocol/crypto/index.ts diff --git a/frontend/packages/fromchat-protocol/src/crypto/kdf.ts b/src/protocol/crypto/kdf.ts similarity index 100% rename from frontend/packages/fromchat-protocol/src/crypto/kdf.ts rename to src/protocol/crypto/kdf.ts diff --git a/frontend/packages/fromchat-protocol/src/crypto/symmetric.ts b/src/protocol/crypto/symmetric.ts similarity index 100% rename from frontend/packages/fromchat-protocol/src/crypto/symmetric.ts rename to src/protocol/crypto/symmetric.ts diff --git a/frontend/packages/fromchat-protocol/src/index.ts b/src/protocol/index.ts similarity index 100% rename from frontend/packages/fromchat-protocol/src/index.ts rename to src/protocol/index.ts diff --git a/frontend/packages/fromchat-protocol/src/protocol/FromChatProtocol.ts b/src/protocol/protocol/FromChatProtocol.ts similarity index 100% rename from frontend/packages/fromchat-protocol/src/protocol/FromChatProtocol.ts rename to src/protocol/protocol/FromChatProtocol.ts diff --git a/frontend/packages/fromchat-protocol/src/protocol/types.ts b/src/protocol/protocol/types.ts similarity index 100% rename from frontend/packages/fromchat-protocol/src/protocol/types.ts rename to src/protocol/protocol/types.ts diff --git a/frontend/tsconfig.json b/tsconfig.json similarity index 77% rename from frontend/tsconfig.json rename to tsconfig.json index 9efcafb..4ac21a3 100644 --- a/frontend/tsconfig.json +++ b/tsconfig.json @@ -14,12 +14,11 @@ "noEmit": true, /* Path mapping */ - "baseUrl": ".", - "paths": { - "@/*": ["src/*"], - "@fromchat/protocol": ["./packages/fromchat-protocol/src"] - }, - + "baseUrl": ".", + "paths": { + "@/*": ["src/main/*"], + "@fromchat/protocol": ["src/protocol/index.ts"] + }, /* Linting */ "strict": true, "erasableSyntaxOnly": true, @@ -32,6 +31,6 @@ "jsx": "react-jsx", "jsxImportSource": "react" }, - "include": ["src", "electron.d.ts", "packages/fromchat-protocol/src"], + "include": ["src", "src/electron/electron.d.ts", "src/protocol"], "exclude": ["**/__*/**", "__*"] } \ No newline at end of file diff --git a/frontend/vite.config.ts b/vite.config.ts similarity index 86% rename from frontend/vite.config.ts rename to vite.config.ts index f304d9d..9b77086 100644 --- a/frontend/vite.config.ts +++ b/vite.config.ts @@ -46,7 +46,7 @@ if (process.env.VITE_ELECTRON) { plugins.push( electron({ main: { - entry: "electron/main.ts", + entry: "src/electron/main.ts", vite: { build: { outDir: `${outDir}/core` @@ -54,7 +54,7 @@ if (process.env.VITE_ELECTRON) { } }, preload: { - input: "frontend/electron/preload.ts", + input: "src/electron/preload.ts", vite: { build: { outDir: `${outDir}/core` @@ -66,12 +66,18 @@ if (process.env.VITE_ELECTRON) { ); } +const apiProxyTarget = process.env.VITE_API_BASE_URL?.startsWith("http") + ? process.env.VITE_API_BASE_URL + : "http://127.0.0.1:8300"; + export default defineConfig({ + root: path.resolve(__dirname, "src"), + envDir: currentDir, plugins: plugins, resolve: { alias: { - "@": path.resolve(__dirname, "./src"), - "@fromchat/protocol": path.resolve(__dirname, "./packages/fromchat-protocol/src/index.ts") + "@": path.resolve(__dirname, "./src/main"), + "@fromchat/protocol": path.resolve(__dirname, "./src/protocol/index.ts") } }, // Dev entry on 8301: browser uses same-origin `/api` (HTTP + WebSocket, e.g. `/api/chat/ws`). @@ -81,7 +87,7 @@ export default defineConfig({ strictPort: true, proxy: { "/api": { - target: "http://127.0.0.1:8300", + target: apiProxyTarget, changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ""), ws: true,