Restructure backend into microservices, add envelope encryption, DM files, and message editing

This commit is contained in:
2026-01-10 14:59:31 +03:00
Unverified
parent 1f706eaa34
commit fd4c00057c
74 changed files with 6647 additions and 820 deletions
+1
View File
@@ -0,0 +1 @@
# Main service module
+24
View File
@@ -0,0 +1,24 @@
import os
# Database is always in backend/data/ relative to project root
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
@@ -0,0 +1,154 @@
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
+120
View File
@@ -0,0 +1,120 @@
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()
# Зависимость для получения текущего пользователя
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> 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:
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
@@ -0,0 +1,45 @@
#!/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()
+89
View File
@@ -0,0 +1,89 @@
import logging
import os
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from threading import RLock
from typing import Dict
LOGS_DIR = Path(__file__).resolve().parent / "logs"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
class HumanReadableFileHandler(RotatingFileHandler):
def __init__(self, filename: Path, level: int) -> None:
super().__init__(filename, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", delay=True)
self.level = level
self._lock = RLock()
self._last_date: str | None = None
self._previous_entry: str | None = None
def emit(self, record: logging.LogRecord) -> None:
try:
message = record.getMessage().strip()
if not message:
return
timestamp = datetime.fromtimestamp(record.created)
date_str = timestamp.strftime("%d.%m.%Y")
time_str = timestamp.strftime("%H:%M:%S")
lines = [line.rstrip() for line in message.splitlines() if line.strip()]
with self._lock:
if self.stream is None:
self.stream = self._open()
if self._last_date != date_str:
if self._last_date is not None:
self.stream.write("\n")
separator = "-" * 11
self.stream.write(f"\n\n{separator}\n{date_str}\n{separator}\n\n")
self._last_date = date_str
entry_lines: list[str] = []
if lines:
entry_lines.append(f"{time_str} {lines[0]}")
for line in lines[1:]:
if line.startswith("|"):
entry_lines.append(f" {line}")
else:
entry_lines.append(f"{line}")
else:
entry_lines.append(time_str)
entry_text = "\n".join(entry_lines)
if entry_text == self._previous_entry:
return
self.stream.write(entry_text + "\n")
self._previous_entry = entry_text
self.flush()
except Exception:
self.handleError(record)
_HANDLED_FILES: Dict[str, Path] = {}
def _configure_logger(name: str, filename: str, level: int = logging.INFO) -> logging.Logger:
logger = logging.getLogger(name)
target_path = LOGS_DIR / filename
if _HANDLED_FILES.get(name) == target_path:
return logger
logger.handlers.clear()
handler = HumanReadableFileHandler(target_path, level)
handler.setLevel(level)
logger.addHandler(handler)
logger.setLevel(level)
logger.propagate = False
_HANDLED_FILES[name] = target_path
return logger
security_logger = _configure_logger("security", "security.log")
public_chat_logger = _configure_logger("public_chat", "public-chat.log")
dm_logger = _configure_logger("dm", "dm.log")
access_logger = _configure_logger("access", "access.log")
+269
View File
@@ -0,0 +1,269 @@
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
# Import from same directory
from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging
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):
# 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
yield
# Shutdown - cancel cleanup task if it exists
if cleanup_task:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
# Initialize FastAPI
app = FastAPI(title="FromChat", lifespan=lifespan)
# 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:
# If mounting fails, continue without blocking startup; log for debugging
logger.warning(f"Failed to mount internal services for development: {e}")
@app.middleware("http")
async def access_logging_middleware(request: Request, call_next):
# Log incoming request and Authorization header presence for debugging auth issues
try:
auth_header = request.headers.get("authorization")
if auth_header:
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
else:
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
except Exception:
pass
start = time.perf_counter()
try:
response = await call_next(request)
except Exception as exc:
duration = time.perf_counter() - start
user = getattr(getattr(request, "state", None), "current_user", None)
log_access(
"http_error",
method=request.method,
path=request.url.path,
status="error",
user=getattr(user, "username", None),
ip=get_client_ip(request),
duration=f"{duration:.3f}s",
error=str(exc),
)
raise
else:
duration = time.perf_counter() - start
user = getattr(getattr(request, "state", None), "current_user", None)
log_access(
"http_request",
method=request.method,
path=request.url.path,
status=response.status_code,
user=getattr(user, "username", None),
ip=get_client_ip(request),
duration=f"{duration:.3f}s",
)
return response
# 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
app.add_middleware(
CORSMiddleware,
allow_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",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routes
app.include_router(account.router)
app.include_router(envelope_messaging.router)
app.include_router(messaging.router)
app.include_router(profile.router)
app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(devices.router, prefix="/devices")
app.include_router(moderation.router)
app.include_router(download.router)
app.include_router(keys.router)
@app.get("/key/public")
async def key_public_proxy():
"""
Proxy endpoint for messaging public key. In dev this calls the in-process function,
in production it will proxy to the external messaging service via the keys helper.
"""
return await keys.get_public_key()
@app.post("/key/invalidate")
async def key_invalidate_proxy():
"""
Proxy endpoint to invalidate messaging ephemeral key.
"""
return await keys.invalidate_key()
+692
View File
@@ -0,0 +1,692 @@
"""
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 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
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.")
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.")
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.")
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")
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")
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}")
# 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)
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
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."""
type_name = column.type.__class__.__name__
if type_name == 'String':
return f"VARCHAR({column.type.length})"
elif type_name == 'Integer':
return "INTEGER"
elif type_name == 'Text':
return "TEXT"
elif type_name == 'Boolean':
return "BOOLEAN"
elif type_name == 'DateTime':
return "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()
+389
View File
@@ -0,0 +1,389 @@
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)
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'),)
# 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
class ChangePasswordRequest(BaseModel):
currentPasswordDerived: str
newPasswordDerived: str
logoutAllExceptCurrent: bool = False
class SendMessageRequest(BaseModel):
content: str
reply_to_id: int | 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_sender_wrapped_mek_b64: str
previous_recipient_wrapped_mek_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
suspended: bool
suspension_reason: str | None
deleted: bool
class Config:
from_attributes = True
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_sender_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for sender before edit
previous_recipient_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for recipient before edit
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)
+243
View File
@@ -0,0 +1,243 @@
import json
import logging
import os
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
import base64
logger = logging.getLogger("uvicorn.error")
class PushNotificationService:
def __init__(self):
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
# Firebase Admin initialization (modern API). Only FIREBASE_CERT env is supported.
self.firebase_initialized = False
try:
firebase_cert = os.getenv("FIREBASE_CERT")
if not firebase_cert:
raise RuntimeError("FIREBASE_CERT env variable is required for Firebase Admin SDK initialization")
# Support raw JSON or base64-encoded JSON in FIREBASE_CERT
decoded = base64.b64decode(firebase_cert).decode("utf-8")
sa_dict = json.loads(decoded)
cred = firebase_credentials.Certificate(sa_dict)
firebase_admin.initialize_app(cred)
self.firebase_initialized = True
logger.info("Firebase Admin SDK initialized for push sending (FIREBASE_CERT)")
except Exception as e:
logger.error(f"Failed to initialize Firebase Admin SDK from FIREBASE_CERT: {e}")
raise
if (not self.vapid_public_key) or (not self.vapid_private_key):
raise ValueError("VAPID public or private key is None")
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"""
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)
for user in users:
# Check if user has push subscription before trying to send
# Try all FCM tokens first (Android). If none or all fail, fall back to web push subscription.
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == user.id).all()
payload_data = {
"type": "public_message",
"message_id": message.id,
"sender_id": message.user_id,
"sender_username": message.author.username
}
title = f"{message.author.username}"
body = message.content[:100] + ("..." if len(message.content) > 100 else "")
if fcm_rows and self.firebase_initialized:
for fcm in fcm_rows:
try:
self._send_fcm_to_token(fcm.token, title, body, payload_data)
except Exception as e:
logger.error(f"Failed to send FCM to user {user.id} token {fcm.token}: {e}")
# Check if this is a permanent failure and clean up the token
self._cleanup_failed_fcm_token(db, fcm, str(e))
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
if subscription:
await self._send_notification_to_user(
db, user.id, title, body, message.author.profile_picture, payload_data
)
except Exception as e:
logger.error(f"Failed to send public message notifications: {e}")
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
"""Send push notification for a new DM"""
try:
title = f"{sender.username}"
body = "New direct message"
payload_data = {
"type": "dm",
"dm_id": dm_envelope.id,
"sender_id": sender.id,
"sender_username": sender.username
}
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == dm_envelope.recipient_id).all()
if fcm_rows and self.firebase_initialized:
for fcm in fcm_rows:
try:
self._send_fcm_to_token(fcm.token, title, body, payload_data)
except Exception as e:
logger.error(f"Failed to send FCM to user {dm_envelope.recipient_id} token {fcm.token}: {e}")
# Check if this is a permanent failure and clean up the token
self._cleanup_failed_fcm_token(db, fcm, str(e))
await self._send_notification_to_user(
db, dm_envelope.recipient_id, 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:
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
)
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):
"""Send an FCM data-only push to a single device token using Firebase Admin SDK.
Notification display is handled by the app, not FCM."""
if not self.firebase_initialized:
raise RuntimeError("Firebase Admin SDK not initialized (FIREBASE_CERT required)")
try:
# Send only data payload - let the app handle notification display
# This prevents FCM from auto-showing notifications
msg = firebase_messaging.Message(
token=token,
data={
"title": title,
"body": body,
**{k: str(v) for k, v in (data or {}).items()}
},
android=firebase_messaging.AndroidConfig(priority="high"),
apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"})
)
resp = firebase_messaging.send(msg)
return resp
except Exception as e:
logger.error(f"Firebase Admin send failed for token {token}: {e}")
raise
def _cleanup_failed_fcm_token(self, db: Session, fcm_token_entry, error_message: str):
"""Clean up FCM tokens that have permanent failures"""
try:
# Check for permanent failure indicators in the error message
permanent_errors = [
"unregistered", "invalidregistration", "notregistered",
"sender_id_mismatch", "invalid_argument"
]
error_lower = error_message.lower()
is_permanent = any(permanent_error in error_lower for permanent_error in permanent_errors)
if is_permanent:
logger.info(f"Removing permanently failed FCM token for user {fcm_token_entry.user_id}: {fcm_token_entry.token}")
db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete()
db.commit()
else:
logger.debug(f"Temporary FCM failure for token {fcm_token_entry.token}, keeping token: {error_message}")
except Exception as e:
logger.error(f"Failed to cleanup FCM token {fcm_token_entry.token}: {e}")
try:
db.rollback()
except Exception:
pass
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
"""Unsubscribe a user from push notifications"""
try:
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()
+578
View File
@@ -0,0 +1,578 @@
from datetime import datetime
from collections import defaultdict, deque
import time
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
import uuid
from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from ..constants import OWNER_USERNAME
from ..dependencies import get_current_user, get_db
from ..models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
from ..utils import create_token, get_password_hash, verify_password, get_client_ip
from ..validation import is_valid_password, is_valid_username, is_valid_display_name
import os
from ..security.audit import log_security
from ..security.profanity import contains_profanity
from ..security.rate_limit import rate_limit_per_ip
router = APIRouter()
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
_FAILED_ATTEMPT_THRESHOLD = 5
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
def _record_failed_login(identifier: str) -> bool:
now = time.time()
attempts = _failed_login_attempts[identifier]
attempts.append(now)
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
attempts.popleft()
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
def _reset_failed_logins(identifier: str) -> None:
_failed_login_attempts.pop(identifier, None)
def _is_admin(user: User) -> bool:
return user.id == 1
def convert_user(user: User) -> dict:
return {
"id": user.id,
"created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(),
"online": user.online,
"username": user.username,
"display_name": user.display_name,
"profile_picture": user.profile_picture,
"bio": user.bio,
"admin": _is_admin(user),
"verified": user.verified,
"suspended": user.suspended or False,
"suspension_reason": user.suspension_reason,
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
}
@router.get("/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.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=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)
user.online = True
user.last_seen = datetime.now()
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)
}
@router.post("/register")
@rate_limit_per_ip("3/hour")
def register(request: Request, register_request: RegisterRequest, db: Session = Depends(get_db)):
username = register_request.username.strip()
display_name = register_request.display_name.strip()
password = register_request.password.strip()
confirm_password = register_request.confirm_password.strip()
client_ip = get_client_ip(request)
raw_ua = request.headers.get("user-agent")
# Determine if owner already exists
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
# 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="Это имя пользователя уже занято"
)
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(
username=username,
display_name=display_name,
password_hash=hashed_password,
online=True,
last_seen=datetime.now(),
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,
)
return {
"status": "success",
"message": "Регистрация прошла успешно",
"token": token,
"user": convert_user(new_user)
}
@router.get("/crypto/public-key")
def get_public_key(current_user: User = Depends(get_current_user), 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), 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), 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), 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}
@router.get("/logout")
def logout(
http: Request,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Revoke current session
from utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials)
if payload and payload.get("session_id"):
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id == payload["session_id"],
).update({DeviceSession.revoked: True})
current_user.online = False
current_user.last_seen = datetime.now()
db.commit()
client_ip = get_client_ip(http)
log_security(
"logout",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
session_id=payload.get("session_id") if payload else None,
)
return {
"status": "success",
"message": "Logged out successfully"
}
@router.post("/change-password")
@rate_limit_per_ip("5/hour")
def change_password(
request: Request,
password_request: ChangePasswordRequest,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Verify current derived password against stored hash
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
# Update password hash to hash of new derived password
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip())
db.commit()
# Optionally revoke all other sessions, keeping the current one
if password_request.logoutAllExceptCurrent:
from utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
current_session_id = payload.get("session_id")
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id != current_session_id,
).update({DeviceSession.revoked: True})
db.commit()
client_ip = get_client_ip(request)
log_security(
"password_changed",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
logout_others=bool(password_request.logoutAllExceptCurrent),
)
return {"status": "success"}
@router.get("/users")
@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse
def list_users(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
users = db.query(User).order_by(User.username.asc()).all()
return {
"users": [
convert_user(u) 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), 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), 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) 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
# Mark user as deleted and clear sensitive data
user.deleted = True
user.display_name = f"Deleted User #{user_id}"
user.bio = None
user.password_hash = ""
user.username = f"deleted_{user_id}"
user.profile_picture = None
user.last_seen = None # Clear last seen timestamp
user.created_at = None # Clear member since timestamp
# 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})
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
@router.post("/delete")
async def delete_account(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Delete the current user's own account - preserves messages/DMs/reactions/files
"""
# Prevent admin/owner account self-deletion
if _is_admin(current_user):
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
await _delete_user_data(current_user, db)
log_security(
"self_delete_account",
severity="warning",
user_id=current_user.id,
username=current_user.username,
)
return {
"status": "success",
"message": "Account deleted successfully"
}
+92
View File
@@ -0,0 +1,92 @@
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
@@ -0,0 +1,381 @@
"""
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
@@ -0,0 +1,906 @@
"""
Envelope encryption API endpoints for private messaging.
Handles:
- Sending encrypted private messages (proxies to messaging service)
- Retrieving encrypted conversations
- Decrypting messages with proper MEK unwrapping
- Managing transport public key distribution
"""
import logging
import json
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from ..db import get_db
from ..models import User, DMEnvelope, DMFile, DMEditHistory, EditMessageRequest
from ..dependencies import get_current_user
from ..security.audit import log_security
from ..service_calls import (
get_messaging_transport_public_key,
get_compliance_public_key,
process_message_with_files_in_messaging_service,
store_encrypted_file,
)
from .messaging import messagingManager, convert_dm_envelope
logger = logging.getLogger("uvicorn.error")
router = APIRouter(prefix="/dm", tags=["Direct Messages"])
# ============================================================================
# Pydantic Models
# ============================================================================
class FileModel(BaseModel):
encrypted_file_data_b64: str
filename: str
file_size: int
class SendEncryptedMessageRequest(BaseModel):
"""Request to send an encrypted message."""
recipient_id: int
client_public_key_b64: str
transport_nonce_b64: str
transport_ciphertext_b64: str
sender_public_key_b64: str
recipient_public_key_b64: str
reply_to_id: Optional[int] = None
files: list[FileModel] = Field(default_factory=list, alias="transport_files")
class Config:
allow_population_by_field_name = True
class EditEncryptedMessageRequest(BaseModel):
"""Request to edit an encrypted message."""
client_public_key_b64: str
transport_nonce_b64: str
transport_ciphertext_b64: str
sender_public_key_b64: str
recipient_public_key_b64: str
# ============================================================================
# Key Management Endpoint
# ============================================================================
@router.get("/key/transport/public")
async def get_transport_public_key_endpoint():
"""
Get the current messaging service ephemeral transport public key.
Clients use this key to encrypt their messages with X25519 + ChaCha20-Poly1305.
Returns:
{
"key_id": "key-identifier",
"public_key_b64": "base64-encoded-key",
"created_at": "unix-timestamp"
}
"""
try:
return await get_messaging_transport_public_key()
except Exception as e:
logger.error("Failed to fetch transport public key: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch encryption key"
)
@router.get("/key/compliance/public")
async def get_compliance_public_key_endpoint():
"""
Get the compliance system public key (for MEK wrapping).
This key is generated offline on an air-gapped machine and used to wrap MEKs
so the compliance system can decrypt archived messages for audit.
Returns:
{
"public_key_b64": "base64-encoded-key"
}
"""
try:
return await get_compliance_public_key()
except Exception as e:
logger.error("Failed to fetch compliance public key: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch compliance key"
)
# ============================================================================
# Message Sending Endpoint
# ============================================================================
@router.post("/send")
async def send_encrypted_message(
request: SendEncryptedMessageRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Send an encrypted private message using envelope encryption.
Flow:
1. Client encrypts plaintext with transport public key (X25519 + ChaCha20)
2. Sends encrypted message to this endpoint with public keys
3. Main backend forwards to messaging service for envelope encryption processing
4. Messaging service returns encrypted message + 3 wrapped MEKs
5. Main backend stores in database
Args:
request: SendEncryptedMessageRequest
current_user: Current authenticated user
db: Database session
Returns:
{
"id": message-id,
"sender_id": sender-user-id,
"recipient_id": recipient-user-id,
"timestamp": iso-timestamp,
"reply_to_id": optional-reply-id
}
"""
try:
# Verify recipient exists
recipient = db.query(User).filter(User.id == request.recipient_id).first()
if not recipient:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Recipient not found"
)
# Verify not sending to self
if current_user.id == request.recipient_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot send messages to yourself"
)
# Fetch compliance public key and process through messaging service
compliance_key_response = await get_compliance_public_key()
compliance_public_key_b64 = compliance_key_response.get("public_key_b64")
if not compliance_public_key_b64:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve compliance key"
)
processed = await process_message_with_files_in_messaging_service(
client_public_key_b64=request.client_public_key_b64,
transport_nonce_b64=request.transport_nonce_b64,
transport_ciphertext_b64=request.transport_ciphertext_b64,
compliance_public_key_b64=compliance_public_key_b64,
sender_public_key_b64=request.sender_public_key_b64,
recipient_public_key_b64=request.recipient_public_key_b64,
transport_files=[{"encrypted_file_data_b64": f.encrypted_file_data_b64} for f in request.files],
)
logger.info(
"Processed encrypted message, storing in database sender_id=%s recipient_id=%s",
current_user.id,
request.recipient_id,
)
msg = processed["message"]
dm_envelope = DMEnvelope(
sender_id=current_user.id,
recipient_id=request.recipient_id,
iv_b64=msg["nonce"],
ciphertext_b64=msg["ciphertext"],
sender_wrapped_mek_b64=processed["sender_wrapped_mek"],
recipient_wrapped_mek_b64=processed["recipient_wrapped_mek"],
compliance_wrapped_mek_b64=processed["compliance_wrapped_mek"],
reply_to_id=request.reply_to_id,
)
db.add(dm_envelope)
db.commit()
db.refresh(dm_envelope)
# Store files encrypted with the SAME MEK as the message.
# We persist per-file nonce (for AES-GCM) but do not persist per-file wrapped MEKs.
try:
file_results: list[dict] = processed.get("files", []) or []
if len(file_results) != len(request.files):
raise HTTPException(status_code=500, detail="File processing count mismatch")
for i, tf in enumerate(request.files):
fr = file_results[i]
file_storage_result = await store_encrypted_file(
encrypted_file_data_b64=fr["ciphertext"],
filename=tf.filename,
content_type="application/octet-stream",
sender_id=current_user.id,
recipient_id=request.recipient_id,
)
df = DMFile(
message_id=dm_envelope.id,
sender_id=current_user.id,
recipient_id=dm_envelope.recipient_id,
path=file_storage_result.get("path") or f"/uploads/files/encrypted/{file_storage_result['file_id']}",
name=Path(tf.filename).name,
nonce_b64=fr["nonce"],
)
db.add(df)
db.commit()
except HTTPException:
raise
except Exception:
try:
db.rollback()
except Exception:
pass
raise
logger.info(
"Stored encrypted message msg_id=%s from user_id=%s to user_id=%s",
dm_envelope.id,
current_user.id,
request.recipient_id,
)
# Send user-specific WebSocket updates (each user gets only their MEK and files metadata)
recipient_payload = convert_dm_envelope(db, dm_envelope, dm_envelope.recipient_id)
await messagingManager.send_update_to_user(dm_envelope.recipient_id, "dmNew", recipient_payload, db)
sender_payload = convert_dm_envelope(db, dm_envelope, dm_envelope.sender_id)
await messagingManager.send_update_to_user(dm_envelope.sender_id, "dmNew", sender_payload, db)
return {
"id": dm_envelope.id,
"sender_id": dm_envelope.sender_id,
"recipient_id": dm_envelope.recipient_id,
"timestamp": dm_envelope.timestamp.isoformat(),
"reply_to_id": dm_envelope.reply_to_id,
}
except HTTPException:
raise
except Exception as e:
logger.exception("Error sending encrypted message: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send message"
)
# ============================================================================
# Compliance Endpoint (User ID 1 Only)
# ============================================================================
@router.get("/compliance/extract/{message_id}")
async def extract_message_for_compliance(
message_id: int,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Extract message data for compliance review.
RESTRICTED: Only accessible by user ID 1 (compliance officer).
This endpoint extracts encrypted message data that can be transferred
to an air-gapped machine for decryption using the compliance private key.
"""
# Log compliance access attempt
client_ip = getattr(request.client, "host", "unknown") if request.client else "unknown"
log_security(
"compliance_access_attempt",
"warning",
username=current_user.username,
user_id=current_user.id,
message_id=message_id,
ip=client_ip,
)
# Security check: only user ID 1 can access this
if current_user.id != 1:
log_security(
"compliance_access_denied",
"error",
username=current_user.username,
user_id=current_user.id,
message_id=message_id,
ip=client_ip,
reason="Unauthorized user (compliance officer access required)",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. This endpoint is restricted to compliance officers.",
)
# Find the message
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first()
if not envelope:
log_security(
"compliance_access_failed",
"warning",
username=current_user.username,
user_id=current_user.id,
message_id=message_id,
ip=client_ip,
reason="Message not found",
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found",
)
# Get sender and recipient usernames for logging
sender = db.query(User).filter(User.id == envelope.sender_id).first()
recipient = db.query(User).filter(User.id == envelope.recipient_id).first()
sender_username = sender.username if sender else f"user_{envelope.sender_id}"
recipient_username = recipient.username if recipient else f"user_{envelope.recipient_id}"
# Extract compliance-relevant data (excluding sensitive server-only fields)
files = []
try:
for f in (envelope.files or []):
wrapped = envelope.compliance_wrapped_mek_b64
files.append(
{
"id": f.id,
"name": f.name,
"path": f.path,
"wrapped_mek_b64": wrapped,
"nonce_b64": getattr(f, "nonce_b64", None),
}
)
except Exception:
files = []
# Get complete edit history for compliance
edit_history = db.query(DMEditHistory).filter(
DMEditHistory.message_id == message_id
).order_by(DMEditHistory.edited_at).all()
edit_history_data = []
for edit_entry in edit_history:
edited_by_user = db.query(User).filter(User.id == edit_entry.edited_by).first()
edit_history_data.append({
"edit_id": edit_entry.id,
"edited_at": edit_entry.edited_at.isoformat(),
"edited_by_user_id": edit_entry.edited_by,
"edited_by_username": edited_by_user.username if edited_by_user else "unknown",
"previous_ciphertext_b64": edit_entry.previous_ciphertext_b64,
"previous_iv_b64": edit_entry.previous_iv_b64,
"previous_sender_wrapped_mek_b64": edit_entry.previous_sender_wrapped_mek_b64,
"previous_recipient_wrapped_mek_b64": edit_entry.previous_recipient_wrapped_mek_b64,
"previous_compliance_wrapped_mek_b64": edit_entry.previous_compliance_wrapped_mek_b64,
})
compliance_data = {
"message_id": envelope.id,
"sender_id": envelope.sender_id,
"recipient_id": envelope.recipient_id,
"timestamp": envelope.timestamp.isoformat(),
"iv_b64": envelope.iv_b64,
"ciphertext_b64": envelope.ciphertext_b64,
"compliance_wrapped_mek_b64": envelope.compliance_wrapped_mek_b64,
"files": files,
"edit_history": edit_history_data,
"total_edits": len(edit_history_data),
"extraction_timestamp": datetime.now().isoformat(),
"extracted_by_user_id": current_user.id,
"compliance_system_ready": envelope.compliance_wrapped_mek_b64 is not None,
}
log_security(
"compliance_extraction_success",
"info",
username=current_user.username,
user_id=current_user.id,
message_id=message_id,
sender_id=envelope.sender_id,
recipient_id=envelope.recipient_id,
sender_username=sender_username,
recipient_username=recipient_username,
ip=client_ip,
)
return {
"status": "success",
"message": "Message data extracted for compliance review",
"data": compliance_data,
"instructions": [
"Transfer this data to an air-gapped machine",
"Use compliance_decryption.py decrypt --input-file <json_file>",
"Keep the compliance private key offline at all times",
],
}
# ============================================================================
# Conversation Retrieval Endpoint
# ============================================================================
@router.get("/conversation/{other_user_id}")
async def get_encrypted_conversation(
other_user_id: int,
limit: int = 50,
offset: int = 0,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Retrieve encrypted conversation with another user.
Returns messages with the wrapped MEK that the current user can unwrap.
Each user receives only their own wrapped MEK version.
Args:
other_user_id: ID of the other user in conversation
limit: Max messages to return (default 50)
offset: Pagination offset (default 0)
current_user: Current authenticated user
db: Database session
Returns:
List of encrypted messages with metadata:
[
{
"id": message-id,
"sender_id": sender-id,
"recipient_id": recipient-id,
"nonce": base64-encoded-nonce,
"ciphertext": base64-encoded-ciphertext,
"wrapped_mek": wrapped-mek-for-current-user,
"timestamp": iso-timestamp,
"reply_to_id": optional-id,
"is_edited": boolean
},
...
]
"""
try:
# Verify other user exists
other_user = db.query(User).filter(User.id == other_user_id).first()
if not other_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Fetch messages in both directions, sorted by timestamp
messages = (
db.query(DMEnvelope)
.filter(
(
(DMEnvelope.sender_id == current_user.id)
& (DMEnvelope.recipient_id == other_user_id)
)
| (
(DMEnvelope.sender_id == other_user_id)
& (DMEnvelope.recipient_id == current_user.id)
)
)
.order_by(DMEnvelope.timestamp.desc())
.limit(limit)
.offset(offset)
.all()
)
result = []
for msg in reversed(messages):
# Select wrapped MEK appropriate for current user
if msg.sender_id == current_user.id:
wrapped_mek = msg.sender_wrapped_mek_b64
else:
wrapped_mek = msg.recipient_wrapped_mek_b64
result.append(
{
"id": msg.id,
"sender_id": msg.sender_id,
"recipient_id": msg.recipient_id,
"nonce": msg.iv_b64,
"ciphertext": msg.ciphertext_b64,
"wrapped_mek": wrapped_mek,
"timestamp": msg.timestamp.isoformat(),
"reply_to_id": msg.reply_to_id,
"is_edited": msg.is_edited,
}
)
logger.info(
"Retrieved %d messages for conversation between user_id=%s and user_id=%s",
len(result),
current_user.id,
other_user_id,
)
return result
except HTTPException:
raise
except Exception as e:
logger.exception("Error fetching conversation: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch conversation"
)
# ============================================================================
# Message Deletion Endpoint
# ============================================================================
@router.get("/owner/compliance-view")
async def get_owner_compliance_view(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Get all encrypted messages accessible to the owner (user_id 1) for compliance.
This endpoint returns all DM envelopes with their compliance-wrapped MEKs.
Only accessible to the system owner for audit/compliance purposes.
Returns:
List of all encrypted messages with compliance_wrapped_mek:
[
{
"id": message-id,
"sender_id": sender-id,
"recipient_id": recipient-id,
"nonce": base64-encoded-nonce,
"ciphertext": base64-encoded-ciphertext,
"compliance_wrapped_mek": wrapped-mek-for-compliance,
"timestamp": iso-timestamp,
},
...
]
"""
if current_user.id != 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owner (user_id 1) can access compliance view"
)
try:
# Fetch all messages
messages = (
db.query(DMEnvelope)
.order_by(DMEnvelope.timestamp.desc())
.all()
)
result = []
for msg in messages:
result.append(
{
"id": msg.id,
"sender_id": msg.sender_id,
"recipient_id": msg.recipient_id,
"nonce": msg.iv_b64,
"ciphertext": msg.ciphertext_b64,
"compliance_wrapped_mek": msg.compliance_wrapped_mek_b64,
"timestamp": msg.timestamp.isoformat(),
}
)
logger.info(
"Owner retrieved %d messages for compliance view",
len(result),
)
return result
except Exception as e:
logger.exception("Error retrieving compliance view: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve compliance view"
)
@router.get("/compliance/edit-history/dm/{message_id}")
async def get_dm_edit_history_for_compliance(
message_id: int,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Get complete edit history for a DM message (compliance access only).
RESTRICTED: Only accessible by user ID 1 (compliance officer).
This endpoint returns the full edit history for a DM message,
including all previous encrypted versions.
Args:
message_id: ID of the DM message
current_user: Current authenticated user (must be user_id 1)
db: Database session
Returns:
Complete edit history for the message
"""
client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown'
# Log compliance access attempt
log_security("dm_edit_history_access_attempt", "warning",
user_id=current_user.id,
username=current_user.username,
ip=client_ip,
message_id=message_id)
# Only user_id 1 (compliance officer) can access
if current_user.id != 1:
log_security("dm_edit_history_access_denied", "error",
user_id=current_user.id,
username=current_user.username,
ip=client_ip,
reason="Unauthorized user (compliance officer access required)")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. This endpoint is restricted to compliance officers."
)
try:
# Get the original message
message = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first()
if not message:
log_security("dm_edit_history_access_failed", "warning",
user_id=current_user.id,
ip=client_ip,
message_id=message_id,
reason="Message not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found"
)
# Get edit history
edit_history = db.query(DMEditHistory).filter(
DMEditHistory.message_id == message_id
).order_by(DMEditHistory.edited_at).all()
# Convert to response format
history_entries = []
for entry in edit_history:
edited_by_user = db.query(User).filter(User.id == entry.edited_by).first()
history_entries.append({
"id": entry.id,
"dm_envelope_id": entry.message_id,
"previous_ciphertext_b64": entry.previous_ciphertext_b64,
"previous_iv_b64": entry.previous_iv_b64,
"previous_sender_wrapped_mek_b64": entry.previous_sender_wrapped_mek_b64,
"previous_recipient_wrapped_mek_b64": entry.previous_recipient_wrapped_mek_b64,
"previous_compliance_wrapped_mek_b64": entry.previous_compliance_wrapped_mek_b64,
"edited_at": entry.edited_at.isoformat(),
"edited_by_username": edited_by_user.username if edited_by_user else "unknown",
"edited_by_user_id": entry.edited_by
})
# Current message data
current_data = {
"id": message.id,
"sender_id": message.sender_id,
"recipient_id": message.recipient_id,
"ciphertext_b64": message.ciphertext_b64,
"iv_b64": message.iv_b64,
"sender_wrapped_mek_b64": message.sender_wrapped_mek_b64,
"recipient_wrapped_mek_b64": message.recipient_wrapped_mek_b64,
"compliance_wrapped_mek_b64": message.compliance_wrapped_mek_b64,
"timestamp": message.timestamp.isoformat(),
"is_edited": message.is_edited
}
result = {
"message_id": message_id,
"current_version": current_data,
"edit_history": history_entries,
"total_edits": len(history_entries)
}
log_security("dm_edit_history_access_success", "info",
user_id=current_user.id,
username=current_user.username,
ip=client_ip,
message_id=message_id,
edit_count=len(history_entries))
return result
except HTTPException:
raise
except Exception as e:
logger.exception("Error retrieving DM edit history: %s", e)
log_security("dm_edit_history_access_error", "error",
user_id=current_user.id,
ip=client_ip,
message_id=message_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve edit history"
)
@router.put("/edit/{message_id}")
async def edit_encrypted_message(
message_id: int,
request: EditEncryptedMessageRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Edit an encrypted private message.
This endpoint allows users to edit their own DM messages. The edit history
is stored in compliance storage, but users only see the latest version.
The message goes through the same envelope encryption process as sending.
Args:
message_id: ID of the message to edit
request: Edit request with transport-encrypted content
current_user: Current authenticated user
db: Database session
Returns:
Updated message info
"""
try:
# Find the message
msg = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first()
if not msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found"
)
# Verify ownership
if msg.sender_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot edit others' messages"
)
# Fetch compliance public key and process through messaging service
compliance_key_response = await get_compliance_public_key()
compliance_public_key_b64 = compliance_key_response.get("public_key_b64")
if not compliance_public_key_b64:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve compliance key"
)
# Process the transport-encrypted message through envelope encryption
processed = await process_message_with_files_in_messaging_service(
client_public_key_b64=request.client_public_key_b64,
transport_nonce_b64=request.transport_nonce_b64,
transport_ciphertext_b64=request.transport_ciphertext_b64,
compliance_public_key_b64=compliance_public_key_b64,
sender_public_key_b64=request.sender_public_key_b64,
recipient_public_key_b64=request.recipient_public_key_b64,
transport_files=[], # No file support for edits currently
)
# Store edit history in compliance storage before updating
edit_history = DMEditHistory(
message_id=msg.id,
dm_envelope_id=msg.id, # Match existing DB schema
previous_ciphertext_b64=msg.ciphertext_b64,
previous_iv_b64=msg.iv_b64,
previous_sender_wrapped_mek_b64=msg.sender_wrapped_mek_b64,
previous_recipient_wrapped_mek_b64=msg.recipient_wrapped_mek_b64,
previous_compliance_wrapped_mek_b64=msg.compliance_wrapped_mek_b64 or "",
edited_by=current_user.id,
edited_by_user_id=current_user.id # Match existing DB schema
)
db.add(edit_history)
# Update the message with new processed content
processed_msg = processed["message"]
msg.ciphertext_b64 = processed_msg["ciphertext"]
msg.iv_b64 = processed_msg["nonce"]
msg.sender_wrapped_mek_b64 = processed["sender_wrapped_mek"]
msg.recipient_wrapped_mek_b64 = processed["recipient_wrapped_mek"]
msg.compliance_wrapped_mek_b64 = processed["compliance_wrapped_mek"]
msg.is_edited = True
db.commit()
db.refresh(msg)
logger.info(
"Edited encrypted message msg_id=%s by user_id=%s",
message_id,
current_user.id
)
# Send WebSocket updates to both sender and recipient
recipient_payload = convert_dm_envelope(db, msg, msg.recipient_id)
await messagingManager.send_update_to_user(msg.recipient_id, "dmEdited", recipient_payload, db)
sender_payload = convert_dm_envelope(db, msg, msg.sender_id)
await messagingManager.send_update_to_user(msg.sender_id, "dmEdited", sender_payload, db)
return {
"id": msg.id,
"sender_id": msg.sender_id,
"recipient_id": msg.recipient_id,
"timestamp": msg.timestamp.isoformat(),
"is_edited": msg.is_edited
}
except HTTPException:
raise
except Exception as e:
logger.exception("Error editing encrypted message: %s", e)
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to edit message"
)
@router.delete("/{message_id}")
async def delete_encrypted_message(
message_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Delete an encrypted message (soft delete).
Only the sender can delete their own messages.
In the compliance system, keys are automatically destroyed after deletion.
Args:
message_id: ID of message to delete
current_user: Current authenticated user
db: Database session
Returns:
{"status": "deleted", "message_id": message-id}
"""
try:
msg = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first()
if not msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found"
)
# Only sender can delete
if msg.sender_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete others' messages"
)
db.delete(msg)
db.commit()
logger.info(
"Deleted encrypted message msg_id=%s by user_id=%s",
message_id,
current_user.id
)
return {"status": "deleted", "message_id": message_id}
except HTTPException:
raise
except Exception as e:
logger.exception("Error deleting message: %s", e)
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete message"
)
+94
View File
@@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
import os
import logging
import base64
router = APIRouter(prefix="/api")
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")
@router.post("/key/invalidate")
async def invalidate_key():
"""
Request messaging service to invalidate its current ephemeral key (rotate).
"""
messaging_module = _get_messaging_module()
if messaging_module:
try:
data = await messaging_module.invalidate_key() # type: ignore
return data
except Exception as e:
logger.error(f"Failed to invalidate key in in-process messaging module: {e}")
raise HTTPException(status_code=500, detail="Failed to invalidate messaging key")
messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301")
url = f"{messaging_url.rstrip('/')}/key/invalidate"
try:
try:
import httpx
resp = httpx.post(url, timeout=5.0)
resp.raise_for_status()
return resp.json()
except Exception:
from urllib import request, error
import json
req = request.Request(url, method="POST")
with request.urlopen(req, timeout=5) as r:
body = r.read()
return json.loads(body)
except Exception as e:
logger.error(f"Failed to call messaging invalidate endpoint via HTTP: {e}")
raise HTTPException(status_code=502, detail="Failed to contact messaging service")
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import List
from ..constants import OWNER_USERNAME
from ..dependencies import get_current_user
from ..models import User
from ..security.audit import log_security
from ..security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
from ..security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
class BlocklistUpdateRequest(BaseModel):
words: List[str] = Field(default_factory=list, min_items=1)
class UnblockIPRequest(BaseModel):
ip: str = Field(..., min_length=1)
router = APIRouter(prefix="/moderation", tags=["moderation"])
def _ensure_owner(user: User) -> None:
if user.username != OWNER_USERNAME:
raise HTTPException(status_code=403, detail="Only owner can perform this action")
@router.get("/blocklist")
def list_blocklist(current_user: User = Depends(get_current_user)):
_ensure_owner(current_user)
return {"words": get_blocklist()}
@router.post("/blocklist")
def append_blocklist(
request: BlocklistUpdateRequest,
current_user: User = Depends(get_current_user)
):
_ensure_owner(current_user)
added, updated = add_to_blocklist(request.words)
log_security(
"blocklist_add",
actor=current_user.username,
actor_id=current_user.id,
added=added,
)
return {"added": added, "words": updated}
@router.delete("/blocklist")
def delete_from_blocklist(
request: BlocklistUpdateRequest,
current_user: User = Depends(get_current_user)
):
_ensure_owner(current_user)
removed, updated = remove_from_blocklist(request.words)
log_security(
"blocklist_remove",
actor=current_user.username,
actor_id=current_user.id,
removed=removed,
)
return {"removed": removed, "words": updated}
@router.post("/unblock-ip")
def unblock_ip(
request: UnblockIPRequest,
current_user: User = Depends(get_current_user)
):
"""Unblock an IP address from rate limiting."""
_ensure_owner(current_user)
ip = request.ip.strip()
if not ip:
raise HTTPException(status_code=400, detail="IP address is required")
cleared = reset_rate_limit_for_ip(ip)
log_security(
"rate_limit_unblock",
actor=current_user.username,
actor_id=current_user.id,
ip=ip,
success=cleared,
)
if cleared:
return {"status": "success", "message": f"Rate limit cleared for IP: {ip}"}
else:
return {"status": "success", "message": f"No rate limit entries found for IP: {ip}"}
@router.post("/clear-all-rate-limits")
def clear_all_rate_limits_endpoint(
current_user: User = Depends(get_current_user)
):
"""Clear all rate limit entries. Use with caution."""
_ensure_owner(current_user)
cleared = clear_all_rate_limits()
log_security(
"rate_limit_clear_all",
actor=current_user.username,
actor_id=current_user.id,
entries_cleared=cleared,
)
return {"status": "success", "message": f"Cleared {cleared} rate limit entries"}
+579
View File
@@ -0,0 +1,579 @@
from pathlib import Path
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_db, get_current_user
from ..models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel
from ..validation import is_valid_username, is_valid_display_name
from ..similarity import is_user_similar_to_verified
from .messaging import messagingManager
from ..security.audit import log_security
from ..security.profanity import contains_profanity
from ..security.rate_limit import rate_limit_per_ip
router = APIRouter()
def _ensure_owner_unsuspended(user: User | None, db: Session):
if user and user.id == 1 and user.suspended:
user.suspended = False
user.suspension_reason = None
db.commit()
db.refresh(user)
# Request models
class UpdateProfileRequest(BaseModel):
username: str | None = None
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()
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),
db: Session = Depends(get_db)
):
"""
Get current user's profile information
"""
try:
_ensure_owner_unsuspended(current_user, db)
return UserProfileResponse(
id=current_user.id,
username=current_user.username,
display_name=current_user.display_name,
profile_picture=current_user.profile_picture,
bio=current_user.bio,
online=current_user.online,
last_seen=current_user.last_seen,
created_at=current_user.created_at,
verified=current_user.verified,
suspended=current_user.suspended or False,
suspension_reason=current_user.suspension_reason,
deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted
)
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()
return {
"users": [
UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
).model_dump()
for user in users
]
}
@router.put("/user/profile")
@rate_limit_per_ip("10/minute")
async def update_user_profile(
request: 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()
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()
return {
"message": "Bio updated successfully",
"bio": current_user.bio
}
@router.get("/user/{username}")
async def get_user_by_username(
username: str,
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)
# Handle deleted or suspended users
if user.deleted or user.suspended:
return UserProfileResponse(
id=user.id,
username="deleted",
display_name="Deleted User",
profile_picture=None,
bio=None,
online=False,
last_seen=None, # Clear last seen timestamp
created_at=None, # Clear member since timestamp
verified=False,
suspended=False,
suspension_reason=None,
deleted=True
)
return UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
)
@router.get("/user/id/{user_id}")
async def get_user_by_id(
user_id: int,
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)
# Handle deleted or suspended users
if user.deleted or user.suspended:
return UserProfileResponse(
id=user.id,
username="deleted",
display_name="Deleted User",
profile_picture=None,
bio=None,
online=False,
last_seen=None, # Clear last seen timestamp
created_at=None, # Clear member since timestamp
verified=False,
suspended=False,
suspension_reason=None,
deleted=True
)
return UserProfileResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
profile_picture=user.profile_picture,
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=user.deleted or False
)
@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()
log_security(
"admin_verify_toggle",
actor=current_user.username,
actor_id=current_user.id,
target_username=target_user.username,
target_id=target_user.id,
verified=target_user.verified,
)
return {
"verified": target_user.verified,
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
}
@router.get("/user/check-similarity/{user_id}")
async def check_user_similarity(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Check if a user is similar to any verified user
"""
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Get all verified users
verified_users = db.query(User).filter(User.verified == True).all()
verified_users_data = [
{"username": user.username, "display_name": user.display_name}
for user in verified_users
]
# Check similarity
is_similar, similar_to = is_user_similar_to_verified(
target_user.username,
target_user.display_name,
verified_users_data
)
return {
"isSimilar": is_similar,
"similarTo": similar_to if is_similar else None
}
# 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()
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"
}
+46
View File
@@ -0,0 +1,46 @@
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))
+89
View File
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,2 @@
# Package marker for security utilities
+406
View File
@@ -0,0 +1,406 @@
from __future__ import annotations
import logging
from html import unescape
from typing import Any, Callable, Dict, List
from ..logging_config import access_logger, dm_logger, public_chat_logger, security_logger
def _clean_username(username: Any) -> str:
if not username:
return "unknown user"
return f"@{username}"
def _format_user(fields: Dict[str, Any], username_key: str = "username", user_id_key: str = "user_id") -> str:
username = fields.get(username_key)
if username is None and "_" in username_key:
base_key = username_key.split("_", 1)[0]
username = fields.get(base_key)
user_id = fields.get(user_id_key)
if user_id is None and "_" in user_id_key:
base_key = user_id_key.split("_", 1)[0]
user_id = fields.get(base_key)
if username and user_id is not None:
return f"{_clean_username(username)} (user id {user_id})"
if username:
return _clean_username(username)
if user_id is not None:
return f"user id {user_id}"
return "unknown user"
def _format_actor(fields: Dict[str, Any], prefix: str) -> str:
return _format_user(fields, f"{prefix}_username", f"{prefix}_id")
def _plural(label: str, count: int) -> str:
return f"{count} {label if count == 1 else label + 's'}"
def _yes_no(flag: Any) -> str:
return "yes" if flag else "no"
def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
if action == "login_success":
lines = [f"Login approved for {_format_user(fields)}"]
session = fields.get("session_id")
if session:
lines.append(f"Session: {session}")
client_bits: List[str] = []
if fields.get("device"):
client_bits.append(fields["device"])
if fields.get("os"):
client_bits.append(fields["os"])
if fields.get("browser"):
client_bits.append(fields["browser"])
if client_bits:
lines.append(f"Client: {', '.join(client_bits)}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "login_failed":
lines = [f"Login denied for {_format_user(fields)}"]
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "auth_bruteforce_detected":
lines = ["Brute-force login pattern detected"]
lines.append(f"Target: {_format_user(fields)}")
failures = fields.get("failures")
if isinstance(failures, dict):
for key, value in failures.items():
lines.append(f"{key}: {value}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
if fields.get("window_seconds"):
lines.append(f"Observation window: {fields['window_seconds']} seconds")
return lines
if action == "registration_success":
ip_raw = fields.get("ip")
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
display_name = fields.get("display_name") or "Unknown"
username = fields.get("username")
user_id = fields.get("user_id")
user_agent = fields.get("user_agent") or "Unknown user agent"
lines = ["Account registered"]
lines.append(f"Display name: {display_name}")
lines.append(f"Username: {_clean_username(username) if username else 'unknown'}")
if ip_display:
lines.append(f"IP: {ip_display}")
if user_agent:
lines.append(f"User agent: {user_agent}")
if user_id is not None:
lines.append(f"User ID: {user_id}")
return lines
if action == "password_changed":
lines = [f"Password changed for {_format_user(fields)}"]
lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "logout":
lines = [f"Logout recorded for {_format_user(fields)}"]
if fields.get("session_id"):
lines.append(f"Session: {fields['session_id']}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
return lines
if action == "admin_delete_user":
return [
"Account removal",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
]
if action == "admin_suspend_user":
lines = [
"User suspension",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
]
if fields.get("reason"):
lines.append(f"Reason: {fields.get('reason')}")
return lines
if action == "admin_unsuspend_user":
return [
"User unsuspension",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
]
if action == "admin_verify_toggle":
return [
"User verification",
f"Actor: {_format_actor(fields, 'actor')}",
f"Target: {_format_actor(fields, 'target')}",
f"Verified: {_yes_no(fields.get('verified'))}",
]
if action == "self_delete_account":
return [f"User {_format_user(fields)} deleted their account"]
if action == "auto_suspension_public_spam":
lines = [
f"Automatic suspension triggered for {_format_user(fields)}",
]
match_type = fields.get("match_type")
if match_type:
lines.append(f"Match type: {match_type}")
similar = fields.get("similar_messages")
occurrences = fields.get("occurrences")
if similar:
lines.append(f"Similar messages detected: {similar}")
if occurrences and not similar:
lines.append(f"Occurrences: {occurrences}")
if fields.get("window_seconds"):
lines.append(f"Observation window: {fields['window_seconds']} seconds")
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
return lines
if action == "auto_suspension_public_burst":
lines = [
f"Automatic suspension triggered for {_format_user(fields)}",
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
]
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
return lines
if action == "public_message_burst":
return [
f"Rapid messaging spike for {_format_user(fields)}",
f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds",
]
if action == "blocklist_add":
added = fields.get("added") or []
lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"]
if added:
lines.append(f"Added entries: {', '.join(added)}")
total = len(fields.get("words") or [])
lines.append(f"Total entries: {total}")
return lines
if action == "blocklist_remove":
removed = fields.get("removed") or []
lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"]
if removed:
lines.append(f"Removed entries: {', '.join(removed)}")
total = len(fields.get("words") or [])
lines.append(f"Total entries: {total}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]:
if action == "message_created":
lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"]
if fields.get("reply_to"):
lines.append(f"In reply to message #{fields['reply_to']}")
attachments = fields.get("attachments")
if attachments:
lines.append(f"Attachments: {_plural('file', attachments)}")
# If content was censored, log both raw and censored versions
if fields.get("raw_content") is not None:
lines.append("Raw content (before censoring):")
for line in unescape(fields["raw_content"]).splitlines():
lines.append(f"| {line}")
lines.append("Censored content (stored):")
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
lines.append(f"| {line}")
elif fields.get("content"):
lines.append("Content:")
for line in unescape(fields["content"]).splitlines():
lines.append(f"| {line}")
return lines
if action == "message_edited":
lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"]
if fields.get("reply_to"):
lines.append(f"Reply to #{fields['reply_to']}")
if fields.get("previous_content"):
lines.append("Previous content:")
for line in unescape(fields["previous_content"] or "").splitlines() or [""]:
lines.append(f"| {line}")
# If content was censored, log both raw and censored versions
if fields.get("raw_content") is not None:
lines.append("Raw content (before censoring):")
for line in unescape(fields["raw_content"]).splitlines():
lines.append(f"| {line}")
lines.append("Censored content (stored):")
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
lines.append(f"| {line}")
elif fields.get("content"):
lines.append("New content:")
for line in unescape(fields["content"] or "").splitlines() or [""]:
lines.append(f"| {line}")
return lines
if action == "message_deleted":
lines = [
f"Message #{fields.get('message_id')} deleted",
f"Actor: {_format_actor(fields, 'actor')}",
]
if fields.get("original_author_id") is not None:
lines.append(f"Original author: user #{fields['original_author_id']}")
if fields.get("content"):
lines.append("Previous content:")
for line in unescape(fields["content"]).splitlines():
lines.append(f"| {line}")
return lines
if action == "reaction_update":
lines = [
f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}",
f"User: {_format_user(fields)}",
]
if fields.get("emoji"):
lines.append(f"Emoji: {fields['emoji']}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]:
if action in {"message_sent", "message_sent_ws"}:
lines = [
f"Direct message #{fields.get('dm_envelope_id')} sent",
f"Sender: {_format_actor(fields, 'sender')}",
]
if fields.get("recipient_id") is not None:
lines.append(f"Recipient: user id {fields['recipient_id']}")
attachments = fields.get("attachment_count")
if attachments:
lines.append(f"Attachments: {_plural('file', attachments)}")
if fields.get("reply_to"):
lines.append(f"In reply to DM #{fields['reply_to']}")
return lines
if action == "message_edited":
return [
f"Direct message #{fields.get('dm_envelope_id')} edited",
f"Author: {_format_user(fields)}",
]
if action == "message_deleted":
lines = [
f"Direct message #{fields.get('dm_envelope_id')} deleted",
f"Actor: {_format_user(fields)}",
]
if fields.get("recipient_id") is not None:
lines.append(f"Recipient: user id {fields['recipient_id']}")
return lines
if action == "reaction_update":
lines = [
f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}",
f"User: {_format_user(fields)}",
]
if fields.get("emoji"):
lines.append(f"Emoji: {fields['emoji']}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _render_access(action: str, fields: Dict[str, Any]) -> List[str]:
ip_raw = fields.get("ip")
ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw
if action == "http_request":
first_line = f"{fields.get('method')} {fields.get('path')}"
if ip_display:
first_line += f" from {ip_display}"
first_line += f" -> {fields.get('status')}"
lines = [first_line]
if fields.get("user"):
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
return lines
if action == "http_error":
first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}"
if ip_display:
first_line += f" from {ip_display}"
lines = [first_line]
if fields.get("error"):
lines.append(f"Exception: {fields['error']}")
if fields.get("user"):
lines.append(f"Authenticated user: {_clean_username(fields['user'])}")
return lines
if action == "ws_connect":
lines = ["WebSocket connected"]
if fields.get("path"):
lines.append(f"Endpoint: {fields['path']}")
if ip_display:
lines.append(f"IP: {ip_display}")
return lines
if action == "ws_disconnect":
lines = ["WebSocket disconnected"]
if fields.get("path"):
lines.append(f"Endpoint: {fields['path']}")
if fields.get("code") is not None:
reason = fields.get("reason") or "no reason"
lines.append(f"Code {fields['code']} ({reason})")
if ip_display:
lines.append(f"IP: {ip_display}")
return lines
if action == "ws_event":
event_name = fields.get("event")
path = fields.get("path")
first_line = "WS"
if path:
first_line += f" {path}"
if ip_display:
first_line += f" from {ip_display}"
if event_name:
first_line += f" -> {event_name}"
lines = [first_line]
if fields.get("user"):
lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}")
for key, value in fields.items():
if key in {"path", "event", "user", "user_id", "ip"} or value is None:
continue
lines.append(f"{key.replace('_', ' ').capitalize()}: {value}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()
if value is not None
]
def _log_event(
logger: logging.Logger,
renderer: Callable[[str, Dict[str, Any]], List[str]],
action: str,
severity: str,
fields: Dict[str, Any],
) -> None:
lines = renderer(action, fields)
if not lines:
return
level = getattr(logging, severity.upper(), logging.INFO)
logger.log(level, "\n".join(lines))
def log_security(action: str, severity: str = "info", **fields: Any) -> None:
_log_event(security_logger, _render_security, action, severity, fields)
def log_public_chat(action: str, severity: str = "info", **fields: Any) -> None:
_log_event(public_chat_logger, _render_public_chat, action, severity, fields)
def log_dm(action: str, severity: str = "info", **fields: Any) -> None:
sanitized_fields = {key: value for key, value in fields.items() if key != "content"}
_log_event(dm_logger, _render_dm, action, severity, sanitized_fields)
def log_access(action: str, severity: str = "info", **fields: Any) -> None:
_log_event(access_logger, _render_access, action, severity, fields)
+694
View File
@@ -0,0 +1,694 @@
from __future__ import annotations
import json
import re
import unicodedata
from pathlib import Path
from threading import RLock
from typing import Iterable, List, Set, Tuple
from better_profanity import Profanity
BLOCKLIST_PATH = Path("data/profanity/blocklist.json")
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
_CUSTOM_RU_TERMS: Set[str] = {
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор",
"пидоры", "пидорас", "пидорасы", "пидорасов",
}
_ADULT_TERMS: Set[str] = {
"порно", "порнуха", "эротика", "эротический", "секс", "сексуальный",
"инцест", "порнография", "порностудия", "порновидео", "порносайт",
"сексчат", "сексчатик", "секслайв", "сексвидео",
}
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
# Words that should never be flagged as profanity (whitelist)
_WHITELIST: Set[str] = {
"говно", # Allow this word
}
# Phrase patterns - these will be applied to normalized text (without special chars)
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
)
# Patterns to check in original text (before normalization) to catch visual bypasses
# These patterns check for special character combinations that visually form letters
_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = (
# Catch "}{" used to visually form "х" followed by "С0С" or similar patterns
# This catches "хуесос" written as "}{¥€С0С" or variations
# Matches: }{ + any characters (including special chars) + С/с + 0 + С/с
# The pattern allows any characters between to catch special chars like ¥€
re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
# Also catch "}{" followed by "уесос" with 0 instead of о
re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
)
# Map for normalizing homoglyphs (similar-looking characters)
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
# Also includes Greek, full-width, and other Unicode variants
_LEET_MAP = {
# Numbers to letters
"0": "о",
"1": "и",
"3": "е",
"4": "а",
# Latin to Cyrillic (lowercase)
"a": "а",
"c": "с",
"e": "е",
"f": "ф",
"g": "г",
"i": "и",
"m": "м",
"n": "н",
"o": "о",
"p": "п",
"s": "с",
"t": "т",
"u": "у",
"v": "в",
"x": "х",
"y": "у",
"z": "з", # English 'z' to Cyrillic 'з'
# Latin to Cyrillic (uppercase)
"A": "а",
"C": "с",
"E": "е",
"F": "ф",
"G": "г",
"I": "и",
"M": "м",
"N": "н",
"O": "о",
"P": "п",
"S": "с",
"T": "т",
"U": "у",
"V": "в",
"X": "х",
"Y": "у",
"Z": "з", # English 'Z' to Cyrillic 'з'
# Greek letters that look like Cyrillic/Latin
"α": "а", # Greek alpha
"Α": "а",
"ο": "о", # Greek omicron
"Ο": "о",
"ρ": "р", # Greek rho (looks like Cyrillic р)
"Ρ": "р",
"υ": "у", # Greek upsilon
"Υ": "у",
"χ": "х", # Greek chi
"Χ": "х",
"ε": "е", # Greek epsilon
"Ε": "е",
"ι": "и", # Greek iota
"Ι": "и",
"ν": "н", # Greek nu
"Ν": "н",
"μ": "м", # Greek mu
"Μ": "м",
"π": "п", # Greek pi
"Π": "п",
"τ": "т", # Greek tau
"Τ": "т",
"γ": "г", # Greek gamma
"Γ": "г",
"σ": "с", # Greek sigma
"Σ": "с",
"φ": "ф", # Greek phi
"Φ": "ф",
# Full-width Latin characters
"": "а",
"": "а",
"": "с",
"": "с",
"": "е",
"": "е",
"": "ф",
"": "ф",
"": "г",
"": "г",
"": "и",
"": "и",
"": "м",
"": "м",
"": "н",
"": "н",
"": "о",
"": "о",
"": "п",
"": "п",
"": "с",
"": "с",
"": "т",
"": "т",
"": "у",
"": "у",
"": "в",
"": "в",
"": "х",
"": "х",
"": "у",
"": "у",
"": "з", # 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
@@ -0,0 +1,232 @@
from __future__ import annotations
import asyncio
import logging
import time
from typing import Callable
from fastapi import Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from ..utils import get_client_ip
logger = logging.getLogger("uvicorn.error")
def get_ip_key(request: Request) -> str:
"""Get rate limit key based on IP address."""
return get_client_ip(request) or get_remote_address(request)
# Initialize limiter with IP-based key function
# Note: We don't set default_limits to avoid affecting all users if one IP is attacked.
# Each endpoint should have an explicit rate limit based on its sensitivity.
# Rate limits automatically expire after the time window - IPs are not permanently blocked.
limiter = Limiter(
key_func=get_ip_key,
default_limits=[], # No global default - each endpoint must have explicit limits
storage_uri="memory://", # In-memory storage (can be changed to Redis later)
)
# Rate limit decorator for IP-based limiting
def rate_limit_per_ip(limit: str) -> Callable:
"""Rate limit based on IP address."""
return limiter.limit(limit, key_func=get_ip_key)
def _get_storage_dict(storage) -> dict | None:
"""Get the internal storage dictionary from slowapi's memory storage."""
if hasattr(storage, "_storage") and isinstance(storage._storage, dict):
return storage._storage
elif hasattr(storage, "storage") and isinstance(storage.storage, dict):
return storage.storage
return None
def reset_all_rate_limits() -> int:
"""
Reset all rate limits by clearing the storage.
This should be called on startup to ensure a clean state.
Returns the number of entries cleared.
"""
try:
# Access the private _storage attribute
storage = limiter._storage
storage_dict = _get_storage_dict(storage)
if storage_dict is None:
# Try using the storage's reset method if available
if hasattr(storage, "reset"):
try:
# Try reset() with no args first (clears all)
storage.reset()
logger.info("Reset all rate limits on startup using storage.reset()")
return 1 # Assume it worked
except TypeError:
# reset() might require arguments, try clearing differently
try:
# Some storage backends need explicit clearing
if hasattr(storage, "clear"):
storage.clear()
logger.info("Reset all rate limits on startup using storage.clear()")
return 1
except Exception:
pass
except Exception:
pass
logger.warning("Could not reset rate limits: storage dict not accessible and no reset method")
return 0
count = len(storage_dict)
if count > 0:
storage_dict.clear()
logger.info(f"Reset all rate limits on startup: cleared {count} entries")
return count
except Exception as e:
logger.warning(f"Failed to reset rate limits on startup: {e}")
return 0
def reset_rate_limit_for_ip(ip: str) -> bool:
"""
Manually reset rate limit for a specific IP address.
This clears all rate limit entries for the given IP.
Returns True if any entries were cleared, False otherwise.
"""
if not ip:
return False
try:
# Access the private _storage attribute
storage = limiter._storage
storage_dict = _get_storage_dict(storage)
if storage_dict is None:
# Try alternative methods
if hasattr(storage, "reset"):
try:
storage.reset(ip)
return True
except Exception:
pass
return False
cleared = False
# slowapi stores entries with keys like "LIMITER:{ip}:{endpoint}"
# We need to find all keys that contain this IP
# Also handle cases where IP might be in different positions
keys_to_remove = []
for key in list(storage_dict.keys()):
if isinstance(key, str):
# Check multiple patterns:
# - "LIMITER:{ip}:{endpoint}"
# - Keys containing the IP anywhere
# - Keys starting with the IP
if (key.startswith(f"LIMITER:{ip}:") or
key.startswith(f"LIMITER:{ip}") or
f":{ip}:" in key or
key.endswith(f":{ip}") or
(ip in key and "LIMITER" in key)):
keys_to_remove.append(key)
for key in keys_to_remove:
try:
del storage_dict[key]
cleared = True
logger.info(f"Cleared rate limit key: {key}")
except KeyError:
pass
if cleared:
logger.info(f"Successfully cleared rate limits for IP: {ip}")
else:
logger.warning(f"No rate limit entries found for IP: {ip}")
return cleared
except Exception as e:
logger.warning(f"Failed to reset rate limit for IP {ip}: {e}")
return False
def clear_all_rate_limits() -> int:
"""
Clear all rate limit entries. Use with caution - this affects all IPs.
Returns the number of entries cleared.
"""
try:
# Access the private _storage attribute
storage = limiter._storage
storage_dict = _get_storage_dict(storage)
if storage_dict is None:
return 0
count = len(storage_dict)
storage_dict.clear()
logger.warning(f"Cleared all {count} rate limit entries")
return count
except Exception as e:
logger.error(f"Failed to clear all rate limits: {e}")
return 0
def cleanup_expired_rate_limits() -> int:
"""
Clean up expired rate limit entries from memory storage.
This helps prevent rate limits from being stuck indefinitely.
Returns the number of entries cleaned up.
"""
try:
# Access the private _storage attribute
storage = limiter._storage
storage_dict = _get_storage_dict(storage)
if storage_dict is None:
return 0
# slowapi's memory storage stores entries as tuples: (count, reset_time)
# Entries should expire naturally, but we'll clean up any that are clearly expired
now = time.time()
cleaned = 0
keys_to_remove = []
for key, value in storage_dict.items():
if isinstance(value, (tuple, list)) and len(value) >= 2:
# Check if reset_time has passed (with some buffer)
reset_time = value[1] if isinstance(value[1], (int, float)) else 0
# Add 60 second buffer to ensure we don't remove active entries
if reset_time > 0 and now > (reset_time + 60):
keys_to_remove.append(key)
elif isinstance(value, dict):
# Some storage formats use dicts with 'expiry' or 'reset' fields
expiry = value.get("expiry") or value.get("reset") or value.get("reset_time")
if expiry and isinstance(expiry, (int, float)) and now > (expiry + 60):
keys_to_remove.append(key)
for key in keys_to_remove:
try:
del storage_dict[key]
cleaned += 1
except KeyError:
pass
if cleaned > 0:
logger.info(f"Cleaned up {cleaned} expired rate limit entries")
return cleaned
except Exception as e:
logger.warning(f"Failed to cleanup expired rate limits: {e}")
return 0
async def start_rate_limit_cleanup_task() -> None:
"""Start a background task to periodically clean up expired rate limit entries."""
while True:
try:
await asyncio.sleep(300) # Run every 5 minutes
cleanup_expired_rate_limits()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in rate limit cleanup task: {e}")
await asyncio.sleep(60) # Wait 1 minute before retrying
+410
View File
@@ -0,0 +1,410 @@
"""
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
# Import request models for in-process calls
logger = logging.getLogger("uvicorn.error")
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).
"""
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 "http://127.0.0.1:8302"
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",
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()
if mod:
try:
# In-process: call the upload-base64 endpoint directly
return await mod.upload_base64_file(
None, # request - not needed for in-process
filename=filename,
data_b64=encrypted_file_data_b64,
content_type=content_type,
) # type: ignore
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 "http://127.0.0.1:8302"
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,
}
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,
}
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,
files=[f["encrypted_file_data_b64"] for f in 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:
import httpx
async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.post(url, json=payload)
r.raise_for_status()
return r.json()
except Exception as e:
logger.error("Failed to process message+files in messaging service: %s", e)
raise
+147
View File
@@ -0,0 +1,147 @@
"""
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, ""
+71
View File
@@ -0,0 +1,71 @@
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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,7 @@
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"]
+594
View File
@@ -0,0 +1,594 @@
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,
get_messages,
edit_message,
delete_message,
add_reaction,
add_dm_reaction,
)
from ..models import (
User,
SendMessageRequest,
EditMessageRequest,
DMEnvelope,
ReactionRequest,
DMReactionRequest,
UpdateLog,
)
from ..security.audit import log_access, log_dm
logger = logging.getLogger("uvicorn.error")
# Create global registry instance
handler_registry = WebSocketHandlerRegistry()
# Create decorator alias
websocket_handler = handler_registry.register
def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None:
"""Log WebSocket event."""
ws_path = getattr(getattr(websocket, "url", None), "path", None)
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
ws_path = websocket.scope.get("path")
ws_path = ws_path or "unknown"
headers = {}
if isinstance(getattr(websocket, "scope", None), dict):
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
xff = headers.get("x-forwarded-for")
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
log_access(
"ws_event",
path=ws_path,
event=event,
user=user.username if user else None,
user_id=user.id if user else None,
ip=client_ip,
**extra,
)
@websocket_handler("getUpdates", authRequired=True)
async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Handle gap detection - client requests updates from a specific sequence number."""
last_seq = data.get("lastSeq", 0)
manager.last_seq_by_ws[websocket] = last_seq
current_seq = manager.sequence_numbers.get(user.id, 0)
# Query database for missed updates
missed_updates = []
if last_seq > 0 and last_seq < current_seq:
try:
# Get all updates between last_seq and current_seq
update_logs = db.query(UpdateLog).filter(
UpdateLog.user_id == user.id,
UpdateLog.sequence > last_seq,
UpdateLog.sequence <= current_seq
).order_by(UpdateLog.sequence.asc()).all()
# Each log entry contains a batch of updates with the same sequence number
for log_entry in update_logs:
updates = json.loads(log_entry.updates)
missed_updates.append({
"seq": log_entry.sequence,
"updates": updates
})
except Exception as e:
logger.error(f"Failed to retrieve missed updates: {e}")
# Send missed updates directly (not through return value)
for batch in missed_updates:
await websocket.send_json({
"type": "updates",
"seq": batch["seq"],
"updates": batch["updates"]
})
# Update the websocket's last sequence tracking
manager.last_seq_by_ws[websocket] = current_seq
log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates))
return {
"status": "ok",
"lastSeq": current_seq,
"missedCount": len(missed_updates)
}
@websocket_handler("ping", authRequired=True)
async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Handle ping - authenticate and set user online."""
# Set user online in DB
user.online = True
user.last_seen = datetime.now()
db.commit()
# Add to online users
manager.online_users.add(user.id)
# Broadcast status change
await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db)
log(manager, websocket, user, "ping")
return {"status": "success"}
@websocket_handler("getMessages", authRequired=True)
async def getMessages(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Get all public chat messages."""
result = await get_messages(user, db)
log(manager, websocket, user, "getMessages")
return result
@websocket_handler("sendMessage", authRequired=True)
async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Send a public chat message."""
message_request: SendMessageRequest = SendMessageRequest.model_validate(data)
# Call internal function directly (rate limiting is handled at infrastructure level via Caddy)
response = await _send_message_internal(message_request, user, db, [])
await manager.broadcast({
"type": "newMessage",
"data": response["message"]
}, db)
log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"])
return response
@websocket_handler("dmSend", authRequired=True)
async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Send a direct message 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}")
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
sender_payload = {
"type": "dmNew",
"data": {
**base_payload,
"wrapped_mek_b64": env.sender_wrapped_mek_b64,
}
}
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("deleteMessage", authRequired=True)
async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Delete a public chat message."""
message_id = data["message_id"]
response = await delete_message(message_id, user, db)
await manager.broadcast({
"type": "messageDeleted",
"data": {"message_id": message_id}
}, db)
log(manager, websocket, user, "deleteMessage", message_id=message_id)
return response
@websocket_handler("addReaction", authRequired=True)
async def addReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Add or remove a reaction to a public chat message."""
reaction_request = ReactionRequest(
message_id=data["message_id"],
emoji=data["emoji"]
)
response = await add_reaction(reaction_request, user, db)
# Broadcast reaction update
await manager.broadcast({
"type": "reactionUpdate",
"data": {
"message_id": data["message_id"],
"emoji": data["emoji"],
"action": response["action"],
"user_id": user.id,
"username": user.username,
"reactions": response["reactions"]
}
}, db)
log(manager, websocket, user, "addReaction", message_id=data["message_id"], emoji=data["emoji"], action=response["action"])
return response
@websocket_handler("addDmReaction", authRequired=True)
async def addDmReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Add or remove a reaction to a direct message."""
reaction_request = DMReactionRequest(
dm_envelope_id=data["dm_envelope_id"],
emoji=data["emoji"]
)
response = await add_dm_reaction(reaction_request, user, db)
# Broadcast reaction update
await manager.broadcast({
"type": "dmReactionUpdate",
"data": {
"dm_envelope_id": data["dm_envelope_id"],
"emoji": data["emoji"],
"action": response["action"],
"user_id": user.id,
"username": user.username,
"reactions": response["reactions"]
}
}, db)
log(manager, websocket, user, "addDmReaction", dm_envelope_id=data["dm_envelope_id"], emoji=data["emoji"], action=response["action"])
return response
@websocket_handler("call_signaling", authRequired=True)
async def call_signaling(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Forward WebRTC signaling between peers."""
payload = data or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
# Ensure sender is set by the server
payload["fromUserId"] = user.id
payload["fromUsername"] = user.username
await manager.send_to_user(to_user_id, {
"type": "call_signaling",
"data": payload
})
log(manager, websocket, user, "call_signaling", to_user_id=to_user_id)
return {"status": "ok"}
@websocket_handler("call_video_toggle", authRequired=True)
async def call_video_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Forward video toggle state between peers."""
payload = data or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
await manager.send_update_to_user(to_user_id, "call_signaling", {
"type": "call_video_toggle",
"fromUserId": user.id,
"toUserId": to_user_id,
"data": {"enabled": payload.get("enabled", False)}
}, db)
log(manager, websocket, user, "call_video_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
return {"status": "ok"}
@websocket_handler("call_screen_share_toggle", authRequired=True)
async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Forward screen share toggle state between peers."""
payload = data or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
await manager.send_update_to_user(to_user_id, "call_signaling", {
"type": "call_screen_share_toggle",
"fromUserId": user.id,
"toUserId": to_user_id,
"data": {"enabled": payload.get("enabled", False)}
}, db)
log(manager, websocket, user, "call_screen_share_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
return {"status": "ok"}
@websocket_handler("subscribeStatus", authRequired=True)
async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Subscribe to status updates for a user."""
user_id_to_subscribe = int(data["userId"])
manager.ws_subscriptions[websocket].add(user_id_to_subscribe)
# Get current status of the user
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
if target_user:
# Send current status directly (not through return value)
await websocket.send_json({
"type": "statusUpdate",
"data": {
"userId": user_id_to_subscribe,
"online": target_user.online,
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
}
})
log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe)
return {"status": "ok"}
else:
log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found")
raise HTTPException(status_code=404, detail="User not found")
@websocket_handler("unsubscribeStatus", authRequired=True)
async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Unsubscribe from status updates for a user."""
user_id_to_unsubscribe = int(data["userId"])
manager.ws_subscriptions[websocket].discard(user_id_to_unsubscribe)
log(manager, websocket, user, "unsubscribeStatus", target_user_id=user_id_to_unsubscribe)
return {"status": "ok"}
@websocket_handler("typing", authRequired=True)
async def typing(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator start for public chat."""
was_typing = manager.typing_state.get(user.id, False)
manager.typing_users[user.id] = time.time()
# Only send update if state changed (started typing)
if not was_typing:
manager.typing_state[user.id] = True
# Broadcast to all connected users
await manager.broadcast({
"type": "typing",
"data": {
"userId": user.id,
"username": user.username
}
}, db)
# No confirmation response - privacy protection
@websocket_handler("stopTyping", authRequired=True)
async def stopTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator stop for public chat."""
was_typing = manager.typing_state.get(user.id, False)
if user.id in manager.typing_users:
del manager.typing_users[user.id]
# Only send update if state changed (stopped typing)
if was_typing:
manager.typing_state[user.id] = False
# Broadcast to all connected users
await manager.broadcast({
"type": "stopTyping",
"data": {
"userId": user.id,
"username": user.username
}
}, db)
# No confirmation response - privacy protection
log(manager, websocket, user, "stopTyping")
@websocket_handler("dmTyping", authRequired=True)
async def dmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator start for DM."""
recipient_id = int(data["recipientId"])
if user.id not in manager.dm_typing_users:
manager.dm_typing_users[user.id] = {}
if user.id not in manager.dm_typing_state:
manager.dm_typing_state[user.id] = {}
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
manager.dm_typing_users[user.id][recipient_id] = time.time()
# Only send update if state changed (started typing)
if not was_typing:
manager.dm_typing_state[user.id][recipient_id] = True
# Send only to recipient
await manager.send_update_to_user(recipient_id, "dmTyping", {
"userId": user.id,
"username": user.username
}, db)
# No confirmation response - privacy protection
@websocket_handler("stopDmTyping", authRequired=True)
async def stopDmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator stop for DM."""
recipient_id = int(data["recipientId"])
was_typing = False
if user.id in manager.dm_typing_state:
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
if user.id in manager.dm_typing_users and recipient_id in manager.dm_typing_users[user.id]:
del manager.dm_typing_users[user.id][recipient_id]
if not manager.dm_typing_users[user.id]:
del manager.dm_typing_users[user.id]
# Only send update if state changed (stopped typing)
if was_typing:
if user.id in manager.dm_typing_state:
manager.dm_typing_state[user.id][recipient_id] = False
# Send only to recipient
await manager.send_update_to_user(recipient_id, "stopDmTyping", {
"userId": user.id,
"username": user.username
}, db)
# No confirmation response - privacy protection
@@ -0,0 +1,33 @@
from typing import Callable
class WebSocketHandlerRegistry:
"""Registry for WebSocket message handlers with authentication support."""
def __init__(self):
self._handlers: dict[str, tuple[Callable, bool]] = {}
def register(self, message_type: str, authRequired: bool = True):
"""Register a handler for a message type.
Args:
message_type: The WebSocket message type to handle
authRequired: If True, handler will receive authenticated User (not None) or raise 401
"""
def decorator(func: Callable):
self._handlers[message_type] = (func, authRequired)
return func
return decorator
def get_handler(self, message_type: str) -> tuple[Callable, bool] | None:
"""Get handler and authRequired flag for a message type.
Returns:
Tuple of (handler function, authRequired flag) or None if not found
"""
return self._handlers.get(message_type)
def get_all_types(self) -> list[str]:
"""Get all registered message types for debugging/logging."""
return list(self._handlers.keys())
+92
View File
@@ -0,0 +1,92 @@
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from types import SimpleNamespace
from ..dependencies import get_current_user
from ..models import User
def extract_token_from_data(data: dict) -> str | None:
"""Extract authentication token from WebSocket message data.
Args:
data: WebSocket message data dictionary
Returns:
Token string or None if not present
"""
credentials = data.get("credentials")
if credentials and isinstance(credentials, dict):
return credentials.get("credentials")
return None
def get_current_user_from_token(token: str, db: Session) -> User | None:
"""Get user from authentication token.
Args:
token: JWT token string
db: Database session
Returns:
User object or None if token is invalid
"""
try:
# Ensure session is in a usable state before querying
try:
db.rollback()
except Exception:
pass
dummy_request = SimpleNamespace()
dummy_request.state = SimpleNamespace()
try:
from fastapi.security import HTTPBearer
security = HTTPBearer()
# We need to create credentials manually
credentials = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials=token
)
return get_current_user(dummy_request, credentials, db)
except HTTPException:
return None
except Exception:
try:
db.rollback()
except Exception:
pass
return None
def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None:
"""Authenticate user from WebSocket message data.
Args:
data: WebSocket message data dictionary
db: Database session
authRequired: If True, raises 401 on missing/invalid token
Returns:
User object (guaranteed not None if authRequired=True) or None
Raises:
HTTPException: 401 if authRequired=True and token is missing/invalid
"""
token = extract_token_from_data(data)
if authRequired:
if not token:
raise HTTPException(status_code=401, detail="Missing credentials")
user = get_current_user_from_token(token, db)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
return user
else:
if token:
return get_current_user_from_token(token, db)
return None