From 8ab5e4e07171af552277bc443a10ace7f44e5e5c Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 7 Jan 2026 15:07:30 +0300 Subject: [PATCH] Fix inter-services communication --- backend/app.py | 192 ++++++++++-------- backend/routes/messaging.py | 36 +++- backend/routes/profile.py | 16 +- backend/routes/push.py | 27 ++- backend/run_local.py | 14 +- backend/services/gateway/main.py | 10 +- .../messaging/files}/websocket/__init__.py | 0 .../messaging/files}/websocket/handlers.py | 0 .../messaging/files}/websocket/registry.py | 0 .../messaging/files}/websocket/utils.py | 0 .../{ => services/push/files}/push_service.py | 0 backend/shared/models.py | 75 ++++--- backend/{ => shared}/similarity.py | 0 deployment/db-init/01-init-roles.sql | 16 +- deployment/docker-compose.yml | 34 ++++ deployment/frontend/server.ts | 12 +- docker/Dockerfile.multi | 15 +- 17 files changed, 287 insertions(+), 160 deletions(-) rename backend/{ => services/messaging/files}/websocket/__init__.py (100%) rename backend/{ => services/messaging/files}/websocket/handlers.py (100%) rename backend/{ => services/messaging/files}/websocket/registry.py (100%) rename backend/{ => services/messaging/files}/websocket/utils.py (100%) rename backend/{ => services/push/files}/push_service.py (100%) rename backend/{ => shared}/similarity.py (100%) diff --git a/backend/app.py b/backend/app.py index 7d06f3f..64be816 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,103 +1,43 @@ import asyncio import time -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, HTTPException from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import subprocess import sys import os -from routes import account, messaging, profile, push, webrtc, devices, moderation +import httpx import logging -from backend.shared.models import User -from backend.shared.constants import OWNER_USERNAME +# Gateway doesn't need direct model access - it's a stateless proxy +# Gateway doesn't need constants - it's a stateless proxy from backend.shared.utils import get_client_ip -from backend.shared.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 +# Gateway doesn't need database access - it's a stateless proxy +from backend.logging_config import access_logger # noqa: F401 - ensure loggers configured +from backend.security.audit import log_access +from backend.security.rate_limit import limiter from slowapi.middleware import SlowAPIMiddleware +# Service URL mapping for routing +SERVICE_URLS = { + "account": os.getenv("ACCOUNT_SERVICE_URL", "http://account_service:8302"), + "profile": os.getenv("PROFILE_SERVICE_URL", "http://profile_service:8303"), + "devices": os.getenv("DEVICE_SERVICE_URL", "http://device_service:8304"), + "messaging": os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305"), + "push": os.getenv("PUSH_SERVICE_URL", "http://push_service:8306"), + "webrtc": os.getenv("WEBRTC_SERVICE_URL", "http://webrtc_service:8307"), + "moderation": os.getenv("MODERATION_SERVICE_URL", "http://moderation_service:8308"), +} + logger = logging.getLogger("uvicorn.error") @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 - 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__)) - ) - 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: - from 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 - + # Gateway is a stateless proxy - no database operations or background tasks needed + logger.info("Gateway proxy service initialized - routing to microservices") yield - - # Shutdown - cancel cleanup task if it exists - if cleanup_task: - cleanup_task.cancel() - try: - await cleanup_task - except asyncio.CancelledError: - pass + logger.info("Gateway proxy service shutting down.") # Инициализация FastAPI app = FastAPI(title="FromChat", lifespan=lifespan) @@ -168,11 +108,83 @@ app.add_middleware( allow_headers=["*"], ) -# Routes -app.include_router(account.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) \ No newline at end of file +# Common API endpoints - route to appropriate services (defined first for priority) +@app.api_route("/login", methods=["POST"]) +async def login(request: Request): + """Login endpoint - routes to account service.""" + return await _proxy_to_service("account", "login", request) + +@app.api_route("/register", methods=["POST"]) +async def register(request: Request): + """Register endpoint - routes to account service.""" + return await _proxy_to_service("account", "register", request) + +@app.api_route("/chat/ws", methods=["GET"]) +async def chat_websocket(request: Request): + """Chat WebSocket endpoint - routes to messaging service.""" + return await _proxy_to_service("messaging", "chat/ws", request) + +# API routes - route to appropriate microservices +@app.api_route("/account/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) +async def proxy_account(path: str, request: Request): + """Proxy account service requests.""" + return await _proxy_to_service("account", path, request) + +@app.api_route("/profile/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) +async def proxy_profile(path: str, request: Request): + """Proxy profile service requests.""" + return await _proxy_to_service("profile", path, request) + +@app.api_route("/devices/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) +async def proxy_devices(path: str, request: Request): + """Proxy device service requests.""" + return await _proxy_to_service("devices", path, request) + +@app.api_route("/messaging/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) +async def proxy_messaging(path: str, request: Request): + """Proxy messaging service requests.""" + return await _proxy_to_service("messaging", path, request) + +@app.api_route("/push/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) +async def proxy_push(path: str, request: Request): + """Proxy push service requests.""" + return await _proxy_to_service("push", path, request) + +@app.api_route("/webrtc/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) +async def proxy_webrtc(path: str, request: Request): + """Proxy WebRTC service requests.""" + return await _proxy_to_service("webrtc", path, request) + +@app.api_route("/moderation/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) +async def proxy_moderation(path: str, request: Request): + """Proxy moderation service requests.""" + return await _proxy_to_service("moderation", path, request) + + +async def _proxy_to_service(service: str, path: str, request: Request): + """Helper function to proxy requests to microservices.""" + service_url = SERVICE_URLS[service] + target_url = f"{service_url}/{service}/{path}" + + # Get request body + body = await request.body() + + # Prepare headers (remove host header) + headers = dict(request.headers) + headers.pop("host", None) + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.request( + method=request.method, + url=target_url, + headers=headers, + content=body, + params=request.query_params, + ) + return response.json() if response.headers.get("content-type", "").startswith("application/json") else response.text + except httpx.RequestError as exc: + logging.error(f"Error communicating with {service} service: {exc}") + raise HTTPException(status_code=503, detail=f"Service {service} unavailable") + +# Routes are handled by the catch-all proxy above \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 85f8e14..f914bd0 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -20,7 +20,8 @@ from backend.shared.dependencies import get_current_user, get_db from backend.shared.utils import convert_user from backend.shared.constants import OWNER_USERNAME from backend.shared.models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog -import backend.push_service as push_service +import os +import httpx from PIL import Image import io import json @@ -29,7 +30,7 @@ from better_profanity import profanity as _bp from backend.security.audit import log_access, log_dm, log_public_chat, log_security from backend.security.profanity import contains_profanity from backend.security.rate_limit import rate_limit_per_ip -from backend.websocket.utils import authenticate_user +from backend.services.messaging.files.websocket.utils import authenticate_user from backend.shared.models import FcmToken @@ -393,7 +394,16 @@ async def _send_message_internal( # Send push notifications for public messages try: - await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id) + push_service_url = os.getenv("PUSH_SERVICE_URL", "http://push_service:8306") + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{push_service_url}/push/send-public-notification", + json={ + "message_id": new_message.id, + "exclude_user_id": current_user.id + } + ) + response.raise_for_status() except Exception as e: logger.error(f"Failed to send push notification for message {new_message.id}: {e}") @@ -1560,4 +1570,22 @@ async def get_file_encrypted(filename: str, current_user: User = Depends(get_cur else: raise HTTPException(500) - return FileResponse(str(path)) \ No newline at end of file + return FileResponse(str(path)) + + +class SendSuspensionRequest(BaseModel): + user_id: int + reason: str + + +@router.post("/send-suspension") +async def send_suspension_to_user( + request: SendSuspensionRequest, + db: Session = Depends(get_db) +): + """Send suspension message to user via WebSocket (called by profile service)""" + try: + await messagingManager.send_suspension_to_user(request.user_id, request.reason) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 1ccba8e..96ad5db 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -13,8 +13,9 @@ from backend.shared.dependencies import get_db, get_current_user from backend.shared.models import User, UpdateBioRequest, UserProfileResponse from pydantic import BaseModel from backend.shared.validation import is_valid_username, is_valid_display_name -from backend.similarity import is_user_similar_to_verified -from .messaging import messagingManager +from backend.shared.similarity import is_user_similar_to_verified +import os +import httpx from backend.security.audit import log_security from backend.security.profanity import contains_profanity from backend.security.rate_limit import rate_limit_per_ip @@ -479,7 +480,16 @@ async def suspend_user( # Send WebSocket suspension message try: - await messagingManager.send_suspension_to_user(user_id, request.reason) + messaging_service_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305") + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{messaging_service_url}/messaging/send-suspension", + json={ + "user_id": user_id, + "reason": request.reason + } + ) + response.raise_for_status() except Exception as e: # Log error but don't fail the request pass diff --git a/backend/routes/push.py b/backend/routes/push.py index b54064c..fac2fc9 100644 --- a/backend/routes/push.py +++ b/backend/routes/push.py @@ -1,11 +1,16 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session +from pydantic import BaseModel from backend.shared.dependencies import get_current_user, get_db from backend.shared.models import User, PushSubscriptionRequest -import backend.push_service as push_service +from backend.services.push.files import push_service router = APIRouter() +class SendPublicNotificationRequest(BaseModel): + message_id: int + exclude_user_id: int + @router.post("/subscribe") async def subscribe_to_push_notifications( request: PushSubscriptionRequest, @@ -37,10 +42,28 @@ async def unsubscribe_from_push_notifications( """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)) + +@router.post("/send-public-notification") +async def send_public_message_notification( + request: SendPublicNotificationRequest, + db: Session = Depends(get_db) +): + """Send push notification for public message (called by messaging service)""" + try: + # Get the message from database + from backend.shared.models import Message + message = db.query(Message).filter(Message.id == request.message_id).first() + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + await push_service.send_public_message_notification(db, message, exclude_user_id=request.exclude_user_id) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/run_local.py b/backend/run_local.py index 7444ba2..3393e27 100644 --- a/backend/run_local.py +++ b/backend/run_local.py @@ -10,13 +10,13 @@ import uvicorn import os # Import service routers -from routes.account import router as account_router -from routes.profile import router as profile_router -from routes.devices import router as device_router -from routes.messaging import router as messaging_router -from routes.push import router as push_router -from routes.webrtc import router as webrtc_router -from routes.moderation import router as moderation_router +from backend.routes.account import router as account_router +from backend.routes.profile import router as profile_router +from backend.routes.devices import router as device_router +from backend.routes.messaging import router as messaging_router +from backend.routes.push import router as push_router +from backend.routes.webrtc import router as webrtc_router +from backend.routes.moderation import router as moderation_router # Import security modules from security.audit import log_access diff --git a/backend/services/gateway/main.py b/backend/services/gateway/main.py index 0ff882d..de11abb 100644 --- a/backend/services/gateway/main.py +++ b/backend/services/gateway/main.py @@ -1,10 +1,6 @@ -from fastapi import FastAPI - -# Gateway service - handles complex operations that Caddy cannot -# This will be expanded later with routing logic to other services +# Gateway service - runs the main gateway app from backend/app.py if __name__ == "__main__": - app = FastAPI(title="Gateway Service") - + from backend.app import app import os, uvicorn - uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301))) + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8300))) diff --git a/backend/websocket/__init__.py b/backend/services/messaging/files/websocket/__init__.py similarity index 100% rename from backend/websocket/__init__.py rename to backend/services/messaging/files/websocket/__init__.py diff --git a/backend/websocket/handlers.py b/backend/services/messaging/files/websocket/handlers.py similarity index 100% rename from backend/websocket/handlers.py rename to backend/services/messaging/files/websocket/handlers.py diff --git a/backend/websocket/registry.py b/backend/services/messaging/files/websocket/registry.py similarity index 100% rename from backend/websocket/registry.py rename to backend/services/messaging/files/websocket/registry.py diff --git a/backend/websocket/utils.py b/backend/services/messaging/files/websocket/utils.py similarity index 100% rename from backend/websocket/utils.py rename to backend/services/messaging/files/websocket/utils.py diff --git a/backend/push_service.py b/backend/services/push/files/push_service.py similarity index 100% rename from backend/push_service.py rename to backend/services/push/files/push_service.py diff --git a/backend/shared/models.py b/backend/shared/models.py index 7e28e34..8e7e2c2 100644 --- a/backend/shared/models.py +++ b/backend/shared/models.py @@ -9,6 +9,7 @@ Base = declarative_base() class User(Base): __tablename__ = "users" + __table_args__ = {"schema": "account_schema"} id = Column(BigInteger, primary_key=True, index=True) username = Column(String(50), unique=True, index=True, nullable=False) @@ -42,9 +43,10 @@ class User(Base): class Message(Base): __tablename__ = "messages" + __table_args__ = {"schema": "messaging_schema"} id = Column(BigInteger, primary_key=True, index=True) - sender_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True) content = Column(Text, nullable=False) content_type = Column(String(50), default="text") encrypted_content = Column(Text, nullable=True) @@ -53,8 +55,8 @@ class Message(Base): edited_at = Column(DateTime, nullable=True) edited = Column(Boolean, default=False) deleted = Column(Boolean, default=False) - reply_to_id = Column(BigInteger, ForeignKey("messages.id"), nullable=True) - thread_id = Column(BigInteger, ForeignKey("messages.id"), nullable=True) + reply_to_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True) + thread_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True) is_public = Column(Boolean, default=False) # Relationships @@ -63,13 +65,15 @@ class Message(Base): reply_to = relationship("Message", remote_side=[id], foreign_keys=[reply_to_id]) thread = relationship("Message", remote_side=[id], foreign_keys=[thread_id]) reactions = relationship("MessageReaction", back_populates="message", cascade="all, delete-orphan") + files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan") class MessageRecipient(Base): __tablename__ = "message_recipients" + __table_args__ = {"schema": "messaging_schema"} id = Column(BigInteger, primary_key=True, index=True) - message_id = Column(BigInteger, ForeignKey("messages.id"), nullable=False, index=True) - recipient_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True) + recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True) read_at = Column(DateTime, nullable=True) delivered_at = Column(DateTime, nullable=True) encrypted_key = Column(Text, nullable=True) @@ -80,10 +84,11 @@ class MessageRecipient(Base): class MessageReaction(Base): __tablename__ = "message_reactions" + __table_args__ = {"schema": "messaging_schema"} id = Column(BigInteger, primary_key=True, index=True) - message_id = Column(BigInteger, ForeignKey("messages.id"), nullable=False, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True) reaction = Column(String(50), nullable=False) created_at = Column(DateTime, default=datetime.utcnow) @@ -92,9 +97,10 @@ class MessageReaction(Base): class Device(Base): __tablename__ = "devices" + __table_args__ = {"schema": "device_schema"} id = Column(BigInteger, primary_key=True, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True) device_id = Column(String(255), unique=True, nullable=False, index=True) device_name = Column(String(255), nullable=True) device_type = Column(String(50), nullable=True) @@ -110,10 +116,11 @@ class Device(Base): class PushSubscription(Base): __tablename__ = "push_subscriptions" + __table_args__ = {"schema": "push_schema"} id = Column(BigInteger, primary_key=True, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) - device_id = Column(BigInteger, ForeignKey("devices.id"), nullable=True, index=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True) + device_id = Column(BigInteger, ForeignKey("device_schema.devices.id"), nullable=True, index=True) endpoint = Column(String(500), nullable=False) p256dh = Column(String(255), nullable=False) auth = Column(String(255), nullable=False) @@ -126,10 +133,11 @@ class PushSubscription(Base): class WebRTCSession(Base): __tablename__ = "webrtc_sessions" + __table_args__ = {"schema": "webrtc_schema"} id = Column(BigInteger, primary_key=True, index=True) session_id = Column(String(255), unique=True, nullable=False, index=True) - initiator_id = Column(BigInteger, ForeignKey("users.id"), nullable=False) + initiator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False) participant_ids = Column(JSON, nullable=False) offer = Column(JSON, nullable=True) answer = Column(JSON, nullable=True) @@ -140,11 +148,12 @@ class WebRTCSession(Base): class ModerationAction(Base): __tablename__ = "moderation_actions" + __table_args__ = {"schema": "moderation_schema"} id = Column(BigInteger, primary_key=True, index=True) - moderator_id = Column(BigInteger, ForeignKey("users.id"), nullable=False) - target_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=True) - target_message_id = Column(BigInteger, ForeignKey("messages.id"), nullable=True) + moderator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False) + target_user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=True) + target_message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True) action_type = Column(String(50), nullable=False) reason = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) @@ -153,9 +162,10 @@ class ModerationAction(Base): class MessageFile(Base): __tablename__ = "message_file" + __table_args__ = {"schema": "messaging_schema"} id = Column(Integer, primary_key=True, index=True) - message_id = Column(BigInteger, ForeignKey("messages.id"), nullable=False, index=True) + message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True) path = Column(Text, nullable=False) name = Column(Text, nullable=False) @@ -166,7 +176,7 @@ class CryptoPublicKey(Base): __tablename__ = "crypto_public_key" id = Column(Integer, primary_key=True, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, unique=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True) public_key_b64 = Column(Text, nullable=False) @@ -174,16 +184,17 @@ class CryptoBackup(Base): __tablename__ = "crypto_backup" id = Column(Integer, primary_key=True, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, unique=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True) blob_json = Column(Text, nullable=False) class DMEnvelope(Base): __tablename__ = "dm_envelope" + __table_args__ = {"schema": "messaging_schema"} id = Column(Integer, primary_key=True, index=True) - sender_id = Column(BigInteger, ForeignKey("users.id"), nullable=False) - recipient_id = Column(BigInteger, ForeignKey("users.id"), nullable=False) + sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False) + recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False) iv_b64 = Column(Text, nullable=False) ciphertext_b64 = Column(Text, nullable=False) salt_b64 = Column(Text, nullable=False) @@ -197,11 +208,12 @@ class DMEnvelope(Base): class DMFile(Base): __tablename__ = "dm_file" + __table_args__ = {"schema": "messaging_schema"} id = Column(Integer, primary_key=True, index=True) - message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True) - sender_id = Column(BigInteger, ForeignKey("users.id"), nullable=False) - recipient_id = Column(BigInteger, ForeignKey("users.id"), nullable=False) + message_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True) + sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False) + recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False) name = Column(Text, nullable=False) path = Column(Text, nullable=False) @@ -210,9 +222,10 @@ class DMFile(Base): class FcmToken(Base): __tablename__ = "fcm_token" + __table_args__ = {"schema": "push_schema"} id = Column(Integer, primary_key=True, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.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) @@ -220,10 +233,11 @@ class FcmToken(Base): class Reaction(Base): __tablename__ = "reaction" + __table_args__ = {"schema": "messaging_schema"} id = Column(Integer, primary_key=True, index=True) - message_id = Column(BigInteger, ForeignKey("messages.id"), nullable=False, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False) + message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False) emoji = Column(String(10), nullable=False) # Store emoji as string timestamp = Column(DateTime, default=datetime.now) @@ -236,10 +250,11 @@ class Reaction(Base): class DMReaction(Base): __tablename__ = "dm_reaction" + __table_args__ = {"schema": "messaging_schema"} id = Column(Integer, primary_key=True, index=True) - dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False) + dm_envelope_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False) emoji = Column(String(10), nullable=False) # Store emoji as string timestamp = Column(DateTime, default=datetime.now) @@ -254,9 +269,10 @@ class DMReaction(Base): # Tracks authenticated device sessions per user class DeviceSession(Base): __tablename__ = "device_session" + __table_args__ = {"schema": "device_schema"} id = Column(Integer, primary_key=True, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True) # Raw User-Agent for reference/debugging raw_user_agent = Column(Text, nullable=True) @@ -392,9 +408,10 @@ class DMReactionResponse(BaseModel): class UpdateLog(Base): """Stores update sequence numbers and updates for gap detection""" __tablename__ = "update_log" + __table_args__ = {"schema": "public"} id = Column(Integer, primary_key=True, index=True) - user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("account_schema.users.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) diff --git a/backend/similarity.py b/backend/shared/similarity.py similarity index 100% rename from backend/similarity.py rename to backend/shared/similarity.py diff --git a/deployment/db-init/01-init-roles.sql b/deployment/db-init/01-init-roles.sql index d7baef1..60a522b 100644 --- a/deployment/db-init/01-init-roles.sql +++ b/deployment/db-init/01-init-roles.sql @@ -2,13 +2,15 @@ -- This script creates dedicated users with limited privileges for each service -- Create service-specific database roles with limited privileges -CREATE ROLE account_service_user LOGIN PASSWORD 'account_service_password'; -CREATE ROLE profile_service_user LOGIN PASSWORD 'profile_service_password'; -CREATE ROLE device_service_user LOGIN PASSWORD 'device_service_password'; -CREATE ROLE messaging_service_user LOGIN PASSWORD 'messaging_service_password'; -CREATE ROLE push_service_user LOGIN PASSWORD 'push_service_user_password'; -CREATE ROLE webrtc_service_user LOGIN PASSWORD 'webrtc_service_password'; -CREATE ROLE moderation_service_user LOGIN PASSWORD 'moderation_service_password'; +-- All services use the same password from DB_PASSWORD environment variable +CREATE ROLE account_service_user LOGIN PASSWORD '${DB_PASSWORD}'; +CREATE ROLE profile_service_user LOGIN PASSWORD '${DB_PASSWORD}'; +CREATE ROLE device_service_user LOGIN PASSWORD '${DB_PASSWORD}'; +CREATE ROLE messaging_service_user LOGIN PASSWORD '${DB_PASSWORD}'; +CREATE ROLE push_service_user LOGIN PASSWORD '${DB_PASSWORD}'; +CREATE ROLE webrtc_service_user LOGIN PASSWORD '${DB_PASSWORD}'; +CREATE ROLE moderation_service_user LOGIN PASSWORD '${DB_PASSWORD}'; +CREATE ROLE gateway_user LOGIN PASSWORD '${DB_PASSWORD}'; -- Create dedicated schemas for each service CREATE SCHEMA IF NOT EXISTS account_schema AUTHORIZATION account_service_user; diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index c894486..c76082c 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -56,8 +56,17 @@ services: context: .. dockerfile: docker/Dockerfile.multi target: gateway + ports: ["8300:8300"] environment: DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD:-changeme}@database:5432/fromchat + PORT: 8300 + ACCOUNT_SERVICE_URL: http://account_service:8302 + PROFILE_SERVICE_URL: http://profile_service:8303 + DEVICE_SERVICE_URL: http://device_service:8304 + MESSAGING_SERVICE_URL: http://messaging_service:8305 + PUSH_SERVICE_URL: http://push_service:8306 + WEBRTC_SERVICE_URL: http://webrtc_service:8307 + MODERATION_SERVICE_URL: http://moderation_service:8308 depends_on: migration_runner: condition: service_completed_successfully @@ -95,6 +104,7 @@ services: environment: DATABASE_URL: postgresql://account_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat JWT_SECRET: ${JWT_SECRET:-changeme} + PORT: 8302 depends_on: migration_runner: condition: service_completed_successfully @@ -125,6 +135,8 @@ services: VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com} + MESSAGING_SERVICE_URL: http://messaging_service:8305 + PORT: 8303 depends_on: migration_runner: condition: service_completed_successfully @@ -151,6 +163,7 @@ services: target: device_service environment: DATABASE_URL: postgresql://device_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat + PORT: 8304 depends_on: migration_runner: condition: service_completed_successfully @@ -175,12 +188,16 @@ services: context: .. dockerfile: docker/Dockerfile.multi target: messaging_service + ports: + - "8305:8305" environment: DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat FIREBASE_CERT: ${FIREBASE_CERT:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com} + PUSH_SERVICE_URL: http://push_service:8306 + PORT: 8305 depends_on: migration_runner: condition: service_completed_successfully @@ -214,6 +231,7 @@ services: VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com} + PORT: 8306 depends_on: migration_runner: condition: service_completed_successfully @@ -243,6 +261,7 @@ services: target: webrtc_service environment: DATABASE_URL: postgresql://webrtc_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat + PORT: 8307 depends_on: migration_runner: condition: service_completed_successfully @@ -269,6 +288,7 @@ services: target: moderation_service environment: DATABASE_URL: postgresql://moderation_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat + PORT: 8308 depends_on: migration_runner: condition: service_completed_successfully @@ -315,6 +335,20 @@ services: - gateway profiles: ["prod"] + # Frontend service - serves the React app + frontend: + build: + context: .. + dockerfile: deployment/frontend/Dockerfile + ports: + - "8301:8301" + environment: + - PORT=8301 + - BACKEND_HOST=http://gateway:8300 + restart: unless-stopped + networks: + - fromchat_external + volumes: database: name: fromchat-database diff --git a/deployment/frontend/server.ts b/deployment/frontend/server.ts index a24cb02..5d58e8c 100644 --- a/deployment/frontend/server.ts +++ b/deployment/frontend/server.ts @@ -1,9 +1,10 @@ import express from 'express'; +import type { Request, Response } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { resolve } from 'path'; const app = express(); -const port = process.env.PORT || 3000; +const port = Number(process.env.PORT) || 8301; const backendHost = process.env.BACKEND_HOST || "http://localhost:8300"; const filePath = process.env.STATIC_FILE_PATH || "."; @@ -15,14 +16,17 @@ app.use('/api', createProxyMiddleware({ ws: true })); +// WebSockets are handled by the general API proxy above + // Serve static files app.use(express.static(resolve(filePath))); // SPA routing - catch all handler for client-side routing -app.use((_req, res) => { +app.use((_req: Request, res: Response) => { res.sendFile(resolve(filePath, 'index.html')); }); -app.listen(port, () => { - console.log(`Server launched on http://localhost:${port}`); +app.listen(port, '0.0.0.0', () => { + console.log(`Backend host: ${backendHost}`); + console.log(`Server launched on http://0.0.0.0:${port}`); }); diff --git a/docker/Dockerfile.multi b/docker/Dockerfile.multi index 38baad1..c959b30 100644 --- a/docker/Dockerfile.multi +++ b/docker/Dockerfile.multi @@ -52,11 +52,11 @@ ENV SERVICE_NAME=account FROM base AS profile_service COPY backend/routes/profile.py /app/backend/routes/profile.py COPY backend/routes/messaging.py /app/backend/routes/messaging.py -COPY backend/push_service.py /app/backend/push_service.py +COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py COPY backend/security /app/backend/security/ COPY backend/logging_config.py /app/backend/logging_config.py -COPY backend/websocket /app/backend/websocket/ -COPY backend/similarity.py /app/backend/similarity.py +COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/ +COPY backend/shared/similarity.py /app/backend/shared/similarity.py COPY backend/services/profile/main.py /app/backend/services/profile/main.py ENV SERVICE_NAME=profile @@ -69,17 +69,17 @@ ENV SERVICE_NAME=device # Messaging service - minimal files only FROM base AS messaging_service COPY backend/routes/messaging.py /app/backend/routes/messaging.py -COPY backend/push_service.py /app/backend/push_service.py +COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py COPY backend/security /app/backend/security/ COPY backend/logging_config.py /app/backend/logging_config.py -COPY backend/websocket /app/backend/websocket/ +COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/ COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py ENV SERVICE_NAME=messaging # Push service - minimal files only FROM base AS push_service COPY backend/routes/push.py /app/backend/routes/push.py -COPY backend/push_service.py /app/backend/push_service.py +COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py COPY backend/services/push/main.py /app/backend/services/push/main.py ENV SERVICE_NAME=push @@ -93,7 +93,7 @@ ENV SERVICE_NAME=webrtc FROM base AS moderation_service COPY backend/routes/moderation.py /app/backend/routes/moderation.py COPY backend/security /app/backend/security/ -COPY backend/similarity.py /app/backend/similarity.py +COPY backend/shared/similarity.py /app/backend/shared/similarity.py COPY backend/logging_config.py /app/backend/logging_config.py COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py ENV SERVICE_NAME=moderation @@ -103,6 +103,7 @@ FROM base AS gateway COPY backend/app.py /app/backend/app.py COPY backend/main.py /app/backend/main.py COPY backend/dependencies.py /app/backend/dependencies.py +COPY backend/logging_config.py /app/backend/logging_config.py COPY backend/security /app/backend/security/ COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py ENV SERVICE_NAME=gateway