mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Restructure backend into microservices, add envelope encryption, DM files, and message editing
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Main service module
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
"a": "а",
|
||||
"A": "а",
|
||||
"c": "с",
|
||||
"C": "с",
|
||||
"e": "е",
|
||||
"E": "е",
|
||||
"f": "ф",
|
||||
"F": "ф",
|
||||
"g": "г",
|
||||
"G": "г",
|
||||
"i": "и",
|
||||
"I": "и",
|
||||
"m": "м",
|
||||
"M": "м",
|
||||
"n": "н",
|
||||
"N": "н",
|
||||
"o": "о",
|
||||
"O": "о",
|
||||
"p": "п",
|
||||
"P": "п",
|
||||
"s": "с",
|
||||
"S": "с",
|
||||
"t": "т",
|
||||
"T": "т",
|
||||
"u": "у",
|
||||
"U": "у",
|
||||
"v": "в",
|
||||
"V": "в",
|
||||
"x": "х",
|
||||
"X": "х",
|
||||
"y": "у",
|
||||
"Y": "у",
|
||||
"z": "з", # Full-width 'z' to Cyrillic 'з'
|
||||
"Z": "з",
|
||||
# Cyrillic to canonical Cyrillic (identity mappings)
|
||||
"а": "а",
|
||||
"с": "с",
|
||||
"е": "е",
|
||||
"ё": "е",
|
||||
"ф": "ф",
|
||||
"г": "г",
|
||||
"и": "и",
|
||||
"м": "м",
|
||||
"н": "н",
|
||||
"о": "о",
|
||||
"п": "п",
|
||||
"т": "т",
|
||||
"у": "у",
|
||||
"ү": "у", # Cyrillic capital U (U+04AE)
|
||||
"Ү": "у", # Cyrillic capital U (U+04AE)
|
||||
"в": "в",
|
||||
"х": "х",
|
||||
"р": "р",
|
||||
"з": "з", # Cyrillic 'з'
|
||||
"д": "д", # Cyrillic 'д'
|
||||
"б": "б", # Cyrillic 'б'
|
||||
"л": "л", # Cyrillic 'л'
|
||||
"я": "я", # Cyrillic 'я'
|
||||
"н": "н", # Already mapped, but explicit
|
||||
# Special characters
|
||||
"@": "а",
|
||||
# Multi-character visual bypasses (handled separately in preprocessing)
|
||||
# "}{" visually forms "х" - handled in _preprocess_visual_bypasses
|
||||
}
|
||||
|
||||
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
||||
("generic", ("айфон", "топ")),
|
||||
("generic", ("самсунг", "говно")),
|
||||
)
|
||||
|
||||
_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
|
||||
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
||||
|
||||
|
||||
def _preprocess_visual_bypasses(text: str) -> str:
|
||||
"""
|
||||
Preprocess text to convert multi-character visual bypasses to their intended letters.
|
||||
This handles cases like "}{" visually forming "х".
|
||||
"""
|
||||
result = text
|
||||
# Convert "}{" to "х" (visual bypass for Cyrillic х)
|
||||
# The curly braces visually form the letter х when placed together
|
||||
result = result.replace("}{", "х")
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_char(ch: str) -> str:
|
||||
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
||||
# First try direct mapping (preserves case for non-mapped chars)
|
||||
if ch in _LEET_MAP:
|
||||
return _LEET_MAP[ch]
|
||||
# Then try lowercase mapping
|
||||
lower = ch.lower()
|
||||
if lower in _LEET_MAP:
|
||||
return _LEET_MAP[lower]
|
||||
# If no mapping and character is ASCII letter, return lowercase
|
||||
# This preserves English words like "fromchat" as-is
|
||||
if ch.isascii() and ch.isalpha():
|
||||
return lower
|
||||
# For other characters, return lowercase for consistency
|
||||
return lower
|
||||
|
||||
|
||||
def _normalize_token(token: str) -> str:
|
||||
"""Normalize a token by mapping all homoglyphs."""
|
||||
return "".join(_normalize_char(ch) for ch in token)
|
||||
|
||||
|
||||
def _normalize_text_for_profanity(text: str) -> str:
|
||||
"""
|
||||
Normalize entire text by mapping homoglyphs to canonical forms.
|
||||
This prevents bypasses like using English 'u' instead of Russian 'у'.
|
||||
"""
|
||||
return "".join(_normalize_char(ch) for ch in text)
|
||||
|
||||
|
||||
def _strip_zero_width_chars(text: str) -> str:
|
||||
"""
|
||||
Remove zero-width characters that could be used to bypass filters.
|
||||
"""
|
||||
# Zero-width space, zero-width non-joiner, zero-width joiner, etc.
|
||||
zero_width_chars = [
|
||||
'\u200B', # Zero-width space
|
||||
'\u200C', # Zero-width non-joiner
|
||||
'\u200D', # Zero-width joiner
|
||||
'\uFEFF', # Zero-width no-break space
|
||||
'\u2060', # Word joiner
|
||||
'\u2061', # Function application
|
||||
'\u2062', # Invisible times
|
||||
'\u2063', # Invisible separator
|
||||
'\u2064', # Invisible plus
|
||||
]
|
||||
result = text
|
||||
for zw_char in zero_width_chars:
|
||||
result = result.replace(zw_char, '')
|
||||
return result
|
||||
|
||||
|
||||
def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]:
|
||||
"""
|
||||
Extract only alphanumeric characters from text and create a mapping
|
||||
from normalized positions to original positions.
|
||||
|
||||
Args:
|
||||
preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching)
|
||||
|
||||
Returns:
|
||||
(normalized_text, position_map) where position_map[i] is the original
|
||||
position of the i-th character in normalized_text
|
||||
"""
|
||||
# First preprocess visual bypasses (like "}{" -> "х")
|
||||
text = _preprocess_visual_bypasses(text)
|
||||
|
||||
# Then normalize Unicode (composed vs decomposed)
|
||||
normalized_unicode = unicodedata.normalize('NFKC', text)
|
||||
|
||||
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
||||
if preserve_spaces:
|
||||
zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064']
|
||||
for zw_char in zero_width_chars:
|
||||
normalized_unicode = normalized_unicode.replace(zw_char, ' ')
|
||||
else:
|
||||
# Strip zero-width characters
|
||||
normalized_unicode = _strip_zero_width_chars(normalized_unicode)
|
||||
|
||||
normalized = []
|
||||
position_map = []
|
||||
|
||||
for i, ch in enumerate(normalized_unicode):
|
||||
# Check if character is alphanumeric (including Cyrillic)
|
||||
if ch.isalnum():
|
||||
# For phrase matching, preserve ASCII letters as-is (just lowercase)
|
||||
# to allow English words in patterns to match
|
||||
if preserve_spaces and ch.isascii() and ch.isalpha():
|
||||
normalized.append(ch.lower())
|
||||
else:
|
||||
# Normalize this character (homoglyphs, Cyrillic, etc.)
|
||||
normalized.append(_normalize_char(ch))
|
||||
position_map.append(i)
|
||||
elif preserve_spaces:
|
||||
# For phrase matching, treat any whitespace or non-alphanumeric as word separator
|
||||
if ch.isspace() or not ch.isalnum():
|
||||
# Normalize to single space to allow patterns to match
|
||||
if normalized and normalized[-1] != ' ': # Don't add consecutive spaces
|
||||
normalized.append(' ')
|
||||
position_map.append(i)
|
||||
|
||||
return "".join(normalized), position_map
|
||||
|
||||
|
||||
def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Check for profane words as substrings or subsequences in normalized text.
|
||||
This catches cases like "хуй" in "хууй" (with extra characters).
|
||||
Returns list of (start, end) positions where profanity is found.
|
||||
"""
|
||||
spans = []
|
||||
normalized_lower = normalized_text.lower()
|
||||
|
||||
for word in profane_words:
|
||||
word_lower = word.lower()
|
||||
|
||||
# First try exact substring match
|
||||
start = 0
|
||||
while True:
|
||||
pos = normalized_lower.find(word_lower, start)
|
||||
if pos == -1:
|
||||
break
|
||||
spans.append((pos, pos + len(word_lower)))
|
||||
start = pos + 1
|
||||
|
||||
# Also check if profane word appears as a subsequence (allowing extra chars)
|
||||
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
|
||||
# Now applies to ALL words, not just length >= 4, to prevent bypasses
|
||||
word_chars = list(word_lower)
|
||||
text_chars = list(normalized_lower)
|
||||
|
||||
# Stricter span limits based on word length to prevent false positives
|
||||
# Shorter words get much stricter limits
|
||||
if len(word_lower) <= 3:
|
||||
max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй")
|
||||
elif len(word_lower) == 4:
|
||||
max_span_ratio = 1.4 # Strict for 4-char words
|
||||
elif len(word_lower) <= 5:
|
||||
max_span_ratio = 1.5 # Moderate for 5-char words
|
||||
else:
|
||||
max_span_ratio = 1.8 # Slightly more lenient for longer words
|
||||
|
||||
# Try to find the word as a subsequence
|
||||
i = 0 # position in text
|
||||
j = 0 # position in word
|
||||
seq_start = None
|
||||
|
||||
while i < len(text_chars) and j < len(word_chars):
|
||||
if text_chars[i] == word_chars[j]:
|
||||
if seq_start is None:
|
||||
seq_start = i
|
||||
j += 1
|
||||
if j == len(word_chars):
|
||||
# Found the word as subsequence
|
||||
seq_end = i + 1
|
||||
# Check if the span is reasonable (not too long)
|
||||
span_length = seq_end - seq_start
|
||||
max_allowed_span = int(len(word_lower) * max_span_ratio)
|
||||
if span_length <= max_allowed_span:
|
||||
# Only add if it's not already covered by exact match
|
||||
if (seq_start, seq_end) not in spans:
|
||||
spans.append((seq_start, seq_end))
|
||||
# Reset to find next occurrence - continue from after the end of this match
|
||||
next_start = seq_start + 1
|
||||
seq_start = None
|
||||
j = 0
|
||||
i = next_start
|
||||
continue
|
||||
i += 1
|
||||
|
||||
return spans
|
||||
|
||||
|
||||
def _check_profanity_in_normalized(normalized_text: str) -> bool:
|
||||
"""
|
||||
Check if normalized text contains profanity.
|
||||
Uses both better_profanity library and substring matching for better detection.
|
||||
|
||||
Returns True if profanity is found.
|
||||
"""
|
||||
if not normalized_text:
|
||||
return False
|
||||
|
||||
# Check normalized text for profanity using better_profanity
|
||||
censored = _profanity.censor(normalized_text, censor_char="\\*")
|
||||
|
||||
# Check if better_profanity found anything
|
||||
if "*" in censored:
|
||||
return True
|
||||
|
||||
# Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня")
|
||||
profane_words = _STATIC_TERMS
|
||||
substring_spans = _check_profanity_substrings(normalized_text, profane_words)
|
||||
|
||||
# If we found any substring matches, there's profanity
|
||||
if substring_spans:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
|
||||
tokens: List[Tuple[int, int, str]] = []
|
||||
start: int | None = None
|
||||
buffer: List[str] = []
|
||||
|
||||
for idx, ch in enumerate(text):
|
||||
if ch.isalnum() or ch in {"@", "#", "_"}:
|
||||
if start is None:
|
||||
start = idx
|
||||
buffer.append(ch)
|
||||
else:
|
||||
if buffer and start is not None:
|
||||
token_raw = "".join(buffer)
|
||||
tokens.append((start, idx, _normalize_token(token_raw)))
|
||||
buffer.clear()
|
||||
start = None
|
||||
if buffer and start is not None:
|
||||
token_raw = "".join(buffer)
|
||||
tokens.append((start, len(text), _normalize_token(token_raw)))
|
||||
return tokens
|
||||
|
||||
|
||||
def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool:
|
||||
if a == b:
|
||||
return True
|
||||
if max_distance <= 0:
|
||||
return False
|
||||
if abs(len(a) - len(b)) > max_distance:
|
||||
return False
|
||||
|
||||
previous = list(range(len(b) + 1))
|
||||
for i, ca in enumerate(a, 1):
|
||||
current = [i]
|
||||
best = current[0]
|
||||
for j, cb in enumerate(b, 1):
|
||||
insert_cost = current[j - 1] + 1
|
||||
delete_cost = previous[j] + 1
|
||||
replace_cost = previous[j - 1] + (0 if ca == cb else 1)
|
||||
cost = min(insert_cost, delete_cost, replace_cost)
|
||||
current.append(cost)
|
||||
if cost < best:
|
||||
best = cost
|
||||
if best > max_distance:
|
||||
return False
|
||||
previous = current
|
||||
return previous[-1] <= max_distance
|
||||
|
||||
|
||||
def _load_sensitive_phrases() -> List[Tuple[str, ...]]:
|
||||
if not _SENSITIVE_PHRASE_PATH.exists():
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8"))
|
||||
phrases: List[Tuple[str, ...]] = []
|
||||
if isinstance(payload, list):
|
||||
for entry in payload:
|
||||
if isinstance(entry, list) and entry:
|
||||
normalized = tuple(str(part).strip() for part in entry if str(part).strip())
|
||||
if normalized:
|
||||
phrases.append(normalized)
|
||||
return phrases
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]:
|
||||
if group not in _PHRASE_CACHE:
|
||||
base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group]
|
||||
if group == "sensitive":
|
||||
base.extend(_load_sensitive_phrases())
|
||||
_PHRASE_CACHE[group] = tuple(
|
||||
tuple(_normalize_token(part) for part in phrase)
|
||||
for phrase in base
|
||||
)
|
||||
return _PHRASE_CACHE[group]
|
||||
|
||||
|
||||
def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]:
|
||||
tokens = _tokenize_with_spans(text)
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
spans: List[Tuple[int, int]] = []
|
||||
normalized_phrases = _get_phrases(group)
|
||||
|
||||
for index in range(len(tokens)):
|
||||
for phrase in normalized_phrases:
|
||||
if index + len(phrase) > len(tokens):
|
||||
continue
|
||||
matches = True
|
||||
for offset, target in enumerate(phrase):
|
||||
token = tokens[index + offset][2]
|
||||
if not _edit_distance_limited(token, target):
|
||||
matches = False
|
||||
break
|
||||
if matches:
|
||||
span_start = tokens[index][0]
|
||||
span_end = tokens[index + len(phrase) - 1][1]
|
||||
spans.append((span_start, span_end))
|
||||
return spans
|
||||
|
||||
_dictionary_lock = RLock()
|
||||
_blocklist_signature: Tuple[str, ...] | None = None
|
||||
_profanity = Profanity()
|
||||
|
||||
|
||||
def _normalize_words(words: Iterable[str]) -> Set[str]:
|
||||
normalized: Set[str] = set()
|
||||
for raw in words:
|
||||
if not raw:
|
||||
continue
|
||||
cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower()
|
||||
if cleaned:
|
||||
normalized.add(cleaned)
|
||||
return normalized
|
||||
|
||||
|
||||
def _load_blocklist() -> Set[str]:
|
||||
if not BLOCKLIST_PATH.exists():
|
||||
return set()
|
||||
try:
|
||||
data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return _normalize_words(data)
|
||||
except Exception:
|
||||
pass
|
||||
return set()
|
||||
|
||||
|
||||
def _write_blocklist(words: Iterable[str]) -> None:
|
||||
BLOCKLIST_PATH.write_text(
|
||||
json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _rebuild_dictionary(force: bool = False) -> None:
|
||||
global _profanity, _blocklist_signature
|
||||
with _dictionary_lock:
|
||||
blocklist_list = sorted(_load_blocklist())
|
||||
signature = tuple(blocklist_list)
|
||||
if not force and _blocklist_signature == signature and _blocklist_signature is not None:
|
||||
return
|
||||
|
||||
profanity = Profanity()
|
||||
profanity.load_censor_words()
|
||||
# Remove whitelisted words from the default word list
|
||||
try:
|
||||
for word in _WHITELIST:
|
||||
profanity.remove_censor_words([word])
|
||||
except AttributeError:
|
||||
# If remove_censor_words doesn't exist, we'll handle it in post-processing
|
||||
pass
|
||||
combined = set(_STATIC_TERMS)
|
||||
combined.update(blocklist_list)
|
||||
# Remove whitelisted words from our custom terms
|
||||
combined -= _WHITELIST
|
||||
if combined:
|
||||
profanity.add_censor_words(list(combined))
|
||||
|
||||
_profanity = profanity
|
||||
_blocklist_signature = signature
|
||||
|
||||
|
||||
def _check_phrase_patterns(text: str) -> bool:
|
||||
"""
|
||||
Check if text matches any phrase patterns.
|
||||
Returns True if any pattern matches.
|
||||
"""
|
||||
# Normalize text for phrase matching (remove special chars but preserve spaces)
|
||||
normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True)
|
||||
normalized_lower = normalized_text.lower()
|
||||
|
||||
# Check phrase patterns
|
||||
for pattern in _PHRASE_PATTERNS:
|
||||
if pattern.search(normalized_lower):
|
||||
return True
|
||||
|
||||
# Check fuzzy phrase spans
|
||||
if _find_fuzzy_phrase_spans(normalized_lower, "generic"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def contains_profanity(text: str) -> bool:
|
||||
"""
|
||||
Check if text contains profanity.
|
||||
Returns True if profanity is detected.
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
_rebuild_dictionary()
|
||||
|
||||
# Check original text patterns first (before normalization) to catch visual bypasses
|
||||
# like "}{" used to form "х"
|
||||
for pattern in _ORIGINAL_TEXT_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return True
|
||||
|
||||
# Check phrase patterns
|
||||
if _check_phrase_patterns(text):
|
||||
return True
|
||||
|
||||
# Normalize text for whitelist matching (to handle special characters)
|
||||
normalized_for_whitelist, _ = _extract_alphanumeric_with_mapping(text)
|
||||
normalized_for_whitelist_lower = normalized_for_whitelist.lower()
|
||||
|
||||
# Check if text contains whitelisted words - if the entire text is a whitelisted word, skip profanity check
|
||||
for whitelist_word in _WHITELIST:
|
||||
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
|
||||
normalized_whitelist_lower = normalized_whitelist.lower()
|
||||
|
||||
# Check if the normalized text exactly matches a whitelisted word
|
||||
if normalized_for_whitelist_lower == normalized_whitelist_lower:
|
||||
return False
|
||||
|
||||
# Extract only alphanumeric characters and normalize homoglyphs
|
||||
# This removes special characters, emojis, etc. that could be used to bypass the filter
|
||||
normalized_text, _ = _extract_alphanumeric_with_mapping(text)
|
||||
|
||||
# Check profanity on normalized text (without special characters)
|
||||
return _check_profanity_in_normalized(normalized_text)
|
||||
|
||||
|
||||
def contains_sensitive_phrase(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
if _find_fuzzy_phrase_spans(text, "sensitive"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_blocklist() -> List[str]:
|
||||
with _dictionary_lock:
|
||||
return sorted(_load_blocklist())
|
||||
|
||||
|
||||
def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
||||
normalized = _normalize_words(words)
|
||||
if not normalized:
|
||||
return [], get_blocklist()
|
||||
|
||||
with _dictionary_lock:
|
||||
current = _load_blocklist()
|
||||
added = sorted(normalized - current)
|
||||
if not added:
|
||||
return [], sorted(current)
|
||||
|
||||
updated = sorted(current | normalized)
|
||||
_write_blocklist(updated)
|
||||
_rebuild_dictionary(force=True)
|
||||
return added, updated
|
||||
|
||||
|
||||
def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]:
|
||||
normalized = _normalize_words(words)
|
||||
if not normalized:
|
||||
return [], get_blocklist()
|
||||
|
||||
with _dictionary_lock:
|
||||
current = _load_blocklist()
|
||||
removed = sorted(word for word in normalized if word in current)
|
||||
if not removed:
|
||||
return [], sorted(current)
|
||||
|
||||
updated = sorted(current - normalized)
|
||||
_write_blocklist(updated)
|
||||
_rebuild_dictionary(force=True)
|
||||
return removed, updated
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, ""
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user