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

This commit is contained in:
2026-01-10 14:59:31 +03:00
Unverified
parent 1f706eaa34
commit fd4c00057c
74 changed files with 6647 additions and 820 deletions
+269
View File
@@ -0,0 +1,269 @@
import asyncio
import time
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import subprocess
import sys
import os
import logging
# Import from same directory
from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging
from .models import User
from .constants import OWNER_USERNAME
from .utils import get_client_ip
from .db import POOL_CONFIG, SessionLocal
from .logging_config import access_logger # noqa: F401 - ensure loggers configured
from .security.audit import log_access
from .security.rate_limit import limiter
from slowapi.middleware import SlowAPIMiddleware
logger = logging.getLogger("uvicorn.error")
def _running_in_docker() -> bool:
"""
Detect whether the process is running inside a Docker container.
Uses presence of /.dockerenv or checking cgroup entries for docker/kubernetes.
"""
try:
if os.path.exists("/.dockerenv"):
return True
# Check cgroup for docker/kubepods indicators
cgroup_path = "/proc/1/cgroup"
if os.path.exists(cgroup_path):
with open(cgroup_path, "rt", encoding="utf-8") as f:
data = f.read()
if "docker" in data or "kubepods" in data or "containerd" in data:
return True
except Exception:
pass
return False
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup - run migration in subprocess to avoid logging interference
try:
logger.info("Starting database migration check...")
# Run migration in a separate process
result = subprocess.run(
[
sys.executable,
"-c",
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
],
cwd=os.path.dirname(os.path.abspath(__file__)),
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
logger.error(f"Migration subprocess failed with code {result.returncode}")
if result.stdout:
logger.error(f"Migration stdout: {result.stdout}")
if result.stderr:
logger.error(f"Migration stderr: {result.stderr}")
else:
logger.info("Database migrations completed successfully")
except Exception as e:
logger.error(f"Failed to run database migrations: {e}")
raise
try:
with SessionLocal() as db:
owner = db.query(User).filter(User.id == 1).first()
if owner and not owner.verified:
owner.verified = True
db.commit()
logger.info(f"Owner user '{OWNER_USERNAME}' has been verified")
elif owner and owner.verified:
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
else:
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
except Exception as e:
logger.error(f"Failed to ensure owner verification: {e}")
logger.info(
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
POOL_CONFIG["pool_size"],
POOL_CONFIG["max_overflow"],
POOL_CONFIG["pool_timeout"],
POOL_CONFIG["pool_recycle"],
POOL_CONFIG["pool_pre_ping"],
)
# Start the messaging cleanup task
try:
# Use absolute import to avoid import errors when package context differs
from services.main.routes.messaging import messagingManager
messagingManager.start_cleanup_task()
logger.info("Messaging cleanup task started")
except Exception as e:
logger.error(f"Failed to start messaging cleanup task: {e}")
# Reset all rate limits on startup to ensure clean state
# This prevents rate limits from persisting across restarts
try:
from .security.rate_limit import reset_all_rate_limits
cleared = reset_all_rate_limits()
if cleared > 0:
logger.info(f"Cleared {cleared} rate limit entries on startup")
except Exception as e:
logger.warning(f"Failed to reset rate limits on startup: {e}")
# Start the rate limit cleanup task
try:
from .security.rate_limit import start_rate_limit_cleanup_task
cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task())
logger.info("Rate limit cleanup task started")
except Exception as e:
logger.error(f"Failed to start rate limit cleanup task: {e}")
cleanup_task = None
yield
# Shutdown - cancel cleanup task if it exists
if cleanup_task:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
# Initialize FastAPI
app = FastAPI(title="FromChat", lifespan=lifespan)
# Add rate limiting middleware
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
# In development (not running inside Docker), mount messaging and file_storage apps directly
if not _running_in_docker():
try:
# Import sub-apps from the services package and mount them to the main app
# Try absolute import first, fall back to relative import
try:
from backend.services.messaging import main as messaging_service_module
from backend.services.file_storage import main as file_storage_service_module
except (ImportError, ModuleNotFoundError):
# Fall back to relative imports when backend is not in path
import sys
import os
current_dir = os.path.dirname(os.path.abspath(__file__))
services_dir = os.path.dirname(current_dir)
backend_dir = os.path.dirname(services_dir)
sys.path.insert(0, backend_dir)
from services.messaging import main as messaging_service_module
from services.file_storage import main as file_storage_service_module
# Mount as sub-applications so their routes are available in-process for development
app.mount("/internal/messaging", messaging_service_module.app)
app.mount("/internal/file_storage", file_storage_service_module.app)
logger.info("Mounted messaging and file_storage services in development mode")
except Exception as e:
# If mounting fails, continue without blocking startup; log for debugging
logger.warning(f"Failed to mount internal services for development: {e}")
@app.middleware("http")
async def access_logging_middleware(request: Request, call_next):
# Log incoming request and Authorization header presence for debugging auth issues
try:
auth_header = request.headers.get("authorization")
if auth_header:
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
else:
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
except Exception:
pass
start = time.perf_counter()
try:
response = await call_next(request)
except Exception as exc:
duration = time.perf_counter() - start
user = getattr(getattr(request, "state", None), "current_user", None)
log_access(
"http_error",
method=request.method,
path=request.url.path,
status="error",
user=getattr(user, "username", None),
ip=get_client_ip(request),
duration=f"{duration:.3f}s",
error=str(exc),
)
raise
else:
duration = time.perf_counter() - start
user = getattr(getattr(request, "state", None), "current_user", None)
log_access(
"http_request",
method=request.method,
path=request.url.path,
status=response.status_code,
user=getattr(user, "username", None),
ip=get_client_ip(request),
duration=f"{duration:.3f}s",
)
return response
# Add security middleware (request size limiting and audit logging)
try:
from services.shared.middleware import add_security_middleware
except ImportError:
try:
from backend.services.shared.middleware import add_security_middleware
except ImportError:
add_security_middleware = None
if add_security_middleware:
add_security_middleware(app)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://fromchat.ru",
"https://beta.fromchat.ru",
"https://www.fromchat.ru",
"http://127.0.0.1:8301",
"http://127.0.0.1:8300",
"http://localhost:8301",
"http://localhost:8300",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routes
app.include_router(account.router)
app.include_router(envelope_messaging.router)
app.include_router(messaging.router)
app.include_router(profile.router)
app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(devices.router, prefix="/devices")
app.include_router(moderation.router)
app.include_router(download.router)
app.include_router(keys.router)
@app.get("/key/public")
async def key_public_proxy():
"""
Proxy endpoint for messaging public key. In dev this calls the in-process function,
in production it will proxy to the external messaging service via the keys helper.
"""
return await keys.get_public_key()
@app.post("/key/invalidate")
async def key_invalidate_proxy():
"""
Proxy endpoint to invalidate messaging ephemeral key.
"""
return await keys.invalidate_key()