Backend moved to a separate repository
@@ -0,0 +1,3 @@
|
||||
# HTTP API host.
|
||||
# Example for local backend: http://localhost:8300
|
||||
VITE_API_BASE_URL=http://localhost:8300
|
||||
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"npm.autoDetect": "off",
|
||||
"files.exclude": {
|
||||
".husky": true,
|
||||
"build": true
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/__pycache__": true,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
# Backend package initializer
|
||||
__all__ = []
|
||||
|
||||
@@ -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 <user_id|username>")
|
||||
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 <user_id|username>")
|
||||
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 <word or phrase> [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_id|username>")
|
||||
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_id|username>")
|
||||
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 <word or phrase> [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_id|username>")
|
||||
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_id|username>")
|
||||
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 <ip_address>")
|
||||
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 <user>": "Suspend account (alias: ban).",
|
||||
"unsuspend <user>": "Unsuspend account (alias: unban).",
|
||||
"delete <user>": "Permanently delete the user account.",
|
||||
"verify <user>": "Mark user as verified.",
|
||||
"unverify <user>": "Remove verification flag.",
|
||||
"block-word <words>": "Add words/phrases to chat filter.",
|
||||
"unblock-word <words>": "Remove words/phrases from filter.",
|
||||
"blocklist": "Show current blocklist.",
|
||||
"unblock-ip <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 <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()
|
||||
|
||||
@@ -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 <script_location>/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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
# Services package initializer
|
||||
__all__ = []
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# File storage service module
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
# Main service module
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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("<H", data[6:8])[0]
|
||||
height = struct.unpack("<H", data[8:10])[0]
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return width, height
|
||||
|
||||
|
||||
def _webp_dimensions(data: bytes) -> 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("<H", data[26:28])[0] & 0x3FFF
|
||||
height = struct.unpack("<H", data[28:30])[0] & 0x3FFF
|
||||
if width > 0 and height > 0:
|
||||
return width, height
|
||||
if chunk == b"VP8L" and len(data) >= 25:
|
||||
bits = struct.unpack("<I", data[21:25])[0]
|
||||
width = (bits & 0x3FFF) + 1
|
||||
height = ((bits >> 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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
# Package marker for security utilities
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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, ""
|
||||
@@ -1,72 +0,0 @@
|
||||
|
||||
<!-- fc:shape=Circle icon=privacy -->
|
||||
## Общее
|
||||
|
||||
Здесь политика конфиденциальности FromChat. Я знаю, что 99% ее даже читать не будут, сделал только для того, чтобы ко мне не было вопросов и чтобы те, кому реально интерессно знали, что происходит с данными.
|
||||
|
||||
Эта политика действует только на официальном сервере [fromchat.ru](https://fromchat.ru). На других серверах политика ставится их админами.
|
||||
|
||||
Вы можете свободно использовать этот текст в любых целях без указания авторства.
|
||||
|
||||
Текст может меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если что-то изменится, я напишу об этом в Telegram-канале.
|
||||
|
||||
<!-- fc:shape=Cookie4Sided icon=storage -->
|
||||
## Ваши данные
|
||||
|
||||
### Какие данные собираются?
|
||||
|
||||
- Логин, имя и прочие данные профиля — без них мессенджер не может существовать. Эти данные видны всем, кто общается с вами.
|
||||
- Пароль — на сервере хранится только односторонний хеш, который используется для проверки. Сервер никогда не видит пароль открытым текстом.
|
||||
- Сообщения в общем чате — они публичны. Любой пользователь на сервере может их увидеть. Они хранятся открытым текстом в базе данных.
|
||||
- Личные сообщения — вкратце: они хранятся в зашифрованном виде, но сервер во время обработки кратко видит открытый текст сообщения. Они могут быть переданы по официальному запросу уполномоченных органов. Если интересно, как именно шифруются сообщения — читайте ниже.
|
||||
- Статус «в сети» и время последней активности — чтобы собеседник видел, когда вы были в сети. К сожалению, скрыть его пока нельзя.
|
||||
- Информация об устройствах (тип, ОС, браузер) — видна только вам, нужно для того, чтобы вы легко распознали взлом и его нейтрализовали.
|
||||
- Звонки — идут в зашифрованном виде через WebRTC-сервер, могут быть записаны в целях соблюдения законодательства и предоставлены уполномоченным органам по запросу.
|
||||
|
||||
<!-- fc:shape=Cookie7Sided icon=chat -->
|
||||
## Больше про личные сообщения
|
||||
|
||||
Если вы очень беспокоетесь за безопасность ваших сообщений, сразу говорю — защита несовершенна и любую защиту можно взломать. Но я постарался сделать доступ к вашим перепискам максимально сложным для хакеров.
|
||||
|
||||
### Весь путь сообщения от вас к собеседнику
|
||||
|
||||
Ваше устройство:
|
||||
1. Вы отправляете сообщение.
|
||||
2. Приложение (клиент) запрашивает открытый ключ у сервера обработки сообщений.
|
||||
3. Приложение скачивает ваш открытый ключ и открытый ключ вашего собеседника.
|
||||
3. Сообщение шифруется этим открытым ключем и отсылается на сервер вместе с открытыми ключами, полученными в предыдущем шаге.
|
||||
|
||||
Сервер:
|
||||
1. Сервер получает ваш запрос на отправку сообщения и пересылает его в изолированный контейнер для обработки сообщений.
|
||||
2. Контейнер расшифровывает ваше сообщение своим закрытым ключем и хранит его в оперативной памяти.
|
||||
3. Создается строка из случайных чисел (MEK).
|
||||
4. Текст вашего сообщения шифруется алгоритмом AES-256, MEK используется как ключ.
|
||||
5. MEK шифруется три раза с помощью вашего открытого ключа и открытых ключей собеседника и официальных запросов.
|
||||
6. Открытый текст вашего сообщения полностью удаляется из оперативной памяти.
|
||||
7. Контейнер возвращает главному серверу зашифрованное сообщение вместе с тремя экземплярами MEK.
|
||||
8. Сообщение записывается в базу данных.
|
||||
|
||||
Устройство собеседника:
|
||||
1. Оно получает ваше сообщение и расшифровывает MEK закрытым ключем, сохраненном в аккаунте собеседника в зашифрованном виде, где пароль от аккаунта используется как ключ.
|
||||
2. Зашифрованный текст сообщения расшифровывается с MEK как ключ.
|
||||
3. Собеседник прочитал ваше сообщение.
|
||||
|
||||
<!-- fc:shape=Cookie9Sided icon=shield -->
|
||||
## Реклама и продажа данных
|
||||
|
||||
Никакой рекламы с моей стороны и продажи ваших данных нет и никогда не будет. Мне нет смысла злить вас ради собственной выгоды.
|
||||
|
||||
На данный момент приложение не собирает никакой аналитики.
|
||||
|
||||
В каналах теоритически может быть реклама от их админов. Я в ней не виноват и контролировать не могу.
|
||||
|
||||
<!-- fc:shape=Cookie4Sided icon=delete -->
|
||||
## Удаление данных
|
||||
|
||||
Если вы хотите удалить сообщение, удерживайте и нажмите "Удалить". Тогда сообщение пропадет из публичного доступа. Зашифрованная копия сообщения останется в целях соблюдения законодательства на 6 месяцев.
|
||||
|
||||
Если вам нужно удалить ваши данные профиля из публичного доступа, вы можете удалить аккаунт в настройках приложения.
|
||||
|
||||
В таком случае все сообщения, которые вы отправили будут анонимизированы, но не удалены.
|
||||
|
||||
Если вам нужно удалить ВСЕ, что связано с вашим профилем из публичного доступа, напишите в Telegram: [Сообщения @fromchat_ch](https://t.me/fromchat_ch?direct=true)
|
||||
@@ -1,68 +0,0 @@
|
||||
|
||||
|
||||
<!-- fc:shape=Circle icon=terms -->
|
||||
## Общее
|
||||
|
||||
**FromChat** — 100% бесплатный и открытый мессенджер. Я создал эти правила, чтобы вы точно знали, что можно, а что нельзя.
|
||||
|
||||
Эти правила действуют только на официальном сервере [fromchat.ru](https://fromchat.ru). Админы других серверов устанавливают свои правила.
|
||||
|
||||
Вы можете свободно использовать этот текст в любых целях без указания авторства.
|
||||
|
||||
Сервис предоставляется как есть, перебои и сбои будут гарантированно из-за слабенькой малинки.
|
||||
|
||||
Правила могут меняться, механизма уведомлений об этом пока нет внутри мессенджера. Если правила изменятся, я напишу об этом в Telegram-канале.
|
||||
|
||||
<!-- fc:shape=Cookie4Sided icon=person -->
|
||||
## Ваш аккаунт
|
||||
|
||||
Условия вступают в силу, когда вы создаете аккаунт. Также советую прочитать [политику конфиденциальности](/api/static/PRIVACY.md), поверьте, это очень важно.
|
||||
|
||||
Вы полностью отвечаете за все, что происходит в вашем аккаунте. Если поставите пароль `12345` — вас точно взломают :)
|
||||
|
||||
Если вы нарушите правила, я вас заблокирую. В таком случае вы сможете только читать сообщения, а отправка и реакции будут заблокированы. Если считаете, что я не прав — пишите в Telegram: [@denis0001_dev](https://t.me/denis0001_dev).
|
||||
|
||||
<!-- fc:shape=Cookie7Sided icon=terms -->
|
||||
## Правила
|
||||
|
||||
### Для общего чата
|
||||
Общий чат — это площадка для общения между всеми пользователями на этом сервере. По очевидным причинам, тут запрещено:
|
||||
- Материться, использовать 18+ и другие неприличные слова;
|
||||
- Разговаривать на тему политики, религии, нелегальных действий и неприличия;
|
||||
- Оскорблять других;
|
||||
- Сливать персональные данные (адрес, номер, ФИО и прочее);
|
||||
- Рекламировать любые продукты, сервисы и прочее без моего согласия;
|
||||
- Популяризировать VPN и другие способы обхода блокировок (это закон, не мое личное правило);
|
||||
- Угрожать в любом виде;
|
||||
- Спамить или засорять чат.
|
||||
|
||||
В целях защиты от спама количество сообщений в минуту ограничено и нельзя отправлять слишком много сообщений с одинаковым текстом. При нарушении вы будете автоматически заблокированы. Алгоритм очень примитивный, поэтому ошибки будут. Если это была ошибка, я вас разблокирую.
|
||||
|
||||
### Для личных сообщений
|
||||
|
||||
За личными сообщениями я не шпионю, но могу предоставить по официальному запросу. Поэтому я пока не могу выявлять там нарушения. Я скоро сделаю механизм жалоб.
|
||||
|
||||
В личке правил гораздо меньше. Мне лень писать снова длинный список, поэтому просто прошу вас, не занимайтесь нелегальными вещами и не спамьте. В личке можно обсуждать все остальное и материться.
|
||||
|
||||
### Глобальные правила
|
||||
|
||||
Пожалуйста, не используйте мессенджер для спама и не устраивайте DDoS или любые другие атаки.
|
||||
|
||||
<!-- fc:shape=Cookie9Sided icon=phone -->
|
||||
## Контакты
|
||||
|
||||
### Если у вас возникли любые вопросы, пишите сюда:
|
||||
|
||||
Почта: [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)
|
||||
|
||||
О шифровании договоримся, если надо.
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 794 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1018 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 980 B |
|
Before Width: | Height: | Size: 944 B |
|
Before Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 856 B |
|
Before Width: | Height: | Size: 516 B |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"id": "general",
|
||||
"title": "Общий чат",
|
||||
"bio": "Общаемся со всеми пользователями FromChat!"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Messaging service module
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Shared code across microservices
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<typeof createProxyMiddleware> & {
|
||||
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})`);
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||