Backend moved to a separate repository

This commit is contained in:
2026-07-14 09:59:03 +03:00
Unverified
parent 1cc5294d52
commit 5f9d9a78d3
340 changed files with 198 additions and 21988 deletions
+3
View File
@@ -0,0 +1,3 @@
# HTTP API host.
# Example for local backend: http://localhost:8300
VITE_API_BASE_URL=http://localhost:8300
+8
View File
@@ -1,3 +1,11 @@
{
"npm.autoDetect": "off",
"files.exclude": {
".husky": true,
"build": true
}
}
{
"files.exclude": {
"**/__pycache__": true,
+13 -65
View File
@@ -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"
}
}
]
+35
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
# Backend package initializer
__all__ = []
-417
View File
@@ -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()
-147
View File
@@ -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
-78
View File
@@ -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()
-28
View File
@@ -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"}
-23
View File
@@ -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
-22
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
# Services package initializer
__all__ = []
@@ -1 +0,0 @@
# File storage service module
-935
View File
@@ -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
View File
@@ -1 +0,0 @@
# Main service module
-24
View File
@@ -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")
-154
View File
@@ -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
-57
View File
@@ -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,
}
-139
View File
@@ -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()
-288
View File
@@ -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
-89
View File
@@ -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")
-349
View File
@@ -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)
-731
View File
@@ -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()
-421
View File
@@ -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)
-63
View File
@@ -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
-374
View File
@@ -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()
-802
View File
@@ -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)
-92
View File
@@ -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"}
-381
View File
@@ -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
File diff suppressed because it is too large Load Diff
-58
View File
@@ -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")
-100
View File
@@ -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)
File diff suppressed because it is too large Load Diff
-114
View File
@@ -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"}
-634
View File
@@ -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,
)
-46
View File
@@ -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))
-40
View File
@@ -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")
-89
View File
@@ -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
-476
View File
@@ -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)
-694
View File
@@ -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
"": "а",
"": "а",
"": "с",
"": "с",
"": "е",
"": "е",
"": "ф",
"": "ф",
"": "г",
"": "г",
"": "и",
"": "и",
"": "м",
"": "м",
"": "н",
"": "н",
"": "о",
"": "о",
"": "п",
"": "п",
"": "с",
"": "с",
"": "т",
"": "т",
"": "у",
"": "у",
"": "в",
"": "в",
"": "х",
"": "х",
"": "у",
"": "у",
"": "з", # Full-width 'z' to Cyrillic 'з'
"": "з",
# 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
-830
View File
@@ -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()
-147
View File
@@ -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, ""
-72
View File
@@ -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)
-68
View File
@@ -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)
О шифровании договоримся, если надо.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 B

@@ -1,5 +0,0 @@
{
"id": "general",
"title": "Общий чат",
"bio": "Общаемся со всеми пользователями FromChat!"
}
-71
View File
@@ -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
-26
View File
@@ -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"]
-638
View File
@@ -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())
-92
View File
@@ -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
View File
@@ -1 +0,0 @@
# Messaging service module
-429
View File
@@ -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
-506
View File
@@ -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)
-343
View File
@@ -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
View File
@@ -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
-75
View File
@@ -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)
+12
View File
@@ -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
Binary file not shown.
View File
-34
View File
@@ -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
-111
View File
@@ -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"]
-29
View File
@@ -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
-10
View File
@@ -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"]
-101
View File
@@ -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.
-120
View File
@@ -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
}
}
}
-32
View File
@@ -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'
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-238
View File
@@ -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
-33
View File
@@ -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
-57
View File
@@ -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"]
-20
View File
@@ -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"
}
}
-57
View File
@@ -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})`);
});
-23
View File
@@ -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"
]
}
-26
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More