From 75ef3fc6044d6948f7b585133e45b391dd48e8c1 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 6 Jan 2026 17:28:25 +0300 Subject: [PATCH] Implement real, working microservices architecture --- .gitignore | 3 +- backend/alembic.ini | 4 +- backend/alembic/env.py | 2 +- backend/app.py | 8 +- backend/main.py | 12 +- backend/migration.py | 83 ++-- backend/push_service.py | 3 +- backend/requirements.txt | 4 +- backend/routes/account.py | 22 +- backend/routes/devices.py | 6 +- backend/routes/messaging.py | 22 +- backend/routes/moderation.py | 12 +- backend/routes/profile.py | 16 +- backend/routes/push.py | 6 +- backend/routes/webrtc.py | 2 +- backend/run_local.py | 80 ++++ backend/security/audit.py | 2 +- backend/security/profanity.py | 2 +- backend/security/rate_limit.py | 2 +- backend/services/account/Dockerfile | 11 + backend/services/account/main.py | 9 + backend/services/device/Dockerfile | 11 + backend/services/device/main.py | 9 + backend/services/gateway/Dockerfile | 14 + backend/services/gateway/main.py | 10 + backend/services/messaging/Dockerfile | 12 + backend/services/messaging/main.py | 9 + backend/services/migration_runner/Dockerfile | 15 + backend/services/migration_runner/main.py | 51 +++ backend/services/moderation/Dockerfile | 13 + backend/services/moderation/main.py | 9 + backend/services/profile/Dockerfile | 11 + backend/services/profile/main.py | 9 + backend/services/push/Dockerfile | 12 + backend/services/push/main.py | 9 + backend/services/webrtc/Dockerfile | 11 + backend/services/webrtc/main.py | 9 + backend/shared/__init__.py | 1 + backend/shared/constants.py | 50 +++ backend/shared/db.py | 62 +++ backend/shared/dependencies.py | 122 ++++++ backend/shared/models.py | 404 +++++++++++++++++++ backend/shared/utils.py | 234 +++++++++++ backend/shared/validation.py | 132 ++++++ backend/websocket/__init__.py | 2 +- backend/websocket/utils.py | 4 +- deployment/db-init/01-init-roles.sql | 68 ++++ deployment/db-init/02-init-tables.sql | 127 ++++++ deployment/docker-compose.yml | 369 ++++++++++++++--- docker/Dockerfile.multi | 122 ++++++ docker/entrypoint.sh | 5 + scripts/deploy.sh | 26 +- 52 files changed, 2088 insertions(+), 165 deletions(-) create mode 100644 backend/run_local.py create mode 100644 backend/services/account/Dockerfile create mode 100644 backend/services/account/main.py create mode 100644 backend/services/device/Dockerfile create mode 100644 backend/services/device/main.py create mode 100644 backend/services/gateway/Dockerfile create mode 100644 backend/services/gateway/main.py create mode 100644 backend/services/messaging/Dockerfile create mode 100644 backend/services/messaging/main.py create mode 100644 backend/services/migration_runner/Dockerfile create mode 100644 backend/services/migration_runner/main.py create mode 100644 backend/services/moderation/Dockerfile create mode 100644 backend/services/moderation/main.py create mode 100644 backend/services/profile/Dockerfile create mode 100644 backend/services/profile/main.py create mode 100644 backend/services/push/Dockerfile create mode 100644 backend/services/push/main.py create mode 100644 backend/services/webrtc/Dockerfile create mode 100644 backend/services/webrtc/main.py create mode 100644 backend/shared/__init__.py create mode 100644 backend/shared/constants.py create mode 100644 backend/shared/db.py create mode 100644 backend/shared/dependencies.py create mode 100644 backend/shared/models.py create mode 100644 backend/shared/utils.py create mode 100644 backend/shared/validation.py create mode 100644 deployment/db-init/01-init-roles.sql create mode 100644 deployment/db-init/02-init-tables.sql create mode 100644 docker/Dockerfile.multi create mode 100755 docker/entrypoint.sh diff --git a/.gitignore b/.gitignore index f6527ab..dbbcd79 100644 --- a/.gitignore +++ b/.gitignore @@ -575,4 +575,5 @@ backend/alembic/** !backend/alembic/env.py !backend/alembic/script.py.mako !frontend/src/css/lib -**/*.module.scss.d.ts \ No newline at end of file +**/*.module.scss.d.ts +.cursor/plans \ No newline at end of file diff --git a/backend/alembic.ini b/backend/alembic.ini index 7d86f97..52538fc 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -5,7 +5,7 @@ # this is typically a path given in POSIX (e.g. forward slashes) # format, relative to the token %(here)s which refers to the location of this # ini file -script_location = %(here)s/alembic +script_location = alembic # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # Uncomment the line below if you want the files to be prepended with date and time @@ -84,7 +84,7 @@ path_separator = os # database URL. This is consumed by the user-maintained env.py script only. # other means of configuring database URLs may be customized within the env.py # file. -sqlalchemy.url = sqlite:///./data/database.db +# sqlalchemy.url is set dynamically from DATABASE_URL environment variable [post_write_hooks] diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 4779ac1..45ee47b 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -17,7 +17,7 @@ if config.config_file_name is not None: # add your model's MetaData object here # for 'autogenerate' support -from models import Base +from backend.shared.models import Base target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, diff --git a/backend/app.py b/backend/app.py index 12ce81e..7d06f3f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -8,11 +8,11 @@ import sys import os from routes import account, messaging, profile, push, webrtc, devices, moderation import logging -from models import User -from constants import OWNER_USERNAME -from utils import get_client_ip +from backend.shared.models import User +from backend.shared.constants import OWNER_USERNAME +from backend.shared.utils import get_client_ip -from db import POOL_CONFIG, SessionLocal +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 diff --git a/backend/main.py b/backend/main.py index 63204f8..cce2a62 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,7 @@ -from constants import * -from db import * -from models import * -from validation import * -from utils import * -from dependencies import * +from backend.shared.constants import * +from backend.shared.db import * +from backend.shared.models import * +from backend.shared.validation import * +from backend.shared.utils import * +from backend.shared.dependencies import * from app import * \ No newline at end of file diff --git a/backend/migration.py b/backend/migration.py index c8d67c6..e48a669 100644 --- a/backend/migration.py +++ b/backend/migration.py @@ -8,7 +8,7 @@ from alembic import command from alembic.config import Config from alembic.runtime.migration import MigrationContext from sqlalchemy import create_engine -from constants import DATABASE_URL +from backend.shared.constants import DATABASE_URL import logging logger = logging.getLogger(__name__) @@ -31,13 +31,15 @@ def run_migrations(): # 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...") - from models import Base + from backend.shared.models import Base Base.metadata.create_all(bind=engine) logger.info("All tables created successfully from models.") # Get the directory where this script is located current_dir = os.path.dirname(os.path.abspath(__file__)) + # Note: Problematic migrations are now cleaned up during Docker build + # Create Alembic configuration alembic_cfg = Config(os.path.join(current_dir, "alembic.ini")) @@ -54,6 +56,13 @@ def run_migrations(): os.makedirs(versions_dir) migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] + + # If no migration files exist after cleanup, create initial migration + if not migration_files: + logger.info("No migration files found after cleanup. Creating initial migration...") + command.revision(alembic_cfg, autogenerate=True, message="Initial migration") + migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] + logger.info(f"Created {len(migration_files)} initial migration(s)") if not migration_files: logger.info("No migration files found. Creating initial migration...") @@ -179,10 +188,11 @@ def run_migrations(): 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.") + # Note: Index-related errors are now prevented by Docker build cleanup else: raise upgrade_error @@ -289,7 +299,7 @@ def _populate_migration_file(migration_path): def _generate_migration_from_models(): """Generate migration content dynamically from SQLAlchemy models.""" - from models import Base + from backend.shared.models import Base import sqlalchemy as sa from datetime import datetime @@ -474,8 +484,8 @@ def _get_column_type(column): def _create_database_directly(): """Fallback method: create database directly using SQLAlchemy.""" - from models import Base - from db import engine + from backend.shared.models import Base + from backend.shared.db import get_engine from sqlalchemy import text, inspect # Check existing tables and update schema @@ -534,35 +544,38 @@ def _create_database_directly(): 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 - current_dir = os.path.dirname(os.path.abspath(__file__)) - versions_dir = os.path.join(current_dir, "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() + engine = get_engine(DATABASE_URL) + with engine.connect() as connection: + # 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 + current_dir = os.path.dirname(os.path.abspath(__file__)) + versions_dir = os.path.join(current_dir, "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): diff --git a/backend/push_service.py b/backend/push_service.py index 6d53416..48db3dd 100644 --- a/backend/push_service.py +++ b/backend/push_service.py @@ -4,8 +4,7 @@ import os from typing import List, Optional from sqlalchemy.orm import Session from pywebpush import webpush, WebPushException -from models import PushSubscription, User, Message, DMEnvelope -from models import FcmToken +from backend.shared.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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 0a78322..45666b4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,7 @@ PyJWT>=2.8.0 fastapi[standard]>=0.116.1 pydantic>=2.11.7 sqlalchemy>=2.0.43 +psycopg2-binary>=2.9.9 bcrypt>=4.3.0 websockets>=15.0.1 Pillow>=10.0.0 @@ -14,4 +15,5 @@ user-agents>=2.2.0 httpx>=0.27.2 rich>=13.9.4 slowapi>=0.1.9 -firebase_admin>=7.1.0 \ No newline at end of file +firebase_admin>=7.1.0 +PyNaCl>=1.5.0 \ No newline at end of file diff --git a/backend/routes/account.py b/backend/routes/account.py index 620c875..ef81545 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -8,16 +8,16 @@ 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 +from backend.shared.constants import OWNER_USERNAME +from backend.shared.dependencies import get_current_user, get_db +from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession +from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip +from backend.shared.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 +from backend.security.audit import log_security +from backend.security.profanity import contains_profanity +from backend.security.rate_limit import rate_limit_per_ip router = APIRouter() _FAILED_ATTEMPT_WINDOW_SECONDS = 300 @@ -356,7 +356,7 @@ def delete_user_as_owner( 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 + from backend.shared.models import Message # local import to avoid circular db.query(Message).filter(Message.user_id == user.id).delete() db.delete(user) @@ -381,7 +381,7 @@ def logout( db: Session = Depends(get_db) ): # Revoke current session - from utils import verify_token as _verify_token + from backend.shared.utils import verify_token as _verify_token payload = _verify_token(credentials.credentials) if payload and payload.get("session_id"): db.query(DeviceSession).filter( @@ -427,7 +427,7 @@ def change_password( # Optionally revoke all other sessions, keeping the current one if password_request.logoutAllExceptCurrent: - from utils import verify_token as _verify_token + from backend.shared.utils import verify_token as _verify_token payload = _verify_token(credentials.credentials) if not payload: raise HTTPException(status_code=401, detail="Invalid token") diff --git a/backend/routes/devices.py b/backend/routes/devices.py index 7cf41b9..2620624 100644 --- a/backend/routes/devices.py +++ b/backend/routes/devices.py @@ -2,9 +2,9 @@ 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 backend.shared.dependencies import get_current_user, get_db +from backend.shared.models import User, DeviceSession +from backend.shared.utils import verify_token from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer router = APIRouter() diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index bc0691b..85f8e14 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -16,29 +16,29 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session -from dependencies import get_current_user, get_db -from .account import convert_user -from constants import OWNER_USERNAME -from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog -from push_service import push_service +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 from PIL import Image import io import json from pydantic import BaseModel from better_profanity import profanity as _bp -from security.audit import log_access, log_dm, log_public_chat, log_security -from security.profanity import contains_profanity -from security.rate_limit import rate_limit_per_ip -from websocket.utils import authenticate_user +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 models import FcmToken +from backend.shared.models import FcmToken router = APIRouter() logger = logging.getLogger("uvicorn.error") MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB -FILES_BASE_DIR = Path("data/uploads/files") +FILES_BASE_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "files" FILES_NORMAL_DIR = FILES_BASE_DIR / "normal" FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index ded5cb8..11d2784 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -2,12 +2,12 @@ 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 +from backend.shared.constants import OWNER_USERNAME +from backend.shared.dependencies import get_current_user +from backend.shared.models import User +from backend.security.audit import log_security +from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist +from backend.security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits class BlocklistUpdateRequest(BaseModel): diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 31d1794..1ccba8e 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -9,15 +9,15 @@ import uuid import io from fastapi import Request -from dependencies import get_db, get_current_user -from models import User, UpdateBioRequest, UserProfileResponse +from backend.shared.dependencies import get_db, get_current_user +from backend.shared.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 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 security.audit import log_security -from security.profanity import contains_profanity -from security.rate_limit import rate_limit_per_ip +from backend.security.audit import log_security +from backend.security.profanity import contains_profanity +from backend.security.rate_limit import rate_limit_per_ip router = APIRouter() @@ -36,7 +36,7 @@ class UpdateProfileRequest(BaseModel): description: str | None = None # Create uploads directory if it doesn't exist -PROFILE_PICTURES_DIR = Path("data/uploads/pfp") +PROFILE_PICTURES_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "pfp" os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) diff --git a/backend/routes/push.py b/backend/routes/push.py index d9799ed..b54064c 100644 --- a/backend/routes/push.py +++ b/backend/routes/push.py @@ -1,8 +1,8 @@ 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 +from backend.shared.dependencies import get_current_user, get_db +from backend.shared.models import User, PushSubscriptionRequest +import backend.push_service as push_service router = APIRouter() diff --git a/backend/routes/webrtc.py b/backend/routes/webrtc.py index 10b07d9..3d452f0 100644 --- a/backend/routes/webrtc.py +++ b/backend/routes/webrtc.py @@ -4,7 +4,7 @@ import hmac import hashlib import time from fastapi import APIRouter, Depends -from dependencies import get_current_user +from backend.shared.dependencies import get_current_user import traceback router = APIRouter() diff --git a/backend/run_local.py b/backend/run_local.py new file mode 100644 index 0000000..7444ba2 --- /dev/null +++ b/backend/run_local.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Local development server that runs all services in a single FastAPI application. +This provides the same monolithic experience as before, but with microservice separation. +""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +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 + +# Import security modules +from security.audit import log_access +from security.rate_limit import limiter +from slowapi.middleware import SlowAPIMiddleware + +# Create main FastAPI app +app = FastAPI(title="FromChat Local Development") + +# Add rate limiting middleware +app.state.limiter = limiter +app.add_middleware(SlowAPIMiddleware) + +# CORS middleware +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=["*"], +) + +# Mount service routers with appropriate prefixes +app.include_router(account_router, prefix="/account") +app.include_router(profile_router, prefix="/profile") +app.include_router(device_router, prefix="/devices") +app.include_router(messaging_router, prefix="/messaging") +app.include_router(push_router, prefix="/push") +app.include_router(webrtc_router, prefix="/webrtc") +app.include_router(moderation_router, prefix="/moderation") + +if __name__ == "__main__": + # Run the server + port = int(os.getenv("PORT", "8301")) + host = os.getenv("HOST", "127.0.0.1") + + print(f"Starting FromChat local development server on {host}:{port}") + print("Available services:") + print(" - Account: http://127.0.0.1:8301/account/") + print(" - Profile: http://127.0.0.1:8301/profile/") + print(" - Devices: http://127.0.0.1:8301/devices/") + print(" - Messaging: http://127.0.0.1:8301/messaging/") + print(" - Push: http://127.0.0.1:8301/push/") + print(" - WebRTC: http://127.0.0.1:8301/webrtc/") + print(" - Moderation: http://127.0.0.1:8301/moderation/") + + uvicorn.run( + "run_local:app", + host=host, + port=port, + reload=True, + reload_dirs=["backend"] + ) diff --git a/backend/security/audit.py b/backend/security/audit.py index f9e7e64..18a6857 100644 --- a/backend/security/audit.py +++ b/backend/security/audit.py @@ -4,7 +4,7 @@ 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 +from backend.logging_config import access_logger, dm_logger, public_chat_logger, security_logger def _clean_username(username: Any) -> str: diff --git a/backend/security/profanity.py b/backend/security/profanity.py index 7091df4..28e57e3 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -9,7 +9,7 @@ from typing import Iterable, List, Set, Tuple from better_profanity import Profanity -BLOCKLIST_PATH = Path("data/profanity/blocklist.json") +BLOCKLIST_PATH = Path(__file__).resolve().parent.parent / "data" / "profanity" / "blocklist.json" BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) _CUSTOM_RU_TERMS: Set[str] = { diff --git a/backend/security/rate_limit.py b/backend/security/rate_limit.py index 2e94d23..45ba7e5 100644 --- a/backend/security/rate_limit.py +++ b/backend/security/rate_limit.py @@ -8,7 +8,7 @@ from fastapi import Request from slowapi import Limiter from slowapi.util import get_remote_address -from utils import get_client_ip +from backend.shared.utils import get_client_ip logger = logging.getLogger("uvicorn.error") diff --git a/backend/services/account/Dockerfile b/backend/services/account/Dockerfile new file mode 100644 index 0000000..fd6eb88 --- /dev/null +++ b/backend/services/account/Dockerfile @@ -0,0 +1,11 @@ +# Account Service Dockerfile +FROM backend/base:latest + +# Copy service-specific files +COPY backend/routes/account.py /app/backend/routes/account.py +COPY backend/services/account/main.py /app/backend/services/account/main.py + +# Set service name for entrypoint +ENV SERVICE_NAME=account + +EXPOSE 8301 diff --git a/backend/services/account/main.py b/backend/services/account/main.py new file mode 100644 index 0000000..fd978a9 --- /dev/null +++ b/backend/services/account/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from backend.routes.account import router as account_router + +if __name__ == "__main__": + app = FastAPI(title="Account Service") + app.include_router(account_router, prefix="/account") + + import os, uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301))) diff --git a/backend/services/device/Dockerfile b/backend/services/device/Dockerfile new file mode 100644 index 0000000..1c82a59 --- /dev/null +++ b/backend/services/device/Dockerfile @@ -0,0 +1,11 @@ +# Device Service Dockerfile +FROM backend/base:latest + +# Copy service-specific files +COPY backend/routes/devices.py /app/backend/routes/devices.py +COPY backend/services/device/main.py /app/backend/services/device/main.py + +# Set service name for entrypoint +ENV SERVICE_NAME=device + +EXPOSE 8301 diff --git a/backend/services/device/main.py b/backend/services/device/main.py new file mode 100644 index 0000000..4faee4d --- /dev/null +++ b/backend/services/device/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from backend.routes.devices import router as device_router + +if __name__ == "__main__": + app = FastAPI(title="Device Service") + app.include_router(device_router, prefix="/devices") + + import os, uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301))) diff --git a/backend/services/gateway/Dockerfile b/backend/services/gateway/Dockerfile new file mode 100644 index 0000000..9e4d9b0 --- /dev/null +++ b/backend/services/gateway/Dockerfile @@ -0,0 +1,14 @@ +# Gateway Service Dockerfile +FROM backend/base:latest + +# Copy service-specific files +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/security /app/backend/security/ +COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py + +# Set service name for entrypoint +ENV SERVICE_NAME=gateway + +EXPOSE 8301 diff --git a/backend/services/gateway/main.py b/backend/services/gateway/main.py new file mode 100644 index 0000000..0ff882d --- /dev/null +++ b/backend/services/gateway/main.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI + +# Gateway service - handles complex operations that Caddy cannot +# This will be expanded later with routing logic to other services + +if __name__ == "__main__": + app = FastAPI(title="Gateway Service") + + import os, uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301))) diff --git a/backend/services/messaging/Dockerfile b/backend/services/messaging/Dockerfile new file mode 100644 index 0000000..86de786 --- /dev/null +++ b/backend/services/messaging/Dockerfile @@ -0,0 +1,12 @@ +# Messaging Service Dockerfile +FROM backend/base:latest + +# Copy service-specific files +COPY backend/routes/messaging.py /app/backend/routes/messaging.py +COPY backend/websocket /app/backend/websocket/ +COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py + +# Set service name for entrypoint +ENV SERVICE_NAME=messaging + +EXPOSE 8301 diff --git a/backend/services/messaging/main.py b/backend/services/messaging/main.py new file mode 100644 index 0000000..3e96823 --- /dev/null +++ b/backend/services/messaging/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from backend.routes.messaging import router as messaging_router + +if __name__ == "__main__": + app = FastAPI(title="Messaging Service") + app.include_router(messaging_router, prefix="/messaging") + + import os, uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301))) diff --git a/backend/services/migration_runner/Dockerfile b/backend/services/migration_runner/Dockerfile new file mode 100644 index 0000000..f216ec1 --- /dev/null +++ b/backend/services/migration_runner/Dockerfile @@ -0,0 +1,15 @@ +# Migration Runner Dockerfile +FROM backend/base:latest + +# Copy migration files +COPY backend/alembic /app/backend/alembic/ +COPY backend/migration.py /app/backend/migration.py + +# Copy migration runner +COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py + +# Set service name for entrypoint +ENV SERVICE_NAME=migration_runner + +# Override entrypoint to run migrations +ENTRYPOINT ["python", "-m", "backend.services.migration_runner.main"] diff --git a/backend/services/migration_runner/main.py b/backend/services/migration_runner/main.py new file mode 100644 index 0000000..50f482a --- /dev/null +++ b/backend/services/migration_runner/main.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Migration runner service - executes database migrations and exits. +This service runs Alembic migrations against PostgreSQL and terminates. +""" + +import os +import sys +from pathlib import Path +from alembic import command +from alembic.config import Config + +def run_migrations(): + """Run Alembic migrations.""" + print("Starting database migrations...") + + # Change to backend directory to run migrations + backend_dir = Path(__file__).parent.parent.parent + os.chdir(backend_dir) + + # Ensure shared models are imported for alembic + import backend.shared.models + + # Set DATABASE_URL from environment if not set + db_url = os.getenv("DATABASE_URL") + if not db_url: + print("ERROR: DATABASE_URL environment variable not set") + sys.exit(1) + + # Export DATABASE_URL for alembic + os.environ["DATABASE_URL"] = db_url + + try: + # Use the robust migration system from migration.py + # This handles all edge cases and recovery scenarios automatically + print("Starting database migrations...") + + # Import and run the migration function + from backend.migration import run_migrations + run_migrations() + + print("Database migrations completed successfully!") + + except Exception as e: + print(f"ERROR: Failed to run migrations: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + +if __name__ == "__main__": + run_migrations() diff --git a/backend/services/moderation/Dockerfile b/backend/services/moderation/Dockerfile new file mode 100644 index 0000000..98fcc6d --- /dev/null +++ b/backend/services/moderation/Dockerfile @@ -0,0 +1,13 @@ +# Moderation Service Dockerfile +FROM backend/base:latest + +# Copy service-specific files +COPY backend/routes/moderation.py /app/backend/routes/moderation.py +COPY backend/security/profanity.py /app/backend/security/profanity.py +COPY backend/similarity.py /app/backend/similarity.py +COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py + +# Set service name for entrypoint +ENV SERVICE_NAME=moderation + +EXPOSE 8301 diff --git a/backend/services/moderation/main.py b/backend/services/moderation/main.py new file mode 100644 index 0000000..bbc4a1c --- /dev/null +++ b/backend/services/moderation/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from backend.routes.moderation import router as moderation_router + +if __name__ == "__main__": + app = FastAPI(title="Moderation Service") + app.include_router(moderation_router, prefix="/moderation") + + import os, uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301))) diff --git a/backend/services/profile/Dockerfile b/backend/services/profile/Dockerfile new file mode 100644 index 0000000..f39eeff --- /dev/null +++ b/backend/services/profile/Dockerfile @@ -0,0 +1,11 @@ +# Profile Service Dockerfile +FROM backend/base:latest + +# Copy service-specific files +COPY backend/routes/profile.py /app/backend/routes/profile.py +COPY backend/services/profile/main.py /app/backend/services/profile/main.py + +# Set service name for entrypoint +ENV SERVICE_NAME=profile + +EXPOSE 8301 diff --git a/backend/services/profile/main.py b/backend/services/profile/main.py new file mode 100644 index 0000000..1d4f258 --- /dev/null +++ b/backend/services/profile/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from backend.routes.profile import router as profile_router + +if __name__ == "__main__": + app = FastAPI(title="Profile Service") + app.include_router(profile_router, prefix="/profile") + + import os, uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301))) diff --git a/backend/services/push/Dockerfile b/backend/services/push/Dockerfile new file mode 100644 index 0000000..c80f922 --- /dev/null +++ b/backend/services/push/Dockerfile @@ -0,0 +1,12 @@ +# Push Service Dockerfile +FROM backend/base:latest + +# Copy service-specific files +COPY backend/routes/push.py /app/backend/routes/push.py +COPY backend/push_service.py /app/backend/push_service.py +COPY backend/services/push/main.py /app/backend/services/push/main.py + +# Set service name for entrypoint +ENV SERVICE_NAME=push + +EXPOSE 8301 diff --git a/backend/services/push/main.py b/backend/services/push/main.py new file mode 100644 index 0000000..aa1e5e9 --- /dev/null +++ b/backend/services/push/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from backend.routes.push import router as push_router + +if __name__ == "__main__": + app = FastAPI(title="Push Service") + app.include_router(push_router, prefix="/push") + + import os, uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301))) diff --git a/backend/services/webrtc/Dockerfile b/backend/services/webrtc/Dockerfile new file mode 100644 index 0000000..2d5cdf9 --- /dev/null +++ b/backend/services/webrtc/Dockerfile @@ -0,0 +1,11 @@ +# WebRTC Service Dockerfile +FROM backend/base:latest + +# Copy service-specific files +COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py +COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py + +# Set service name for entrypoint +ENV SERVICE_NAME=webrtc + +EXPOSE 8301 diff --git a/backend/services/webrtc/main.py b/backend/services/webrtc/main.py new file mode 100644 index 0000000..123fb9b --- /dev/null +++ b/backend/services/webrtc/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from backend.routes.webrtc import router as webrtc_router + +if __name__ == "__main__": + app = FastAPI(title="WebRTC Service") + app.include_router(webrtc_router, prefix="/webrtc") + + import os, uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301))) diff --git a/backend/shared/__init__.py b/backend/shared/__init__.py new file mode 100644 index 0000000..64b0416 --- /dev/null +++ b/backend/shared/__init__.py @@ -0,0 +1 @@ +# Shared modules package \ No newline at end of file diff --git a/backend/shared/constants.py b/backend/shared/constants.py new file mode 100644 index 0000000..9f383e0 --- /dev/null +++ b/backend/shared/constants.py @@ -0,0 +1,50 @@ +import os + +# Database +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/database.db") + +# JWT +JWT_SECRET_KEY = os.getenv("JWT_SECRET", "default-jwt-secret-for-development") +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 user +OWNER_USERNAME = os.getenv("OWNER_USERNAME", "owner") + +# Push notifications +VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "") +VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "") +VAPID_SUBJECT = os.getenv("VAPID_SUBJECT", "mailto:admin@example.com") + +# Rate limiting +RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) +RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) + +# File uploads +MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "10485760")) # 10MB +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".avi", ".mp3", ".wav"} + +# WebSocket +WEBSOCKET_PING_INTERVAL = 30 +WEBSOCKET_PING_TIMEOUT = 60 + +# Encryption +ENCRYPTION_KEY_LENGTH = 32 +ENCRYPTION_NONCE_LENGTH = 12 + +# Moderation +PROFANITY_THRESHOLD = float(os.getenv("PROFANITY_THRESHOLD", "0.8")) +SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", "0.85")) + +# WebRTC +WEBRTC_ICE_SERVERS = [ + {"urls": "stun:stun.l.google.com:19302"}, + {"urls": "stun:stun1.l.google.com:19302"} +] + +# Logging +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" diff --git a/backend/shared/db.py b/backend/shared/db.py new file mode 100644 index 0000000..f9cd7b9 --- /dev/null +++ b/backend/shared/db.py @@ -0,0 +1,62 @@ +import os +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy import create_engine +from .constants import DATABASE_URL + +# Database connection settings +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 create_engine_from_url(database_url: str): + """Create SQLAlchemy engine from database URL.""" + connect_args = {} + if database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + + engine_kwargs = { + "pool_size": POOL_SIZE, + "max_overflow": MAX_OVERFLOW, + "pool_recycle": POOL_RECYCLE, + "pool_pre_ping": True, + "pool_timeout": POOL_TIMEOUT, + } + + engine = create_engine( + database_url, + connect_args=connect_args, + **engine_kwargs, + ) + + return engine + +# Create engine - this should be called by each service with its own DATABASE_URL +def get_engine(database_url: str = None): + """Get SQLAlchemy engine for the given database URL.""" + url = database_url or DATABASE_URL + return create_engine_from_url(url) + +# Session factory - create per service +def get_session_factory(database_url: str = None): + """Get session factory for the given database URL.""" + engine = get_engine(database_url) + return sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Dependency for FastAPI - create per service +def get_db(database_url: str = None): + """FastAPI dependency to get database session.""" + SessionLocal = get_session_factory(database_url) + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/shared/dependencies.py b/backend/shared/dependencies.py new file mode 100644 index 0000000..d4baf55 --- /dev/null +++ b/backend/shared/dependencies.py @@ -0,0 +1,122 @@ +from datetime import datetime, timedelta +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session +from backend.shared.utils import verify_token +from backend.shared.models import User, DeviceSession +from backend.shared.db import get_session_factory +import logging + +security = HTTPBearer() +logger = logging.getLogger("uvicorn.error") + +# Зависимость для получения сессии БД +SessionLocal = get_session_factory() + +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 backend.shared.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 \ No newline at end of file diff --git a/backend/shared/models.py b/backend/shared/models.py new file mode 100644 index 0000000..ca409f3 --- /dev/null +++ b/backend/shared/models.py @@ -0,0 +1,404 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float, JSON, BigInteger, UniqueConstraint +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship +from datetime import datetime +import json +from pydantic import BaseModel + +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + + id = Column(BigInteger, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + email = Column(String(100), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + salt = Column(String(64), nullable=False) + display_name = Column(String(100), nullable=True) + bio = Column(Text, nullable=True) + avatar_url = Column(String(255), nullable=True) + is_online = Column(Boolean, default=False) + last_seen = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + verified = Column(Boolean, default=False) + verification_token = Column(String(255), nullable=True) + reset_token = Column(String(255), nullable=True) + reset_token_expires = Column(DateTime, nullable=True) + two_factor_enabled = Column(Boolean, default=False) + two_factor_secret = Column(String(255), nullable=True) + login_attempts = Column(Integer, default=0) + locked_until = Column(DateTime, nullable=True) + public_key = Column(Text, nullable=True) + private_key = Column(Text, nullable=True) + encryption_enabled = Column(Boolean, default=False) + + # Relationships + messages = relationship("Message", back_populates="sender", cascade="all, delete-orphan") + message_recipients = relationship("MessageRecipient", back_populates="recipient", cascade="all, delete-orphan") + devices = relationship("Device", back_populates="user", cascade="all, delete-orphan") + push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan") + +class Message(Base): + __tablename__ = "messages" + + id = Column(BigInteger, primary_key=True, index=True) + sender_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True) + content = Column(Text, nullable=False) + content_type = Column(String(50), default="text") + encrypted_content = Column(Text, nullable=True) + signature = Column(Text, nullable=True) + timestamp = Column(DateTime, default=datetime.utcnow, index=True) + 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) + is_public = Column(Boolean, default=False) + + # Relationships + sender = relationship("User", back_populates="messages") + recipients = relationship("MessageRecipient", back_populates="message", cascade="all, delete-orphan") + 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") + +class MessageRecipient(Base): + __tablename__ = "message_recipients" + + 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) + read_at = Column(DateTime, nullable=True) + delivered_at = Column(DateTime, nullable=True) + encrypted_key = Column(Text, nullable=True) + + # Relationships + message = relationship("Message", back_populates="recipients") + recipient = relationship("User", back_populates="message_recipients") + +class MessageReaction(Base): + __tablename__ = "message_reactions" + + 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) + reaction = Column(String(50), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + message = relationship("Message", back_populates="reactions") + +class Device(Base): + __tablename__ = "devices" + + id = Column(BigInteger, primary_key=True, index=True) + user_id = Column(BigInteger, ForeignKey("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) + public_key = Column(Text, nullable=True) + signed_prekey = Column(Text, nullable=True) + one_time_prekeys = Column(JSON, nullable=True) + last_active = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="devices") + push_subscriptions = relationship("PushSubscription", back_populates="device", cascade="all, delete-orphan") + +class PushSubscription(Base): + __tablename__ = "push_subscriptions" + + 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) + endpoint = Column(String(500), nullable=False) + p256dh = Column(String(255), nullable=False) + auth = Column(String(255), nullable=False) + user_agent = Column(String(500), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="push_subscriptions") + device = relationship("Device", back_populates="push_subscriptions") + +class WebRTCSession(Base): + __tablename__ = "webrtc_sessions" + + 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) + participant_ids = Column(JSON, nullable=False) + offer = Column(JSON, nullable=True) + answer = Column(JSON, nullable=True) + ice_candidates = Column(JSON, nullable=True) + status = Column(String(50), default="pending") + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + +class ModerationAction(Base): + __tablename__ = "moderation_actions" + + 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) + action_type = Column(String(50), nullable=False) + reason = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + expires_at = Column(DateTime, nullable=True) + + +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) + salt_b64 = Column(Text, nullable=False) + iv2_b64 = Column(Text, nullable=False) + wrapped_mk_b64 = Column(Text, nullable=False) + reply_to_id = Column(Integer, nullable=True) + timestamp = 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) + + message = relationship("DMEnvelope", back_populates="files") + + +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 models +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 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"), + ) diff --git a/backend/shared/utils.py b/backend/shared/utils.py new file mode 100644 index 0000000..75668b3 --- /dev/null +++ b/backend/shared/utils.py @@ -0,0 +1,234 @@ +import secrets +import string +import hashlib +import hmac +import base64 +import json +from datetime import datetime, timedelta +from typing import Optional +import re +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.backends import default_backend +import nacl.secret +import nacl.utils +from fastapi import Request +import jwt +import bcrypt +from backend.shared.constants import JWT_SECRET_KEY, JWT_ALGORITHM, MAX_TOKEN_LIFETIME_HOURS +import ipaddress + +def generate_secure_token(length: int = 32) -> str: + """Generate a cryptographically secure random token.""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) + +def hash_password(password: str, salt: Optional[bytes] = None) -> tuple[str, bytes]: + """Hash a password with PBKDF2 and return (hash, salt).""" + if salt is None: + salt = secrets.token_bytes(32) + + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + backend=default_backend() + ) + + key = kdf.derive(password.encode()) + return base64.b64encode(key).decode(), salt + +def verify_password(password: str, hashed: str, salt: bytes) -> bool: + """Verify a password against its hash and salt.""" + try: + key = base64.b64decode(hashed) + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + backend=default_backend() + ) + kdf.verify(password.encode(), key) + return True + except: + return False + +def generate_verification_token() -> str: + """Generate a verification token for email verification.""" + return generate_secure_token(64) + +def generate_reset_token() -> str: + """Generate a password reset token.""" + return generate_secure_token(64) + +def get_client_ip(request: Request) -> str: + """Extract the real client IP from the request.""" + # Check X-Forwarded-For header first + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + # Take the first IP in case of multiple proxies + client_ip = forwarded_for.split(",")[0].strip() + try: + # Validate IP address + ipaddress.ip_address(client_ip) + return client_ip + except ValueError: + pass + + # Check X-Real-IP header + real_ip = request.headers.get("X-Real-IP") + if real_ip: + try: + ipaddress.ip_address(real_ip) + return real_ip + except ValueError: + pass + + # Fallback to request.client.host + client_host = request.client.host if request.client else "unknown" + try: + ipaddress.ip_address(client_host) + return client_host + except ValueError: + return "unknown" + +def validate_email(email: str) -> bool: + """Validate email address format.""" + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return re.match(pattern, email) is not None + +def validate_username(username: str) -> bool: + """Validate username format.""" + if not username or len(username) < 3 or len(username) > 50: + return False + + # Allow alphanumeric, underscore, and hyphen + pattern = r'^[a-zA-Z0-9_-]+$' + return re.match(pattern, username) is not None + +def sanitize_filename(filename: str) -> str: + """Sanitize filename to prevent directory traversal.""" + return re.sub(r'[^\w\.-]', '_', filename) + +def generate_file_hash(content: bytes) -> str: + """Generate SHA256 hash of file content.""" + return hashlib.sha256(content).hexdigest() + +def encrypt_data(data: str, key: bytes) -> str: + """Encrypt data using NaCl secret box.""" + box = nacl.secret.SecretBox(key) + encrypted = box.encrypt(data.encode()) + return base64.b64encode(encrypted).decode() + +def decrypt_data(encrypted_data: str, key: bytes) -> str: + """Decrypt data using NaCl secret box.""" + box = nacl.secret.SecretBox(key) + encrypted = base64.b64decode(encrypted_data) + decrypted = box.decrypt(encrypted) + return decrypted.decode() + +def generate_encryption_key() -> bytes: + """Generate a new encryption key.""" + return nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE) + +def format_datetime(dt: datetime) -> str: + """Format datetime for API responses.""" + return dt.isoformat() + +def parse_datetime(dt_str: str) -> Optional[datetime]: + """Parse datetime from API requests.""" + try: + return datetime.fromisoformat(dt_str.replace('Z', '+00:00')) + except: + return None + +def calculate_age(birth_date: datetime) -> int: + """Calculate age from birth date.""" + today = datetime.now() + age = today.year - birth_date.year + if today.month < birth_date.month or (today.month == birth_date.month and today.day < birth_date.day): + age -= 1 + return age + +def truncate_text(text: str, max_length: int, suffix: str = "...") -> str: + """Truncate text to max length with suffix.""" + if len(text) <= max_length: + return text + return text[:max_length - len(suffix)] + suffix + +def is_valid_url(url: str) -> bool: + """Validate URL format.""" + pattern = r'^https?://[^\s/$.?#].[^\s]*$' + return re.match(pattern, url) is not None + +def generate_device_id() -> str: + """Generate a unique device identifier.""" + return generate_secure_token(32) + +def normalize_phone_number(phone: str) -> str: + """Normalize phone number format.""" + # Remove all non-digit characters except + + normalized = re.sub(r'[^\d+]', '', phone) + + # Ensure it starts with + + if not normalized.startswith('+'): + if normalized.startswith('00'): + normalized = '+' + normalized[2:] + else: + normalized = '+' + normalized + + return normalized + + +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 get_password_hash(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + + +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 _is_admin(user) -> bool: + return user.id == 1 + + +def convert_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 + } diff --git a/backend/shared/validation.py b/backend/shared/validation.py new file mode 100644 index 0000000..e0b235d --- /dev/null +++ b/backend/shared/validation.py @@ -0,0 +1,132 @@ +from typing import Optional +from pydantic import BaseModel, EmailStr, Field, validator +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 + +class UserCreate(BaseModel): + username: str = Field(min_length=3, max_length=50) + email: EmailStr + password: str = Field(min_length=8, max_length=128) + display_name: Optional[str] = Field(None, max_length=100) + + @validator('username') + def username_alphanumeric(cls, v): + if not re.match(r'^[a-zA-Z0-9_-]+$', v): + raise ValueError('Username must be alphanumeric with underscores or hyphens') + return v + + @validator('password') + def password_strength(cls, v): + if not re.search(r'[A-Z]', v): + raise ValueError('Password must contain at least one uppercase letter') + if not re.search(r'[a-z]', v): + raise ValueError('Password must contain at least one lowercase letter') + if not re.search(r'\d', v): + raise ValueError('Password must contain at least one digit') + return v + +class UserLogin(BaseModel): + username_or_email: str = Field(min_length=1, max_length=100) + password: str = Field(min_length=1, max_length=128) + +class UserUpdate(BaseModel): + display_name: Optional[str] = Field(None, max_length=100) + bio: Optional[str] = Field(None, max_length=500) + avatar_url: Optional[str] = Field(None, max_length=255) + + @validator('avatar_url') + def validate_avatar_url(cls, v): + if v and not v.startswith(('http://', 'https://')): + raise ValueError('Avatar URL must be a valid HTTP/HTTPS URL') + return v + +class MessageCreate(BaseModel): + content: str = Field(min_length=1, max_length=10000) + content_type: str = Field(default="text", pattern=r'^(text|image|video|audio|file)$') + reply_to_id: Optional[int] = None + recipient_ids: list[int] = Field(min_items=1, max_items=100) + +class MessageUpdate(BaseModel): + content: str = Field(min_length=1, max_length=10000) + +class DeviceRegister(BaseModel): + device_id: str = Field(min_length=1, max_length=255) + device_name: Optional[str] = Field(None, max_length=255) + device_type: Optional[str] = Field(None, max_length=50) + public_key: Optional[str] = Field(None, max_length=10000) + +class PushSubscriptionCreate(BaseModel): + endpoint: str = Field(max_length=500) + p256dh: str = Field(max_length=255) + auth: str = Field(max_length=255) + device_id: Optional[str] = Field(None, max_length=255) + +class WebRTCOffer(BaseModel): + offer: dict + participant_ids: list[int] = Field(min_items=1, max_items=10) + +class WebRTCAnswer(BaseModel): + answer: dict + session_id: str = Field(max_length=255) + +class WebRTCIceCandidate(BaseModel): + candidate: dict + session_id: str = Field(max_length=255) + +class ModerationActionCreate(BaseModel): + target_user_id: Optional[int] = None + target_message_id: Optional[int] = None + action_type: str = Field(pattern=r'^(ban|mute|delete|warn)$') + reason: Optional[str] = Field(None, max_length=1000) + duration_hours: Optional[int] = Field(None, gt=0, le=8760) # Max 1 year + +class PasswordResetRequest(BaseModel): + email: EmailStr + +class PasswordReset(BaseModel): + token: str = Field(min_length=64, max_length=64) + new_password: str = Field(..., min_length=8, max_length=128) + + @validator('new_password') + def password_strength(cls, v): + if not re.search(r'[A-Z]', v): + raise ValueError('Password must contain at least one uppercase letter') + if not re.search(r'[a-z]', v): + raise ValueError('Password must contain at least one lowercase letter') + if not re.search(r'\d', v): + raise ValueError('Password must contain at least one digit') + return v + +class TwoFactorSetup(BaseModel): + code: str = Field(pattern=r'^\d{6}$') + +class TwoFactorVerify(BaseModel): + code: str = Field(pattern=r'^\d{6}$') + +class EmailVerification(BaseModel): + token: str = Field(min_length=64, max_length=64) diff --git a/backend/websocket/__init__.py b/backend/websocket/__init__.py index efe848a..7d313c2 100644 --- a/backend/websocket/__init__.py +++ b/backend/websocket/__init__.py @@ -1,4 +1,4 @@ -from websocket.registry import WebSocketHandlerRegistry +from .registry import WebSocketHandlerRegistry # Note: handler_registry and websocket_handler are not imported here to avoid circular dependency # Import them directly from websocket.handlers when needed diff --git a/backend/websocket/utils.py b/backend/websocket/utils.py index 1688706..bca1685 100644 --- a/backend/websocket/utils.py +++ b/backend/websocket/utils.py @@ -2,8 +2,8 @@ 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 +from backend.shared.dependencies import get_current_user +from backend.shared.models import User def extract_token_from_data(data: dict) -> str | None: diff --git a/deployment/db-init/01-init-roles.sql b/deployment/db-init/01-init-roles.sql new file mode 100644 index 0000000..d7baef1 --- /dev/null +++ b/deployment/db-init/01-init-roles.sql @@ -0,0 +1,68 @@ +-- Initialize database roles and schemas for FromChat microservices +-- 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'; + +-- Create dedicated schemas for each service +CREATE SCHEMA IF NOT EXISTS account_schema AUTHORIZATION account_service_user; +CREATE SCHEMA IF NOT EXISTS profile_schema AUTHORIZATION profile_service_user; +CREATE SCHEMA IF NOT EXISTS device_schema AUTHORIZATION device_service_user; +CREATE SCHEMA IF NOT EXISTS messaging_schema AUTHORIZATION messaging_service_user; +CREATE SCHEMA IF NOT EXISTS push_schema AUTHORIZATION push_service_user; +CREATE SCHEMA IF NOT EXISTS webrtc_schema AUTHORIZATION webrtc_service_user; +CREATE SCHEMA IF NOT EXISTS moderation_schema AUTHORIZATION moderation_service_user; + +-- Grant basic connection privileges +GRANT CONNECT ON DATABASE fromchat TO account_service_user, profile_service_user, device_service_user, messaging_service_user, push_service_user, webrtc_service_user, moderation_service_user; + +-- Grant schema-level privileges (limited to each service's schema) +-- Account service +GRANT USAGE ON SCHEMA account_schema TO account_service_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA account_schema TO account_service_user; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA account_schema TO account_service_user; + +-- Profile service +GRANT USAGE ON SCHEMA profile_schema TO profile_service_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA profile_schema TO profile_service_user; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA profile_schema TO profile_service_user; + +-- Device service +GRANT USAGE ON SCHEMA device_schema TO device_service_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA device_schema TO device_service_user; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA device_schema TO device_service_user; + +-- Messaging service +GRANT USAGE ON SCHEMA messaging_schema TO messaging_service_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA messaging_schema TO messaging_service_user; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA messaging_schema TO messaging_service_user; + +-- Push service +GRANT USAGE ON SCHEMA push_schema TO push_service_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA push_schema TO push_service_user; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA push_schema TO push_service_user; + +-- WebRTC service +GRANT USAGE ON SCHEMA webrtc_schema TO webrtc_service_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA webrtc_schema TO webrtc_service_user; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA webrtc_schema TO webrtc_service_user; + +-- Moderation service +GRANT USAGE ON SCHEMA moderation_schema TO moderation_service_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA moderation_schema TO moderation_service_user; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA moderation_schema TO moderation_service_user; + +-- Set default privileges for future objects +ALTER DEFAULT PRIVILEGES IN SCHEMA account_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO account_service_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA profile_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO profile_service_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA device_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO device_service_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA messaging_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO messaging_service_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA push_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO push_service_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA webrtc_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO webrtc_service_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA moderation_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO moderation_service_user; diff --git a/deployment/db-init/02-init-tables.sql b/deployment/db-init/02-init-tables.sql new file mode 100644 index 0000000..acef401 --- /dev/null +++ b/deployment/db-init/02-init-tables.sql @@ -0,0 +1,127 @@ +-- Create tables for FromChat microservices +-- This script creates the necessary tables in their respective schemas + +-- Note: In production, tables will be created by Alembic migrations +-- This script provides a fallback or reference for manual setup + +-- Account schema tables +CREATE TABLE IF NOT EXISTS account_schema.users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + hashed_password VARCHAR(255) NOT NULL, + salt VARCHAR(64) NOT NULL, + display_name VARCHAR(100), + bio TEXT, + avatar_url VARCHAR(255), + is_online BOOLEAN DEFAULT FALSE, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + verified BOOLEAN DEFAULT FALSE, + verification_token VARCHAR(255), + reset_token VARCHAR(255), + reset_token_expires TIMESTAMP, + two_factor_enabled BOOLEAN DEFAULT FALSE, + two_factor_secret VARCHAR(255), + login_attempts INTEGER DEFAULT 0, + locked_until TIMESTAMP, + public_key TEXT, + private_key TEXT, + encryption_enabled BOOLEAN DEFAULT FALSE +); + +-- Profile schema tables (references account_schema.users) +CREATE TABLE IF NOT EXISTS profile_schema.user_profiles ( + user_id BIGINT PRIMARY KEY REFERENCES account_schema.users(id) ON DELETE CASCADE, + display_name VARCHAR(100), + bio TEXT, + avatar_url VARCHAR(255), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Device schema tables +CREATE TABLE IF NOT EXISTS device_schema.devices ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE, + device_id VARCHAR(255) UNIQUE NOT NULL, + device_name VARCHAR(255), + device_type VARCHAR(50), + public_key TEXT, + signed_prekey TEXT, + one_time_prekeys JSONB, + last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Messaging schema tables +CREATE TABLE IF NOT EXISTS messaging_schema.messages ( + id BIGSERIAL PRIMARY KEY, + sender_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + content_type VARCHAR(50) DEFAULT 'text', + encrypted_content TEXT, + signature TEXT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + edited_at TIMESTAMP, + edited BOOLEAN DEFAULT FALSE, + deleted BOOLEAN DEFAULT FALSE, + reply_to_id BIGINT REFERENCES messaging_schema.messages(id), + thread_id BIGINT REFERENCES messaging_schema.messages(id), + is_public BOOLEAN DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS messaging_schema.message_recipients ( + id BIGSERIAL PRIMARY KEY, + message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE, + recipient_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE, + read_at TIMESTAMP, + delivered_at TIMESTAMP, + encrypted_key TEXT +); + +CREATE TABLE IF NOT EXISTS messaging_schema.message_reactions ( + id BIGSERIAL PRIMARY KEY, + message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE, + user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE, + reaction VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Push schema tables +CREATE TABLE IF NOT EXISTS push_schema.push_subscriptions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE, + device_id BIGINT REFERENCES device_schema.devices(id), + endpoint VARCHAR(500) NOT NULL, + p256dh VARCHAR(255) NOT NULL, + auth VARCHAR(255) NOT NULL, + user_agent VARCHAR(500), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- WebRTC schema tables +CREATE TABLE IF NOT EXISTS webrtc_schema.webrtc_sessions ( + id BIGSERIAL PRIMARY KEY, + session_id VARCHAR(255) UNIQUE NOT NULL, + initiator_id BIGINT REFERENCES account_schema.users(id), + participant_ids JSONB NOT NULL, + offer JSONB, + answer JSONB, + ice_candidates JSONB, + status VARCHAR(50) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Moderation schema tables +CREATE TABLE IF NOT EXISTS moderation_schema.moderation_actions ( + id BIGSERIAL PRIMARY KEY, + moderator_id BIGINT REFERENCES account_schema.users(id), + target_user_id BIGINT REFERENCES account_schema.users(id), + target_message_id BIGINT REFERENCES messaging_schema.messages(id), + action_type VARCHAR(50) NOT NULL, + reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP +); diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 948e241..406a600 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -1,67 +1,322 @@ services: - backend: - build: - dockerfile: deployment/Dockerfile.backend - context: .. + # Database service + db: + image: postgres:15 environment: - PORT: 8300 - JWT_SECRET: ${JWT_SECRET} - VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} - VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} - FIREBASE_CERT: ${FIREBASE_CERT} + POSTGRES_DB: fromchat + POSTGRES_USER: fromchat_admin + POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme} volumes: - - data:/app/data - - logs:/app/logs - - develop: - watch: - - action: sync+restart - path: ../backend - target: /app - - action: rebuild - path: ../backend/requirements.txt - - frontend: - build: - dockerfile: deployment/frontend/Dockerfile - context: .. - environment: - PORT: 8301 - BACKEND_HOST: http://backend:8300 - ports: - - "8301:8301" - depends_on: - - backend - develop: - watch: - - action: rebuild - path: ../frontend - - action: sync+restart - path: server.js - target: /server/server.js - - action: rebuild - path: package.json - - caddy: - build: - context: ./caddy - dockerfile: Dockerfile + - postgres_data:/var/lib/postgresql/data + - ./db-init:/docker-entrypoint-initdb.d + networks: + - fromchat_internal restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fromchat_admin -d fromchat"] + interval: 10s + timeout: 5s + retries: 5 + + # Migration runner - runs once before other services + migration_runner: + build: + context: .. + dockerfile: docker/Dockerfile.multi + target: migration_runner + environment: + DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD:-changeme}@db:5432/fromchat + JWT_SECRET: ${JWT_SECRET:-changeme} + depends_on: + db: + condition: service_healthy + networks: + - fromchat_internal + develop: + watch: + - action: sync + path: backend/alembic.ini + target: /app/backend/alembic.ini + - action: sync + path: backend/alembic + target: /app/backend/alembic + - action: sync + path: backend/migration.py + target: /app/backend/migration.py + - action: sync + path: backend/services/migration_runner + target: /app/backend/services/migration_runner + - action: sync+restart + path: backend/shared + target: /app/backend/shared + + # Gateway service - handles complex operations + gateway: + build: + context: .. + dockerfile: docker/Dockerfile.multi + target: gateway + environment: + DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD:-changeme}@db:5432/fromchat + depends_on: + migration_runner: + condition: service_completed_successfully + networks: + - fromchat_internal + - fromchat_external + restart: unless-stopped + develop: + watch: + - action: sync + path: backend/app.py + target: /app/backend/app.py + - action: sync + path: backend/main.py + target: /app/backend/main.py + - action: sync + path: backend/dependencies.py + target: /app/backend/dependencies.py + - action: sync + path: backend/security + target: /app/backend/security + - action: sync + path: backend/services/gateway + target: /app/backend/services/gateway + - action: sync+restart + path: backend/shared + target: /app/backend/shared + + # Account service + account_service: + build: + context: .. + dockerfile: docker/Dockerfile.multi + target: account_service + environment: + DATABASE_URL: postgresql://account_service_user:${DB_PASSWORD:-changeme}@db:5432/fromchat + JWT_SECRET: ${JWT_SECRET:-changeme} + depends_on: + migration_runner: + condition: service_completed_successfully + networks: + - fromchat_internal + restart: unless-stopped + develop: + watch: + - action: sync + path: backend/routes/account.py + target: /app/backend/routes/account.py + - action: sync + path: backend/services/account + target: /app/backend/services/account + - action: sync+restart + path: backend/shared + target: /app/backend/shared + + # Profile service + profile_service: + build: + context: .. + dockerfile: docker/Dockerfile.multi + target: profile_service + environment: + DATABASE_URL: postgresql://profile_service_user:${DB_PASSWORD:-changeme}@db: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} + depends_on: + migration_runner: + condition: service_completed_successfully + networks: + - fromchat_internal + restart: unless-stopped + develop: + watch: + - action: sync + path: backend/routes/profile.py + target: /app/backend/routes/profile.py + - action: sync + path: backend/services/profile + target: /app/backend/services/profile + - action: sync+restart + path: backend/shared + target: /app/backend/shared + + # Device service + device_service: + build: + context: .. + dockerfile: docker/Dockerfile.multi + target: device_service + environment: + DATABASE_URL: postgresql://device_service_user:${DB_PASSWORD:-changeme}@db:5432/fromchat + depends_on: + migration_runner: + condition: service_completed_successfully + networks: + - fromchat_internal + restart: unless-stopped + develop: + watch: + - action: sync + path: backend/routes/devices.py + target: /app/backend/routes/devices.py + - action: sync + path: backend/services/device + target: /app/backend/services/device + - action: sync+restart + path: backend/shared + target: /app/backend/shared + + # Messaging service + messaging_service: + build: + context: .. + dockerfile: docker/Dockerfile.multi + target: messaging_service + environment: + DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD:-changeme}@db: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} + depends_on: + migration_runner: + condition: service_completed_successfully + networks: + - fromchat_internal + restart: unless-stopped + develop: + watch: + - action: sync + path: backend/routes/messaging.py + target: /app/backend/routes/messaging.py + - action: sync + path: backend/websocket + target: /app/backend/websocket + - action: sync + path: backend/services/messaging + target: /app/backend/services/messaging + - action: sync+restart + path: backend/shared + target: /app/backend/shared + + # Push service + push_service: + build: + context: .. + dockerfile: docker/Dockerfile.multi + target: push_service + environment: + DATABASE_URL: postgresql://push_service_user:${DB_PASSWORD:-changeme}@db: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} + depends_on: + migration_runner: + condition: service_completed_successfully + networks: + - fromchat_internal + restart: unless-stopped + develop: + watch: + - action: sync + path: backend/routes/push.py + target: /app/backend/routes/push.py + - action: sync + path: backend/push_service.py + target: /app/backend/push_service.py + - action: sync + path: backend/services/push + target: /app/backend/services/push + - action: sync+restart + path: backend/shared + target: /app/backend/shared + + # WebRTC service + webrtc_service: + build: + context: .. + dockerfile: docker/Dockerfile.multi + target: webrtc_service + environment: + DATABASE_URL: postgresql://webrtc_service_user:${DB_PASSWORD:-changeme}@db:5432/fromchat + depends_on: + migration_runner: + condition: service_completed_successfully + networks: + - fromchat_internal + restart: unless-stopped + develop: + watch: + - action: sync + path: backend/routes/webrtc.py + target: /app/backend/routes/webrtc.py + - action: sync + path: backend/services/webrtc + target: /app/backend/services/webrtc + - action: sync+restart + path: backend/shared + target: /app/backend/shared + + # Moderation service + moderation_service: + build: + context: .. + dockerfile: docker/Dockerfile.multi + target: moderation_service + environment: + DATABASE_URL: postgresql://moderation_service_user:${DB_PASSWORD:-changeme}@db:5432/fromchat + depends_on: + migration_runner: + condition: service_completed_successfully + networks: + - fromchat_internal + restart: unless-stopped + develop: + watch: + - action: sync + path: backend/routes/moderation.py + target: /app/backend/routes/moderation.py + - action: sync + path: backend/security + target: /app/backend/security + - action: sync + path: backend/similarity.py + target: /app/backend/similarity.py + - action: sync + path: backend/services/moderation + target: /app/backend/services/moderation + - action: sync+restart + path: backend/shared + target: /app/backend/shared + + # Caddy reverse proxy + caddy: + image: caddy:2 ports: - "80:80" - "443:443" - extra_hosts: - - "host.docker.internal:host-gateway" volumes: - - certs:/root/site/certs - environment: - XDG_DATA_HOME: /root/site/certs - XDG_CONFIG_HOME: /root/site/certs + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - fromchat_external + restart: unless-stopped + depends_on: + - gateway volumes: - data: - name: fromchat-data - logs: - name: fromchat-logs - certs: - name: fromchat-certs \ No newline at end of file + postgres_data: + caddy_data: + caddy_config: + +networks: + fromchat_internal: + driver: bridge + internal: true + fromchat_external: + driver: bridge \ No newline at end of file diff --git a/docker/Dockerfile.multi b/docker/Dockerfile.multi new file mode 100644 index 0000000..38baad1 --- /dev/null +++ b/docker/Dockerfile.multi @@ -0,0 +1,122 @@ +# Multi-stage Dockerfile for all FromChat microservices +# This combines the base image and all services in one file + +# Base stage - common setup for all services +FROM python:3.11-slim AS base + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd --create-home --shell /bin/bash fromchat + +# Set working directory +WORKDIR /app + +# Create logs and data directories with proper permissions +RUN mkdir -p /app/backend/logs /app/backend/data /app/backend/data/profanity /app/backend/data/uploads /app/backend/data/uploads/pfp && \ + chown -R fromchat:fromchat /app/backend/logs /app/backend/data && \ + chmod -R 755 /app/backend/logs /app/backend/data + +# Copy requirements first for better caching +COPY backend/requirements.txt /app/requirements.txt + +# Install Python dependencies with cache mounts +RUN --mount=type=cache,target=/home/fromchat/.cache/pip \ + pip install --no-cache-dir -r requirements.txt + +# Copy shared modules +COPY backend/shared /app/backend/shared/ + +# Copy entrypoint script +COPY docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Switch to non-root user +USER fromchat + +# Set entrypoint +ENTRYPOINT ["/app/entrypoint.sh"] + +# Account service - minimal files only +FROM base AS account_service +COPY backend/routes/account.py /app/backend/routes/account.py +COPY backend/security /app/backend/security/ +COPY backend/logging_config.py /app/backend/logging_config.py +COPY backend/services/account/main.py /app/backend/services/account/main.py +ENV SERVICE_NAME=account + +# Profile service - minimal files only +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/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/profile/main.py /app/backend/services/profile/main.py +ENV SERVICE_NAME=profile + +# Device service - minimal files only +FROM base AS device_service +COPY backend/routes/devices.py /app/backend/routes/devices.py +COPY backend/services/device/main.py /app/backend/services/device/main.py +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/security /app/backend/security/ +COPY backend/logging_config.py /app/backend/logging_config.py +COPY backend/websocket /app/backend/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/main.py /app/backend/services/push/main.py +ENV SERVICE_NAME=push + +# WebRTC service - minimal files only +FROM base AS webrtc_service +COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py +COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py +ENV SERVICE_NAME=webrtc + +# Moderation service - minimal files only +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/logging_config.py /app/backend/logging_config.py +COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py +ENV SERVICE_NAME=moderation + +# Gateway service - minimal files only +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/security /app/backend/security/ +COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py +ENV SERVICE_NAME=gateway + +# Migration runner - needs alembic config and migration files +FROM base AS migration_runner +# Temporarily switch back to root to manage file permissions +USER root +COPY backend/alembic.ini /app/backend/alembic.ini +COPY backend/alembic /app/backend/alembic/ +COPY backend/migration.py /app/backend/migration.py +COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py +# Clean up problematic migrations as root +RUN find /app/backend/alembic/versions -name "*auto_generated_migration_for_schema_*" | xargs rm -f || true +# Switch back to fromchat user +USER fromchat +ENV SERVICE_NAME=migration_runner \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..523f910 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Entrypoint script for FromChat microservices + +# Run the service module +exec python -m backend.services.${SERVICE_NAME}.main diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 6f7d888..f05943e 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -314,6 +314,8 @@ step "Detecting services" cd "$DEPLOYMENT_DIR" SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null) +# Note: Using multi-stage Dockerfile - no separate base image build needed + if [ -z "$SERVICES" ]; then error "No services found in docker-compose.yml" fi @@ -340,7 +342,11 @@ for SERVICE in $SERVICES; do DOCKERFILE_REL=$(echo "$BUILD_OUTPUT" | grep "dockerfile:" | \ sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \ tr -d '"' | tr -d "'" | xargs) - + + TARGET=$(echo "$BUILD_OUTPUT" | grep "target:" | \ + sed 's/.*target:[[:space:]]*\(.*\)/\1/' | \ + tr -d '"' | tr -d "'" | xargs) + CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \ sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \ tr -d '"' | tr -d "'" | xargs) @@ -377,12 +383,18 @@ for SERVICE in $SERVICES; do fi fi - if docker buildx build \ - --platform "$PLATFORM" \ - --file "$DOCKERFILE" \ - --tag "$IMAGE_TAG" \ - --load \ - "$BUILD_CONTEXT"; then + # Safety check: ensure no sqlite in DATABASE_URL for Docker services + if grep -q "DATABASE_URL.*sqlite" "$DEPLOYMENT_DIR/docker-compose.yml" 2>/dev/null; then + error "Found sqlite DATABASE_URL in docker-compose.yml - SQLite not allowed in Docker" + exit 1 + fi + + BUILD_ARGS="--platform \"$PLATFORM\" --file \"$DOCKERFILE\" --tag \"$IMAGE_TAG\" --load" + if [ -n "$TARGET" ]; then + BUILD_ARGS="$BUILD_ARGS --target \"$TARGET\"" + fi + + if docker buildx build $BUILD_ARGS "$BUILD_CONTEXT"; then echo -e " ${GREEN}✓${NC} Built ${CYAN}$SERVICE${NC}" BUILT_IMAGES+=("$IMAGE_TAG") echo ""