Implement real, working microservices architecture

This commit is contained in:
2026-01-06 17:28:25 +03:00
Unverified
parent c7c9d9606d
commit 75ef3fc604
52 changed files with 2088 additions and 165 deletions
+1
View File
@@ -576,3 +576,4 @@ backend/alembic/**
!backend/alembic/script.py.mako !backend/alembic/script.py.mako
!frontend/src/css/lib !frontend/src/css/lib
**/*.module.scss.d.ts **/*.module.scss.d.ts
.cursor/plans
+2 -2
View File
@@ -5,7 +5,7 @@
# this is typically a path given in POSIX (e.g. forward slashes) # 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 # format, relative to the token %(here)s which refers to the location of this
# ini file # 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 # 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 # 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. # 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 # other means of configuring database URLs may be customized within the env.py
# file. # file.
sqlalchemy.url = sqlite:///./data/database.db # sqlalchemy.url is set dynamically from DATABASE_URL environment variable
[post_write_hooks] [post_write_hooks]
+1 -1
View File
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
# add your model's MetaData object here # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
from models import Base from backend.shared.models import Base
target_metadata = Base.metadata target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
+4 -4
View File
@@ -8,11 +8,11 @@ import sys
import os import os
from routes import account, messaging, profile, push, webrtc, devices, moderation from routes import account, messaging, profile, push, webrtc, devices, moderation
import logging import logging
from models import User from backend.shared.models import User
from constants import OWNER_USERNAME from backend.shared.constants import OWNER_USERNAME
from utils import get_client_ip 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 logging_config import access_logger # noqa: F401 - ensure loggers configured
from security.audit import log_access from security.audit import log_access
from security.rate_limit import limiter from security.rate_limit import limiter
+6 -6
View File
@@ -1,7 +1,7 @@
from constants import * from backend.shared.constants import *
from db import * from backend.shared.db import *
from models import * from backend.shared.models import *
from validation import * from backend.shared.validation import *
from utils import * from backend.shared.utils import *
from dependencies import * from backend.shared.dependencies import *
from app import * from app import *
+18 -5
View File
@@ -8,7 +8,7 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine from sqlalchemy import create_engine
from constants import DATABASE_URL from backend.shared.constants import DATABASE_URL
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,13 +31,15 @@ def run_migrations():
# If no application tables exist, create them directly from models # If no application tables exist, create them directly from models
if not existing_tables: if not existing_tables:
logger.info("No application tables found. Creating all tables directly from models...") 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) Base.metadata.create_all(bind=engine)
logger.info("All tables created successfully from models.") logger.info("All tables created successfully from models.")
# Get the directory where this script is located # Get the directory where this script is located
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
# Note: Problematic migrations are now cleaned up during Docker build
# Create Alembic configuration # Create Alembic configuration
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini")) alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
@@ -55,6 +57,13 @@ def run_migrations():
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] 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: if not migration_files:
logger.info("No migration files found. Creating initial migration...") logger.info("No migration files found. Creating initial migration...")
# Check if database exists and has tables # Check if database exists and has tables
@@ -183,6 +192,7 @@ def run_migrations():
# Try upgrade again # Try upgrade again
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.") logger.info("Database migrations completed successfully after reset.")
# Note: Index-related errors are now prevented by Docker build cleanup
else: else:
raise upgrade_error raise upgrade_error
@@ -289,7 +299,7 @@ def _populate_migration_file(migration_path):
def _generate_migration_from_models(): def _generate_migration_from_models():
"""Generate migration content dynamically from SQLAlchemy models.""" """Generate migration content dynamically from SQLAlchemy models."""
from models import Base from backend.shared.models import Base
import sqlalchemy as sa import sqlalchemy as sa
from datetime import datetime from datetime import datetime
@@ -474,8 +484,8 @@ def _get_column_type(column):
def _create_database_directly(): def _create_database_directly():
"""Fallback method: create database directly using SQLAlchemy.""" """Fallback method: create database directly using SQLAlchemy."""
from models import Base from backend.shared.models import Base
from db import engine from backend.shared.db import get_engine
from sqlalchemy import text, inspect from sqlalchemy import text, inspect
# Check existing tables and update schema # Check existing tables and update schema
@@ -533,6 +543,9 @@ def _create_database_directly():
# Table doesn't exist, create it # Table doesn't exist, create it
logger.info(f"Creating table {table_name}") logger.info(f"Creating table {table_name}")
# Create alembic_version table manually
engine = get_engine(DATABASE_URL)
with engine.connect() as connection:
# Create alembic_version table manually # Create alembic_version table manually
connection.execute(text(""" connection.execute(text("""
CREATE TABLE IF NOT EXISTS alembic_version ( CREATE TABLE IF NOT EXISTS alembic_version (
+1 -2
View File
@@ -4,8 +4,7 @@ import os
from typing import List, Optional from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pywebpush import webpush, WebPushException from pywebpush import webpush, WebPushException
from models import PushSubscription, User, Message, DMEnvelope from backend.shared.models import PushSubscription, User, Message, DMEnvelope, FcmToken
from models import FcmToken
import firebase_admin import firebase_admin
from firebase_admin import credentials as firebase_credentials from firebase_admin import credentials as firebase_credentials
from firebase_admin import messaging as firebase_messaging from firebase_admin import messaging as firebase_messaging
+2
View File
@@ -2,6 +2,7 @@ PyJWT>=2.8.0
fastapi[standard]>=0.116.1 fastapi[standard]>=0.116.1
pydantic>=2.11.7 pydantic>=2.11.7
sqlalchemy>=2.0.43 sqlalchemy>=2.0.43
psycopg2-binary>=2.9.9
bcrypt>=4.3.0 bcrypt>=4.3.0
websockets>=15.0.1 websockets>=15.0.1
Pillow>=10.0.0 Pillow>=10.0.0
@@ -15,3 +16,4 @@ httpx>=0.27.2
rich>=13.9.4 rich>=13.9.4
slowapi>=0.1.9 slowapi>=0.1.9
firebase_admin>=7.1.0 firebase_admin>=7.1.0
PyNaCl>=1.5.0
+11 -11
View File
@@ -8,16 +8,16 @@ import uuid
from user_agents import parse as parse_ua from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from constants import OWNER_USERNAME from backend.shared.constants import OWNER_USERNAME
from dependencies import get_current_user, get_db from backend.shared.dependencies import get_current_user, get_db
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
from utils import create_token, get_password_hash, verify_password, get_client_ip from backend.shared.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.validation import is_valid_password, is_valid_username, is_valid_display_name
import os import os
from security.audit import log_security from backend.security.audit import log_security
from security.profanity import contains_profanity from backend.security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip from backend.security.rate_limit import rate_limit_per_ip
router = APIRouter() router = APIRouter()
_FAILED_ATTEMPT_WINDOW_SECONDS = 300 _FAILED_ATTEMPT_WINDOW_SECONDS = 300
@@ -356,7 +356,7 @@ def delete_user_as_owner(
raise HTTPException(status_code=400, detail="Cannot delete owner account") raise HTTPException(status_code=400, detail="Cannot delete owner account")
# Manually delete user's messages to satisfy FK constraints # 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.query(Message).filter(Message.user_id == user.id).delete()
db.delete(user) db.delete(user)
@@ -381,7 +381,7 @@ def logout(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Revoke current session # 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) payload = _verify_token(credentials.credentials)
if payload and payload.get("session_id"): if payload and payload.get("session_id"):
db.query(DeviceSession).filter( db.query(DeviceSession).filter(
@@ -427,7 +427,7 @@ def change_password(
# Optionally revoke all other sessions, keeping the current one # Optionally revoke all other sessions, keeping the current one
if password_request.logoutAllExceptCurrent: 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) payload = _verify_token(credentials.credentials)
if not payload: if not payload:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
+3 -3
View File
@@ -2,9 +2,9 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from backend.shared.dependencies import get_current_user, get_db
from models import User, DeviceSession from backend.shared.models import User, DeviceSession
from utils import verify_token from backend.shared.utils import verify_token
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
router = APIRouter() router = APIRouter()
+11 -11
View File
@@ -16,29 +16,29 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from backend.shared.dependencies import get_current_user, get_db
from .account import convert_user from backend.shared.utils import convert_user
from constants import OWNER_USERNAME from backend.shared.constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog from backend.shared.models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
from push_service import push_service import backend.push_service as push_service
from PIL import Image from PIL import Image
import io import io
import json import json
from pydantic import BaseModel from pydantic import BaseModel
from better_profanity import profanity as _bp from better_profanity import profanity as _bp
from security.audit import log_access, log_dm, log_public_chat, log_security from backend.security.audit import log_access, log_dm, log_public_chat, log_security
from security.profanity import contains_profanity from backend.security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip from backend.security.rate_limit import rate_limit_per_ip
from websocket.utils import authenticate_user from backend.websocket.utils import authenticate_user
from models import FcmToken from backend.shared.models import FcmToken
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB 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_NORMAL_DIR = FILES_BASE_DIR / "normal"
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
+6 -6
View File
@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List from typing import List
from constants import OWNER_USERNAME from backend.shared.constants import OWNER_USERNAME
from dependencies import get_current_user from backend.shared.dependencies import get_current_user
from models import User from backend.shared.models import User
from security.audit import log_security from backend.security.audit import log_security
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist from backend.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.security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
class BlocklistUpdateRequest(BaseModel): class BlocklistUpdateRequest(BaseModel):
+8 -8
View File
@@ -9,15 +9,15 @@ import uuid
import io import io
from fastapi import Request from fastapi import Request
from dependencies import get_db, get_current_user from backend.shared.dependencies import get_db, get_current_user
from models import User, UpdateBioRequest, UserProfileResponse from backend.shared.models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel from pydantic import BaseModel
from validation import is_valid_username, is_valid_display_name from backend.shared.validation import is_valid_username, is_valid_display_name
from similarity import is_user_similar_to_verified from backend.similarity import is_user_similar_to_verified
from .messaging import messagingManager from .messaging import messagingManager
from security.audit import log_security from backend.security.audit import log_security
from security.profanity import contains_profanity from backend.security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip from backend.security.rate_limit import rate_limit_per_ip
router = APIRouter() router = APIRouter()
@@ -36,7 +36,7 @@ class UpdateProfileRequest(BaseModel):
description: str | None = None description: str | None = None
# Create uploads directory if it doesn't exist # 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) os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
+3 -3
View File
@@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from backend.shared.dependencies import get_current_user, get_db
from models import User, PushSubscriptionRequest from backend.shared.models import User, PushSubscriptionRequest
from push_service import push_service import backend.push_service as push_service
router = APIRouter() router = APIRouter()
+1 -1
View File
@@ -4,7 +4,7 @@ import hmac
import hashlib import hashlib
import time import time
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from dependencies import get_current_user from backend.shared.dependencies import get_current_user
import traceback import traceback
router = APIRouter() router = APIRouter()
+80
View File
@@ -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"]
)
+1 -1
View File
@@ -4,7 +4,7 @@ import logging
from html import unescape from html import unescape
from typing import Any, Callable, Dict, List 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: def _clean_username(username: Any) -> str:
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Iterable, List, Set, Tuple
from better_profanity import Profanity 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) BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
_CUSTOM_RU_TERMS: Set[str] = { _CUSTOM_RU_TERMS: Set[str] = {
+1 -1
View File
@@ -8,7 +8,7 @@ from fastapi import Request
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address 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") logger = logging.getLogger("uvicorn.error")
+11
View File
@@ -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
+9
View File
@@ -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)))
+11
View File
@@ -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
+9
View File
@@ -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)))
+14
View File
@@ -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
+10
View File
@@ -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)))
+12
View File
@@ -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
+9
View File
@@ -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)))
@@ -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"]
+51
View File
@@ -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()
+13
View File
@@ -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
+9
View File
@@ -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)))
+11
View File
@@ -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
+9
View File
@@ -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)))
+12
View File
@@ -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
+9
View File
@@ -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)))
+11
View File
@@ -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
+9
View File
@@ -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)))
+1
View File
@@ -0,0 +1 @@
# Shared modules package
+50
View File
@@ -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"
+62
View File
@@ -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()
+122
View File
@@ -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
+404
View File
@@ -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"),
)
+234
View File
@@ -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
}
+132
View File
@@ -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)
+1 -1
View File
@@ -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 # Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
# Import them directly from websocket.handlers when needed # Import them directly from websocket.handlers when needed
+2 -2
View File
@@ -2,8 +2,8 @@ from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from types import SimpleNamespace from types import SimpleNamespace
from dependencies import get_current_user from backend.shared.dependencies import get_current_user
from models import User from backend.shared.models import User
def extract_token_from_data(data: dict) -> str | None: def extract_token_from_data(data: dict) -> str | None:
+68
View File
@@ -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;
+127
View File
@@ -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
);
+312 -57
View File
@@ -1,67 +1,322 @@
services: services:
backend: # Database service
build: db:
dockerfile: deployment/Dockerfile.backend image: postgres:15
context: ..
environment: environment:
PORT: 8300 POSTGRES_DB: fromchat
JWT_SECRET: ${JWT_SECRET} POSTGRES_USER: fromchat_admin
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
FIREBASE_CERT: ${FIREBASE_CERT}
volumes: volumes:
- data:/app/data - postgres_data:/var/lib/postgresql/data
- logs:/app/logs - ./db-init:/docker-entrypoint-initdb.d
networks:
develop: - fromchat_internal
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
restart: unless-stopped 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: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- certs:/root/site/certs - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
environment: - caddy_data:/data
XDG_DATA_HOME: /root/site/certs - caddy_config:/config
XDG_CONFIG_HOME: /root/site/certs networks:
- fromchat_external
restart: unless-stopped
depends_on:
- gateway
volumes: volumes:
data: postgres_data:
name: fromchat-data caddy_data:
logs: caddy_config:
name: fromchat-logs
certs: networks:
name: fromchat-certs fromchat_internal:
driver: bridge
internal: true
fromchat_external:
driver: bridge
+122
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Entrypoint script for FromChat microservices
# Run the service module
exec python -m backend.services.${SERVICE_NAME}.main
+18 -6
View File
@@ -314,6 +314,8 @@ step "Detecting services"
cd "$DEPLOYMENT_DIR" cd "$DEPLOYMENT_DIR"
SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null) 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 if [ -z "$SERVICES" ]; then
error "No services found in docker-compose.yml" error "No services found in docker-compose.yml"
fi fi
@@ -341,6 +343,10 @@ for SERVICE in $SERVICES; do
sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \ sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \
tr -d '"' | tr -d "'" | xargs) 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:" | \ CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \
sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \ sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \
tr -d '"' | tr -d "'" | xargs) tr -d '"' | tr -d "'" | xargs)
@@ -377,12 +383,18 @@ for SERVICE in $SERVICES; do
fi fi
fi fi
if docker buildx build \ # Safety check: ensure no sqlite in DATABASE_URL for Docker services
--platform "$PLATFORM" \ if grep -q "DATABASE_URL.*sqlite" "$DEPLOYMENT_DIR/docker-compose.yml" 2>/dev/null; then
--file "$DOCKERFILE" \ error "Found sqlite DATABASE_URL in docker-compose.yml - SQLite not allowed in Docker"
--tag "$IMAGE_TAG" \ exit 1
--load \ fi
"$BUILD_CONTEXT"; then
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}" echo -e " ${GREEN}${NC} Built ${CYAN}$SERVICE${NC}"
BUILT_IMAGES+=("$IMAGE_TAG") BUILT_IMAGES+=("$IMAGE_TAG")
echo "" echo ""