mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement real, working microservices architecture
This commit is contained in:
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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,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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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] = {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)))
|
||||||
@@ -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
|
||||||
@@ -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)))
|
||||||
@@ -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
|
||||||
@@ -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)))
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)))
|
||||||
@@ -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
|
||||||
@@ -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)))
|
||||||
@@ -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
|
||||||
@@ -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)))
|
||||||
@@ -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
|
||||||
@@ -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)))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Shared modules package
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,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,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:
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+5
@@ -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
@@ -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 ""
|
||||||
|
|||||||
Reference in New Issue
Block a user