mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Compare commits
6 Commits
@@ -1,12 +0,0 @@
|
||||
# Exclude data directory to prevent local database from being copied into production images
|
||||
backend/data/
|
||||
|
||||
# Exclude logs
|
||||
backend/logs/
|
||||
|
||||
# Exclude development files
|
||||
node_modules/
|
||||
.git/
|
||||
.gitignore
|
||||
README.md
|
||||
*.log
|
||||
+1
-2
@@ -575,5 +575,4 @@ backend/alembic/**
|
||||
!backend/alembic/env.py
|
||||
!backend/alembic/script.py.mako
|
||||
!frontend/src/css/lib
|
||||
**/*.module.scss.d.ts
|
||||
.cursor/plans
|
||||
**/*.module.scss.d.ts
|
||||
Vendored
+1
-2
@@ -6,6 +6,5 @@
|
||||
"**/.husky/_": true,
|
||||
"**/.venv": true,
|
||||
"**/node_modules": true
|
||||
},
|
||||
"python.terminal.activateEnvironment": false
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = alembic
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
@@ -84,7 +84,7 @@ path_separator = os
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
# sqlalchemy.url is set dynamically from DATABASE_URL environment variable
|
||||
sqlalchemy.url = sqlite:///./data/database.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from backend.shared.models import Base
|
||||
from models import Base
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
|
||||
+91
-118
@@ -1,43 +1,102 @@
|
||||
import asyncio
|
||||
import time
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import httpx
|
||||
from routes import account, messaging, profile, push, webrtc, devices, moderation
|
||||
import logging
|
||||
# Gateway doesn't need direct model access - it's a stateless proxy
|
||||
# Gateway doesn't need constants - it's a stateless proxy
|
||||
from backend.shared.utils import get_client_ip
|
||||
from models import User
|
||||
from constants import OWNER_USERNAME
|
||||
from utils import get_client_ip
|
||||
|
||||
# Gateway doesn't need database access - it's a stateless proxy
|
||||
from backend.logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||
from backend.security.audit import log_access
|
||||
from backend.security.rate_limit import limiter
|
||||
from db import POOL_CONFIG, SessionLocal
|
||||
from logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||
from security.audit import log_access
|
||||
from security.rate_limit import limiter
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
|
||||
# Service URL mapping for routing
|
||||
SERVICE_URLS = {
|
||||
"account": os.getenv("ACCOUNT_SERVICE_URL", "http://account_service:8302"),
|
||||
"profile": os.getenv("PROFILE_SERVICE_URL", "http://profile_service:8303"),
|
||||
"devices": os.getenv("DEVICE_SERVICE_URL", "http://device_service:8304"),
|
||||
"messaging": os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305"),
|
||||
"push": os.getenv("PUSH_SERVICE_URL", "http://push_service:8306"),
|
||||
"webrtc": os.getenv("WEBRTC_SERVICE_URL", "http://webrtc_service:8307"),
|
||||
"moderation": os.getenv("MODERATION_SERVICE_URL", "http://moderation_service:8308"),
|
||||
}
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Gateway is a stateless proxy - no database operations or background tasks needed
|
||||
logger.info("Gateway proxy service initialized - routing to microservices")
|
||||
# Startup - run migration in separate process to avoid logging interference
|
||||
try:
|
||||
logger.info("Starting database migration check...")
|
||||
# Run migration in a separate process
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
||||
],
|
||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run database migrations: {e}")
|
||||
raise
|
||||
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
owner = db.query(User).filter(User.username == OWNER_USERNAME).first()
|
||||
if owner and not owner.verified:
|
||||
owner.verified = True
|
||||
db.commit()
|
||||
logger.info(f"Owner user '{OWNER_USERNAME}' has been verified")
|
||||
elif owner and owner.verified:
|
||||
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
|
||||
else:
|
||||
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure owner verification: {e}")
|
||||
|
||||
logger.info(
|
||||
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
|
||||
POOL_CONFIG["pool_size"],
|
||||
POOL_CONFIG["max_overflow"],
|
||||
POOL_CONFIG["pool_timeout"],
|
||||
POOL_CONFIG["pool_recycle"],
|
||||
POOL_CONFIG["pool_pre_ping"],
|
||||
)
|
||||
|
||||
# Start the messaging cleanup task
|
||||
try:
|
||||
from routes.messaging import messagingManager
|
||||
messagingManager.start_cleanup_task()
|
||||
logger.info("Messaging cleanup task started")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start messaging cleanup task: {e}")
|
||||
|
||||
# Reset all rate limits on startup to ensure clean state
|
||||
# This prevents rate limits from persisting across restarts
|
||||
try:
|
||||
from security.rate_limit import reset_all_rate_limits
|
||||
cleared = reset_all_rate_limits()
|
||||
if cleared > 0:
|
||||
logger.info(f"Cleared {cleared} rate limit entries on startup")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
||||
|
||||
# Start the rate limit cleanup task
|
||||
try:
|
||||
from security.rate_limit import start_rate_limit_cleanup_task
|
||||
cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task())
|
||||
logger.info("Rate limit cleanup task started")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rate limit cleanup task: {e}")
|
||||
cleanup_task = None
|
||||
|
||||
yield
|
||||
logger.info("Gateway proxy service shutting down.")
|
||||
|
||||
# Shutdown - cancel cleanup task if it exists
|
||||
if cleanup_task:
|
||||
cleanup_task.cancel()
|
||||
try:
|
||||
await cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Инициализация FastAPI
|
||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||
@@ -49,16 +108,6 @@ app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
@app.middleware("http")
|
||||
async def access_logging_middleware(request: Request, call_next):
|
||||
# Log incoming request and Authorization header presence for debugging auth issues
|
||||
try:
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header:
|
||||
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
|
||||
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
|
||||
else:
|
||||
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
|
||||
except Exception:
|
||||
pass
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
@@ -108,87 +157,11 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Common API endpoints - route to appropriate services (defined first for priority)
|
||||
@app.api_route("/login", methods=["POST"])
|
||||
async def login(request: Request):
|
||||
"""Login endpoint - routes to account service."""
|
||||
return await _proxy_to_service("account", "login", request)
|
||||
|
||||
@app.api_route("/register", methods=["POST"])
|
||||
async def register(request: Request):
|
||||
"""Register endpoint - routes to account service."""
|
||||
return await _proxy_to_service("account", "register", request)
|
||||
|
||||
|
||||
# API routes - route to appropriate microservices
|
||||
@app.api_route("/account/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
async def proxy_account(path: str, request: Request):
|
||||
"""Proxy account service requests."""
|
||||
return await _proxy_to_service("account", path, request)
|
||||
|
||||
@app.api_route("/profile/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
async def proxy_profile(path: str, request: Request):
|
||||
"""Proxy profile service requests."""
|
||||
return await _proxy_to_service("profile", path, request)
|
||||
|
||||
@app.api_route("/devices/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
async def proxy_devices(path: str, request: Request):
|
||||
"""Proxy device service requests."""
|
||||
return await _proxy_to_service("devices", path, request)
|
||||
|
||||
@app.api_route("/messaging/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
async def proxy_messaging(path: str, request: Request):
|
||||
"""Proxy messaging service requests."""
|
||||
return await _proxy_to_service("messaging", path, request)
|
||||
|
||||
@app.api_route("/push/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
async def proxy_push(path: str, request: Request):
|
||||
"""Proxy push service requests."""
|
||||
return await _proxy_to_service("push", path, request)
|
||||
|
||||
@app.api_route("/webrtc/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
async def proxy_webrtc(path: str, request: Request):
|
||||
"""Proxy WebRTC service requests."""
|
||||
return await _proxy_to_service("webrtc", path, request)
|
||||
|
||||
@app.api_route("/moderation/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
async def proxy_moderation(path: str, request: Request):
|
||||
"""Proxy moderation service requests."""
|
||||
return await _proxy_to_service("moderation", path, request)
|
||||
|
||||
async def _proxy_to_service(service: str, path: str, request: Request):
|
||||
"""Helper function to proxy requests to microservices."""
|
||||
from fastapi.responses import Response
|
||||
|
||||
service_url = SERVICE_URLS[service]
|
||||
target_url = f"{service_url}/{service}/{path}"
|
||||
|
||||
# Get request body
|
||||
body = await request.body()
|
||||
|
||||
# Prepare headers (remove host header)
|
||||
headers = dict(request.headers)
|
||||
headers.pop("host", None)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
params=request.query_params,
|
||||
)
|
||||
|
||||
# Return response with the same status code and content
|
||||
content = response.content
|
||||
return Response(
|
||||
content=content,
|
||||
status_code=response.status_code,
|
||||
headers={"content-type": response.headers.get("content-type", "application/json")}
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
logging.error(f"Error communicating with {service} service: {exc}")
|
||||
raise HTTPException(status_code=503, detail=f"Service {service} unavailable")
|
||||
|
||||
# Routes are handled by the catch-all proxy above
|
||||
# Routes
|
||||
app.include_router(account.router)
|
||||
app.include_router(messaging.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
app.include_router(webrtc.router, prefix="/webrtc")
|
||||
app.include_router(devices.router, prefix="/devices")
|
||||
app.include_router(moderation.router)
|
||||
+1
-17
@@ -5,10 +5,8 @@ from sqlalchemy.orm import Session
|
||||
from utils import verify_token
|
||||
from models import User, DeviceSession
|
||||
from db import SessionLocal
|
||||
import logging
|
||||
|
||||
security = HTTPBearer()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Зависимость для получения сессии БД
|
||||
def get_db():
|
||||
@@ -25,17 +23,8 @@ def get_current_user(
|
||||
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"},
|
||||
)
|
||||
payload = verify_token(token)
|
||||
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",
|
||||
@@ -43,7 +32,6 @@ def get_current_user(
|
||||
)
|
||||
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",
|
||||
@@ -72,7 +60,6 @@ def get_current_user(
|
||||
)
|
||||
|
||||
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",
|
||||
@@ -86,7 +73,6 @@ def get_current_user(
|
||||
# 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",
|
||||
@@ -99,7 +85,6 @@ def get_current_user(
|
||||
|
||||
# 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",
|
||||
@@ -108,7 +93,6 @@ def get_current_user(
|
||||
|
||||
# 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",
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
from backend.shared.constants import *
|
||||
from backend.shared.db import *
|
||||
from backend.shared.models import *
|
||||
from backend.shared.validation import *
|
||||
from backend.shared.utils import *
|
||||
from backend.shared.dependencies import *
|
||||
from constants import *
|
||||
from db import *
|
||||
from models import *
|
||||
from validation import *
|
||||
from utils import *
|
||||
from dependencies import *
|
||||
from app import *
|
||||
+42
-76
@@ -8,7 +8,7 @@ from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine
|
||||
from backend.shared.constants import DATABASE_URL
|
||||
from constants import DATABASE_URL
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,36 +19,19 @@ def run_migrations():
|
||||
This function will upgrade the database to the latest migration.
|
||||
Fully automated - handles all scenarios automatically.
|
||||
"""
|
||||
# Get the directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
try:
|
||||
# FIRST: Check if database has any application tables (excluding alembic_version)
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(connection)
|
||||
existing_tables = [table for table in inspector.get_table_names()
|
||||
if not table.startswith('sqlite_') and table != 'alembic_version']
|
||||
|
||||
# If no application tables exist, create them directly from models
|
||||
if not existing_tables:
|
||||
logger.info("No application tables found. Creating all tables directly from models...")
|
||||
from backend.shared.models import Base
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("All tables created successfully from models.")
|
||||
|
||||
# Note: Problematic migrations are now cleaned up during Docker build
|
||||
|
||||
# Get the directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Create Alembic configuration
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
|
||||
|
||||
# Disable Alembic's logging configuration to avoid interfering with FastAPI
|
||||
alembic_cfg.set_main_option("configure_logging", "false")
|
||||
|
||||
|
||||
# Set the database URL in the config
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
|
||||
# Check if any migration files exist
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
|
||||
@@ -56,13 +39,6 @@ def run_migrations():
|
||||
os.makedirs(versions_dir)
|
||||
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
# If no migration files exist after cleanup, create initial migration
|
||||
if not migration_files:
|
||||
logger.info("No migration files found after cleanup. Creating initial migration...")
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
logger.info(f"Created {len(migration_files)} initial migration(s)")
|
||||
|
||||
if not migration_files:
|
||||
logger.info("No migration files found. Creating initial migration...")
|
||||
@@ -147,11 +123,7 @@ def run_migrations():
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully.")
|
||||
except Exception as upgrade_error:
|
||||
error_msg = str(upgrade_error)
|
||||
# Handle PostgreSQL "already exists" errors gracefully
|
||||
if "already exists" in error_msg.lower() or "relation" in error_msg.lower() and "exists" in error_msg.lower():
|
||||
pass
|
||||
elif "Can't locate revision identified by 'direct_creation'" in error_msg:
|
||||
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
||||
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
||||
# Clear the alembic_version table and start fresh
|
||||
engine = create_engine(DATABASE_URL)
|
||||
@@ -192,11 +164,10 @@ def run_migrations():
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DELETE FROM alembic_version"))
|
||||
connection.commit()
|
||||
|
||||
|
||||
# Try upgrade again
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully after reset.")
|
||||
# Note: Index-related errors are now prevented by Docker build cleanup
|
||||
else:
|
||||
raise upgrade_error
|
||||
|
||||
@@ -303,7 +274,7 @@ def _populate_migration_file(migration_path):
|
||||
|
||||
def _generate_migration_from_models():
|
||||
"""Generate migration content dynamically from SQLAlchemy models."""
|
||||
from backend.shared.models import Base
|
||||
from models import Base
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime
|
||||
|
||||
@@ -488,12 +459,10 @@ def _get_column_type(column):
|
||||
|
||||
def _create_database_directly():
|
||||
"""Fallback method: create database directly using SQLAlchemy."""
|
||||
from backend.shared.models import Base
|
||||
from backend.shared.db import get_engine
|
||||
from models import Base
|
||||
from db import engine
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
|
||||
# Check existing tables and update schema
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
@@ -550,38 +519,35 @@ def _create_database_directly():
|
||||
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
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS alembic_version (
|
||||
version_num VARCHAR(32) NOT NULL,
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)
|
||||
"""))
|
||||
|
||||
# Get the correct revision ID from existing migration files
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
import re
|
||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
||||
if revision_match:
|
||||
revision_id = revision_match.group(1)
|
||||
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING"))
|
||||
else:
|
||||
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
|
||||
else:
|
||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||
|
||||
connection.commit()
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS alembic_version (
|
||||
version_num VARCHAR(32) NOT NULL,
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)
|
||||
"""))
|
||||
|
||||
# Get the correct revision ID from existing migration files
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
import re
|
||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
||||
if revision_match:
|
||||
revision_id = revision_match.group(1)
|
||||
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
||||
else:
|
||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||
else:
|
||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _get_sql_type(column):
|
||||
|
||||
+49
-10
@@ -71,6 +71,55 @@ class CryptoBackup(Base):
|
||||
blob_json = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class SignalPreKeyBundle(Base):
|
||||
__tablename__ = "signal_prekey_bundle"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
bundle_json = Column(Text, nullable=False) # JSON string of PreKeyBundleData (identity, signed prekey, registration ID)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class SignalPreKey(Base):
|
||||
__tablename__ = "signal_prekey"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
prekey_id = Column(Integer, nullable=False) # The prekey ID from the client
|
||||
public_key = Column(Text, nullable=False) # Base64 encoded public key
|
||||
used = Column(Boolean, default=False, nullable=False, index=True) # Whether this prekey has been used
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
__table_args__ = (UniqueConstraint('user_id', 'prekey_id', name='_user_prekey_uc'),)
|
||||
|
||||
|
||||
class SignalSession(Base):
|
||||
__tablename__ = "signal_session"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
recipient_id = Column(Integer, nullable=False, index=True) # The other user in the session
|
||||
device_id = Column(Integer, default=1, nullable=False) # Device ID (always 1 for now)
|
||||
encrypted_session_data = Column(Text, nullable=False) # Encrypted session record (JSON with salt, iv, ciphertext)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
__table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_uc'),)
|
||||
|
||||
|
||||
class SentMessagePlaintext(Base):
|
||||
"""Stores encrypted plaintexts of sent messages for history display"""
|
||||
__tablename__ = "sent_message_plaintext"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
message_id = Column(Integer, nullable=False, index=True) # DM envelope ID
|
||||
recipient_id = Column(Integer, nullable=False, index=True) # The recipient of the message
|
||||
encrypted_data = Column(Text, nullable=False) # Encrypted plaintext (JSON with salt, iv, ciphertext)
|
||||
created_at = Column(DateTime, default=datetime.now, index=True)
|
||||
|
||||
__table_args__ = (UniqueConstraint('user_id', 'message_id', name='_user_message_uc'),)
|
||||
|
||||
|
||||
class DMEnvelope(Base):
|
||||
__tablename__ = "dm_envelope"
|
||||
|
||||
@@ -113,16 +162,6 @@ class PushSubscription(Base):
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from pywebpush import webpush, WebPushException
|
||||
from models import PushSubscription, User, Message, DMEnvelope
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
class PushNotificationService:
|
||||
def __init__(self):
|
||||
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
|
||||
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
|
||||
|
||||
if (not self.vapid_public_key) or (not self.vapid_private_key):
|
||||
raise ValueError("VAPID public or private key is None")
|
||||
|
||||
self.vapid_claims = {
|
||||
"sub": "mailto:support@fromchat.ru",
|
||||
"aud": "https://fcm.googleapis.com"
|
||||
}
|
||||
|
||||
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
|
||||
"""Subscribe a user to push notifications"""
|
||||
try:
|
||||
# Check if user already has a subscription
|
||||
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
existing_sub.endpoint = endpoint
|
||||
existing_sub.p256dh_key = p256dh_key
|
||||
existing_sub.auth_key = auth_key
|
||||
else:
|
||||
# Create new subscription
|
||||
new_sub = PushSubscription(
|
||||
user_id=user_id,
|
||||
endpoint=endpoint,
|
||||
p256dh_key=p256dh_key,
|
||||
auth_key=auth_key
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Push subscription saved for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
|
||||
"""Send push notification for a new public chat message"""
|
||||
try:
|
||||
# Get all users except the sender
|
||||
users = db.query(User).filter(User.id != message.user_id)
|
||||
if exclude_user_id:
|
||||
users = users.filter(User.id != exclude_user_id)
|
||||
|
||||
for user in users:
|
||||
# Check if user has push subscription before trying to send
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
||||
if not subscription:
|
||||
continue
|
||||
|
||||
await self._send_notification_to_user(
|
||||
db, user.id,
|
||||
f"New message from {message.author.username}",
|
||||
message.content[:100] + ("..." if len(message.content) > 100 else ""),
|
||||
message.author.profile_picture,
|
||||
{
|
||||
"type": "public_message",
|
||||
"message_id": message.id,
|
||||
"sender_id": message.user_id,
|
||||
"sender_username": message.author.username
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send public message notifications: {e}")
|
||||
|
||||
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
||||
"""Send push notification for a new DM"""
|
||||
try:
|
||||
await self._send_notification_to_user(
|
||||
db, dm_envelope.recipient_id,
|
||||
f"New message from {sender.username}",
|
||||
"You have a new direct message",
|
||||
sender.profile_picture,
|
||||
{
|
||||
"type": "dm",
|
||||
"dm_id": dm_envelope.id,
|
||||
"sender_id": sender.id,
|
||||
"sender_username": sender.username
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send DM notification: {e}")
|
||||
|
||||
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
|
||||
"""Send a push notification to a specific user"""
|
||||
try:
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"icon": icon or "about:blank",
|
||||
"tag": f"message_{user_id}",
|
||||
"data": data
|
||||
}
|
||||
|
||||
subscription_info = {
|
||||
"endpoint": subscription.endpoint,
|
||||
"keys": {
|
||||
"p256dh": subscription.p256dh_key,
|
||||
"auth": subscription.auth_key
|
||||
}
|
||||
}
|
||||
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=self.vapid_private_key,
|
||||
vapid_claims=self.vapid_claims
|
||||
)
|
||||
|
||||
except WebPushException as e:
|
||||
logger.error(f"WebPush error for user {user_id}: {e}")
|
||||
# If the subscription is invalid, remove it
|
||||
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
||||
|
||||
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
||||
"""Unsubscribe a user from push notifications"""
|
||||
try:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
logger.info(f"Push subscription removed for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
# Global instance
|
||||
push_service = PushNotificationService()
|
||||
@@ -2,7 +2,6 @@ PyJWT>=2.8.0
|
||||
fastapi[standard]>=0.116.1
|
||||
pydantic>=2.11.7
|
||||
sqlalchemy>=2.0.43
|
||||
psycopg2-binary>=2.9.9
|
||||
bcrypt>=4.3.0
|
||||
websockets>=15.0.1
|
||||
Pillow>=10.0.0
|
||||
@@ -15,5 +14,3 @@ user-agents>=2.2.0
|
||||
httpx>=0.27.2
|
||||
rich>=13.9.4
|
||||
slowapi>=0.1.9
|
||||
firebase_admin>=7.1.0
|
||||
PyNaCl>=1.5.0
|
||||
+505
-61
@@ -8,16 +8,16 @@ import uuid
|
||||
from user_agents import parse as parse_ua
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from backend.shared.constants import OWNER_USERNAME
|
||||
from backend.shared.dependencies import get_current_user, get_db
|
||||
from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup
|
||||
from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||
from backend.shared.validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession, SentMessagePlaintext
|
||||
from utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||
import os
|
||||
|
||||
from backend.security.audit import log_security
|
||||
from backend.security.profanity import contains_profanity
|
||||
from backend.security.rate_limit import rate_limit_per_ip
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
router = APIRouter()
|
||||
|
||||
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
||||
@@ -39,24 +39,21 @@ def _record_failed_login(identifier: str) -> bool:
|
||||
def _reset_failed_logins(identifier: str) -> None:
|
||||
_failed_login_attempts.pop(identifier, None)
|
||||
|
||||
def _is_admin(user: User) -> bool:
|
||||
return user.id == 1
|
||||
|
||||
def convert_user(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_seen": user.last_seen.isoformat(),
|
||||
"online": user.is_online,
|
||||
"online": user.online,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"profile_picture": user.avatar_url,
|
||||
"profile_picture": user.profile_picture,
|
||||
"bio": user.bio,
|
||||
"admin": _is_admin(user),
|
||||
"admin": user.username == OWNER_USERNAME,
|
||||
"verified": user.verified,
|
||||
"suspended": user.suspended,
|
||||
"suspended": user.suspended or False,
|
||||
"suspension_reason": user.suspension_reason,
|
||||
"deleted": user.deleted
|
||||
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
||||
}
|
||||
|
||||
@router.get("/check_auth")
|
||||
@@ -64,7 +61,7 @@ def check_auth(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"authenticated": True,
|
||||
"username": current_user.username,
|
||||
"admin": _is_admin(current_user)
|
||||
"admin": current_user.username == OWNER_USERNAME
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +74,7 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user or not verify_password(login_request.password.strip(), user.hashed_password):
|
||||
if not user or not verify_password(login_request.password.strip(), user.password_hash):
|
||||
log_security(
|
||||
"login_failed",
|
||||
severity="warning",
|
||||
@@ -112,9 +109,30 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
detail="Неверное имя пользователя или пароль"
|
||||
)
|
||||
|
||||
# Generate session ID for JWT (device session will be created on first device service access)
|
||||
# Create device session and embed into JWT
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
device_name = request.headers.get("x-device-name")
|
||||
ua = parse_ua(raw_ua or "")
|
||||
session_id = uuid.uuid4().hex
|
||||
|
||||
device = DeviceSession(
|
||||
user_id=user.id,
|
||||
raw_user_agent=raw_ua,
|
||||
device_name=device_name,
|
||||
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
||||
os_name=(ua.os.family or None),
|
||||
os_version=(ua.os.version_string or None),
|
||||
browser_name=(ua.browser.family or None),
|
||||
browser_version=(ua.browser.version_string or None),
|
||||
brand=(ua.device.brand or None),
|
||||
model=(ua.device.model or None),
|
||||
session_id=session_id,
|
||||
created_at=datetime.now(),
|
||||
last_seen=datetime.now(),
|
||||
revoked=False,
|
||||
)
|
||||
db.add(device)
|
||||
|
||||
user.online = True
|
||||
user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
@@ -127,19 +145,15 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
for identifier in identifiers:
|
||||
_reset_failed_logins(identifier)
|
||||
|
||||
# Parse user agent for logging
|
||||
ua = parse_ua(raw_ua or "")
|
||||
device_type = "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"
|
||||
|
||||
log_security(
|
||||
"login_success",
|
||||
username=user.username,
|
||||
user_id=user.id,
|
||||
ip=client_ip,
|
||||
session_id=session_id,
|
||||
device=device_type,
|
||||
os=ua.os.family,
|
||||
browser=ua.browser.family,
|
||||
device=device.device_type,
|
||||
os=device.os_name,
|
||||
browser=device.browser_name,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -163,6 +177,13 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
||||
# Determine if owner already exists
|
||||
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
||||
|
||||
# If owner not yet registered, only allow the owner to register
|
||||
if not owner_exists and username != OWNER_USERNAME:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Регистрация временно закрыта до регистрации владельца"
|
||||
)
|
||||
|
||||
# Validate input
|
||||
if not is_valid_username(username):
|
||||
raise HTTPException(
|
||||
@@ -198,6 +219,13 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
||||
detail="Пароли не совпадают"
|
||||
)
|
||||
|
||||
# After owner exists, disallow registering the reserved owner username via public registration
|
||||
if owner_exists and username == OWNER_USERNAME:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Это имя пользователя зарезервировано"
|
||||
)
|
||||
|
||||
existing_user = db.query(User).filter(User.username == username).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
@@ -213,9 +241,8 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
||||
new_user = User(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
hashed_password=hashed_password,
|
||||
salt="", # Not used since bcrypt includes salt in hash
|
||||
is_online=True,
|
||||
password_hash=hashed_password,
|
||||
online=True,
|
||||
last_seen=datetime.now(),
|
||||
verified=is_owner
|
||||
)
|
||||
@@ -224,13 +251,32 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# Generate a temporary session ID for the token (device session will be created on first device service access)
|
||||
# Create initial device session
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
device_name = request.headers.get("x-device-name")
|
||||
ua = parse_ua(raw_ua or "")
|
||||
session_id = uuid.uuid4().hex
|
||||
device = DeviceSession(
|
||||
user_id=new_user.id,
|
||||
raw_user_agent=raw_ua,
|
||||
device_name=device_name,
|
||||
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
||||
os_name=(ua.os.family or None),
|
||||
os_version=(ua.os.version_string or None),
|
||||
browser_name=(ua.browser.family or None),
|
||||
browser_version=(ua.browser.version_string or None),
|
||||
brand=(ua.device.brand or None),
|
||||
model=(ua.device.model or None),
|
||||
session_id=session_id,
|
||||
created_at=datetime.now(),
|
||||
last_seen=datetime.now(),
|
||||
revoked=False,
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
|
||||
token = create_token(new_user.id, new_user.username, session_id)
|
||||
|
||||
# Parse user agent for logging
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
ua = parse_ua(raw_ua or "")
|
||||
os_name = ua.os.family or "Unknown OS"
|
||||
if ua.os.version_string:
|
||||
os_name = f"{os_name} {ua.os.version_string}"
|
||||
@@ -309,7 +355,7 @@ def delete_user_as_owner(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Only owner can delete users
|
||||
if _is_admin(current_user):
|
||||
if current_user.username != OWNER_USERNAME:
|
||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
@@ -317,11 +363,11 @@ def delete_user_as_owner(
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deleting the owner account via API
|
||||
if _is_admin(user):
|
||||
if user.username == OWNER_USERNAME:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete owner account")
|
||||
|
||||
# Manually delete user's messages to satisfy FK constraints
|
||||
from backend.shared.models import Message # local import to avoid circular
|
||||
from models import Message # local import to avoid circular
|
||||
db.query(Message).filter(Message.user_id == user.id).delete()
|
||||
|
||||
db.delete(user)
|
||||
@@ -345,14 +391,14 @@ def logout(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Revoke current session - TODO: Move to device service
|
||||
# from backend.shared.utils import verify_token as _verify_token
|
||||
# payload = _verify_token(credentials.credentials)
|
||||
# if payload and payload.get("session_id"):
|
||||
# db.query(DeviceSession).filter(
|
||||
# DeviceSession.user_id == current_user.id,
|
||||
# DeviceSession.session_id == payload["session_id"],
|
||||
# ).update({DeviceSession.revoked: True})
|
||||
# Revoke current session
|
||||
from utils import verify_token as _verify_token
|
||||
payload = _verify_token(credentials.credentials)
|
||||
if payload and payload.get("session_id"):
|
||||
db.query(DeviceSession).filter(
|
||||
DeviceSession.user_id == current_user.id,
|
||||
DeviceSession.session_id == payload["session_id"],
|
||||
).update({DeviceSession.revoked: True})
|
||||
|
||||
current_user.online = False
|
||||
current_user.last_seen = datetime.now()
|
||||
@@ -364,7 +410,7 @@ def logout(
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip=client_ip,
|
||||
session_id=None, # TODO: Get session_id from device service
|
||||
session_id=payload.get("session_id") if payload else None,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -383,25 +429,25 @@ def change_password(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Verify current derived password against stored hash
|
||||
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.hashed_password):
|
||||
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
|
||||
|
||||
# Update password hash to hash of new derived password
|
||||
current_user.hashed_password = get_password_hash(password_request.newPasswordDerived.strip())
|
||||
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip())
|
||||
db.commit()
|
||||
|
||||
# Optionally revoke all other sessions, keeping the current one - TODO: Move to device service
|
||||
# if password_request.logoutAllExceptCurrent:
|
||||
# from backend.shared.utils import verify_token as _verify_token
|
||||
# payload = _verify_token(credentials.credentials)
|
||||
# if not payload:
|
||||
# raise HTTPException(status_code=401, detail="Invalid token")
|
||||
# current_session_id = payload.get("session_id")
|
||||
# db.query(DeviceSession).filter(
|
||||
# DeviceSession.user_id == current_user.id,
|
||||
# DeviceSession.session_id != current_session_id,
|
||||
# ).update({DeviceSession.revoked: True})
|
||||
# db.commit()
|
||||
# Optionally revoke all other sessions, keeping the current one
|
||||
if password_request.logoutAllExceptCurrent:
|
||||
from utils import verify_token as _verify_token
|
||||
payload = _verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
current_session_id = payload.get("session_id")
|
||||
db.query(DeviceSession).filter(
|
||||
DeviceSession.user_id == current_user.id,
|
||||
DeviceSession.session_id != current_session_id,
|
||||
).update({DeviceSession.revoked: True})
|
||||
db.commit()
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
log_security(
|
||||
@@ -433,6 +479,404 @@ def get_public_key_of(request: Request, user_id: int, current_user: User = Depen
|
||||
return {"publicKey": row.public_key_b64 if row else None}
|
||||
|
||||
|
||||
@router.post("/crypto/signal/prekey-bundle")
|
||||
@rate_limit_per_ip("10/minute")
|
||||
def upload_prekey_bundle(
|
||||
request: Request,
|
||||
payload: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Upload Signal Protocol prekey bundle for the current user"""
|
||||
from models import SignalPreKeyBundle, SignalPreKey
|
||||
import json
|
||||
|
||||
bundle = payload.get("bundle")
|
||||
if not bundle:
|
||||
raise HTTPException(status_code=400, detail="bundle required")
|
||||
|
||||
# Validate bundle structure
|
||||
if not isinstance(bundle, dict):
|
||||
raise HTTPException(status_code=400, detail="bundle must be a JSON object")
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ["registrationId", "identityKey", "signedPreKey"]
|
||||
for field in required_fields:
|
||||
if field not in bundle:
|
||||
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
||||
|
||||
if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
|
||||
|
||||
# Store bundle (identity key, signed prekey, registration ID) - without the one-time prekey
|
||||
bundle_without_prekey = {
|
||||
"registrationId": bundle["registrationId"],
|
||||
"identityKey": bundle["identityKey"],
|
||||
"signedPreKey": bundle["signedPreKey"]
|
||||
}
|
||||
bundle_json = json.dumps(bundle_without_prekey)
|
||||
if len(bundle_json) > 50000: # 50KB limit
|
||||
raise HTTPException(status_code=400, detail="Bundle too large")
|
||||
|
||||
# Store or update the bundle
|
||||
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.bundle_json = bundle_json
|
||||
row.updated_at = datetime.now()
|
||||
else:
|
||||
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
|
||||
db.add(row)
|
||||
|
||||
# Store the one-time prekey if provided
|
||||
if "preKey" in bundle and bundle["preKey"]:
|
||||
prekey = bundle["preKey"]
|
||||
if isinstance(prekey, dict) and "keyId" in prekey and "publicKey" in prekey:
|
||||
# Check if this prekey already exists
|
||||
existing = db.query(SignalPreKey).filter(
|
||||
SignalPreKey.user_id == current_user.id,
|
||||
SignalPreKey.prekey_id == prekey["keyId"]
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing prekey (mark as unused if it was used)
|
||||
existing.public_key = prekey["publicKey"]
|
||||
existing.used = False
|
||||
existing.created_at = datetime.now()
|
||||
else:
|
||||
# Add new prekey
|
||||
new_prekey = SignalPreKey(
|
||||
user_id=current_user.id,
|
||||
prekey_id=prekey["keyId"],
|
||||
public_key=prekey["publicKey"],
|
||||
used=False
|
||||
)
|
||||
db.add(new_prekey)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/crypto/signal/prekeys/bulk")
|
||||
@rate_limit_per_ip("10/minute")
|
||||
def upload_prekeys_bulk(
|
||||
request: Request,
|
||||
payload: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Upload multiple Signal Protocol prekeys in one request"""
|
||||
from models import SignalPreKeyBundle, SignalPreKey
|
||||
import json
|
||||
|
||||
base_bundle = payload.get("baseBundle")
|
||||
prekeys = payload.get("prekeys", [])
|
||||
|
||||
if not base_bundle:
|
||||
raise HTTPException(status_code=400, detail="baseBundle required")
|
||||
|
||||
if not isinstance(prekeys, list):
|
||||
raise HTTPException(status_code=400, detail="prekeys must be an array")
|
||||
|
||||
# Validate base bundle structure
|
||||
if not isinstance(base_bundle, dict):
|
||||
raise HTTPException(status_code=400, detail="baseBundle must be a JSON object")
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ["registrationId", "identityKey", "signedPreKey"]
|
||||
for field in required_fields:
|
||||
if field not in base_bundle:
|
||||
raise HTTPException(status_code=400, detail=f"Missing required field in baseBundle: {field}")
|
||||
|
||||
if not isinstance(base_bundle["signedPreKey"], dict) or "keyId" not in base_bundle["signedPreKey"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
|
||||
|
||||
# Store or update the base bundle (identity key, signed prekey, registration ID)
|
||||
bundle_without_prekey = {
|
||||
"registrationId": base_bundle["registrationId"],
|
||||
"identityKey": base_bundle["identityKey"],
|
||||
"signedPreKey": base_bundle["signedPreKey"]
|
||||
}
|
||||
bundle_json = json.dumps(bundle_without_prekey)
|
||||
if len(bundle_json) > 50000: # 50KB limit
|
||||
raise HTTPException(status_code=400, detail="Bundle too large")
|
||||
|
||||
# Store or update the bundle
|
||||
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.bundle_json = bundle_json
|
||||
row.updated_at = datetime.now()
|
||||
else:
|
||||
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
|
||||
db.add(row)
|
||||
|
||||
# Store all prekeys
|
||||
for prekey in prekeys:
|
||||
if not isinstance(prekey, dict) or "keyId" not in prekey or "publicKey" not in prekey:
|
||||
continue # Skip invalid prekeys
|
||||
|
||||
# Check if this prekey already exists
|
||||
existing = db.query(SignalPreKey).filter(
|
||||
SignalPreKey.user_id == current_user.id,
|
||||
SignalPreKey.prekey_id == prekey["keyId"]
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing prekey (mark as unused if it was used)
|
||||
existing.public_key = prekey["publicKey"]
|
||||
existing.used = False
|
||||
existing.created_at = datetime.now()
|
||||
else:
|
||||
# Add new prekey
|
||||
new_prekey = SignalPreKey(
|
||||
user_id=current_user.id,
|
||||
prekey_id=prekey["keyId"],
|
||||
public_key=prekey["publicKey"],
|
||||
used=False
|
||||
)
|
||||
db.add(new_prekey)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "uploaded": len(prekeys)}
|
||||
|
||||
|
||||
@router.get("/crypto/signal/prekey-bundle")
|
||||
def get_prekey_bundle(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Signal Protocol prekey bundle for the current user"""
|
||||
from models import SignalPreKeyBundle
|
||||
import json
|
||||
|
||||
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
||||
|
||||
try:
|
||||
bundle = json.loads(row.bundle_json)
|
||||
return {"bundle": bundle}
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
||||
|
||||
|
||||
@router.get("/crypto/signal/prekey-bundle/of/{user_id}")
|
||||
@rate_limit_per_ip("100/minute")
|
||||
def get_prekey_bundle_of(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Signal Protocol prekey bundle for another user with prekey rotation"""
|
||||
from models import SignalPreKeyBundle, SignalPreKey
|
||||
import json
|
||||
|
||||
# Get the base bundle (identity key, signed prekey, registration ID)
|
||||
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
||||
|
||||
try:
|
||||
bundle = json.loads(row.bundle_json)
|
||||
|
||||
# Find an unused prekey for this user
|
||||
unused_prekey = db.query(SignalPreKey).filter(
|
||||
SignalPreKey.user_id == user_id,
|
||||
SignalPreKey.used == False
|
||||
).order_by(SignalPreKey.created_at.asc()).first()
|
||||
|
||||
if unused_prekey:
|
||||
# Mark this prekey as used (atomic operation)
|
||||
unused_prekey.used = True
|
||||
db.commit()
|
||||
|
||||
# Add the prekey to the bundle
|
||||
bundle["preKey"] = {
|
||||
"keyId": unused_prekey.prekey_id,
|
||||
"publicKey": unused_prekey.public_key
|
||||
}
|
||||
else:
|
||||
# No unused prekeys available - return bundle without prekey
|
||||
# The client will need to establish a session using the signed prekey only
|
||||
pass
|
||||
|
||||
return {"bundle": bundle}
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
||||
|
||||
|
||||
@router.post("/crypto/signal/sessions")
|
||||
@rate_limit_per_ip("100/minute")
|
||||
def upload_signal_sessions(
|
||||
request: Request,
|
||||
payload: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Upload encrypted Signal Protocol sessions for the current user"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
sessions = payload.get("sessions")
|
||||
if not isinstance(sessions, list):
|
||||
raise HTTPException(status_code=400, detail="sessions must be a list")
|
||||
|
||||
uploaded_count = 0
|
||||
for session_data in sessions:
|
||||
if not isinstance(session_data, dict):
|
||||
continue
|
||||
|
||||
recipient_id = session_data.get("recipientId")
|
||||
device_id = session_data.get("deviceId", 1)
|
||||
encrypted_data = session_data.get("encryptedData")
|
||||
|
||||
if not recipient_id or not encrypted_data:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Validate encrypted_data is valid JSON
|
||||
json.loads(encrypted_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
# Store or update session
|
||||
existing = db.query(SignalSession).filter(
|
||||
SignalSession.user_id == current_user.id,
|
||||
SignalSession.recipient_id == recipient_id,
|
||||
SignalSession.device_id == device_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.encrypted_session_data = encrypted_data
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
new_session = SignalSession(
|
||||
user_id=current_user.id,
|
||||
recipient_id=recipient_id,
|
||||
device_id=device_id,
|
||||
encrypted_session_data=encrypted_data
|
||||
)
|
||||
db.add(new_session)
|
||||
uploaded_count += 1
|
||||
|
||||
db.commit()
|
||||
return {"status": "ok", "uploaded_count": uploaded_count}
|
||||
|
||||
|
||||
@router.get("/crypto/signal/sessions")
|
||||
@rate_limit_per_ip("60/minute")
|
||||
def get_signal_sessions(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all encrypted Signal Protocol sessions for the current user"""
|
||||
sessions = db.query(SignalSession).filter(
|
||||
SignalSession.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
return {
|
||||
"sessions": [
|
||||
{
|
||||
"recipientId": s.recipient_id,
|
||||
"deviceId": s.device_id,
|
||||
"encryptedData": s.encrypted_session_data,
|
||||
"updatedAt": s.updated_at.isoformat()
|
||||
}
|
||||
for s in sessions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/crypto/signal/message-plaintexts")
|
||||
@rate_limit_per_ip("100/minute")
|
||||
def upload_message_plaintexts(
|
||||
request: Request,
|
||||
payload: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Upload encrypted plaintexts of sent messages"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
messages = payload.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
raise HTTPException(status_code=400, detail="messages must be a list")
|
||||
|
||||
uploaded_count = 0
|
||||
for msg_data in messages:
|
||||
if not isinstance(msg_data, dict):
|
||||
continue
|
||||
|
||||
message_id = msg_data.get("messageId")
|
||||
recipient_id = msg_data.get("recipientId")
|
||||
encrypted_data = msg_data.get("encryptedData")
|
||||
|
||||
if not message_id or not recipient_id or not encrypted_data:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Validate encrypted_data is valid JSON
|
||||
json.loads(encrypted_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
# Store or update plaintext
|
||||
existing = db.query(SentMessagePlaintext).filter(
|
||||
SentMessagePlaintext.user_id == current_user.id,
|
||||
SentMessagePlaintext.message_id == message_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.encrypted_data = encrypted_data
|
||||
else:
|
||||
new_plaintext = SentMessagePlaintext(
|
||||
user_id=current_user.id,
|
||||
message_id=message_id,
|
||||
recipient_id=recipient_id,
|
||||
encrypted_data=encrypted_data
|
||||
)
|
||||
db.add(new_plaintext)
|
||||
uploaded_count += 1
|
||||
|
||||
db.commit()
|
||||
return {"status": "ok", "uploaded_count": uploaded_count}
|
||||
|
||||
|
||||
@router.get("/crypto/signal/message-plaintexts")
|
||||
@rate_limit_per_ip("60/minute")
|
||||
def get_message_plaintexts(
|
||||
request: Request,
|
||||
recipient_id: int | None = None, # Optional filter by recipient
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get encrypted plaintexts of sent messages for the current user"""
|
||||
query = db.query(SentMessagePlaintext).filter(
|
||||
SentMessagePlaintext.user_id == current_user.id
|
||||
)
|
||||
|
||||
if recipient_id is not None:
|
||||
query = query.filter(SentMessagePlaintext.recipient_id == recipient_id)
|
||||
|
||||
plaintexts = query.all()
|
||||
|
||||
return {
|
||||
"messages": [
|
||||
{
|
||||
"messageId": p.message_id,
|
||||
"recipientId": p.recipient_id,
|
||||
"encryptedData": p.encrypted_data,
|
||||
"createdAt": p.created_at.isoformat()
|
||||
}
|
||||
for p in plaintexts
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
||||
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
@@ -461,7 +905,7 @@ async def _delete_user_data(user: User, db: Session):
|
||||
user.deleted = True
|
||||
user.display_name = f"Deleted User #{user_id}"
|
||||
user.bio = None
|
||||
user.hashed_password = ""
|
||||
user.password_hash = ""
|
||||
user.username = f"deleted_{user_id}"
|
||||
user.profile_picture = None
|
||||
user.last_seen = None # Clear last seen timestamp
|
||||
@@ -521,7 +965,7 @@ async def delete_account(
|
||||
Delete the current user's own account - preserves messages/DMs/reactions/files
|
||||
"""
|
||||
# Prevent admin/owner account self-deletion
|
||||
if _is_admin(current_user):
|
||||
if current_user.username == OWNER_USERNAME or current_user.id == 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
|
||||
|
||||
await _delete_user_data(current_user, db)
|
||||
|
||||
@@ -2,9 +2,9 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.shared.dependencies import get_current_user, get_db
|
||||
from backend.shared.models import User, DeviceSession
|
||||
from backend.shared.utils import verify_token
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, DeviceSession
|
||||
from utils import verify_token
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
+34
-237
@@ -16,30 +16,26 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.shared.dependencies import get_current_user, get_db
|
||||
from backend.shared.utils import convert_user
|
||||
from backend.shared.constants import OWNER_USERNAME
|
||||
from backend.shared.models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
|
||||
import os
|
||||
import httpx
|
||||
from dependencies import get_current_user, get_db
|
||||
from .account import convert_user
|
||||
from constants import OWNER_USERNAME
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
|
||||
from push_service import push_service
|
||||
from PIL import Image
|
||||
import io
|
||||
import json
|
||||
from pydantic import BaseModel
|
||||
from better_profanity import profanity as _bp
|
||||
from backend.security.audit import log_access, log_dm, log_public_chat, log_security
|
||||
from backend.security.profanity import contains_profanity
|
||||
from backend.security.rate_limit import rate_limit_per_ip
|
||||
from backend.services.messaging.files.websocket.utils import authenticate_user
|
||||
|
||||
from backend.shared.models import FcmToken
|
||||
from security.audit import log_access, log_dm, log_public_chat, log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
from websocket.utils import authenticate_user
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
|
||||
|
||||
FILES_BASE_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "files"
|
||||
FILES_BASE_DIR = Path("data/uploads/files")
|
||||
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
|
||||
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||
|
||||
@@ -54,8 +50,8 @@ _BURST_COUNT_THRESHOLD = 20
|
||||
_SHORT_MESSAGE_LENGTH = 8
|
||||
_SHORT_MESSAGE_REPEAT_LIMIT = 4
|
||||
|
||||
_recent_message_cache: dict[int, deque[tuple[str, str, float, int]]] = defaultdict(deque) # (normalized, content, timestamp, message_id)
|
||||
_message_rate_cache: dict[int, deque[tuple[float, int]]] = defaultdict(deque) # (timestamp, message_id)
|
||||
_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque)
|
||||
_message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
|
||||
_burst_last_logged: dict[int, float] = {}
|
||||
|
||||
|
||||
@@ -66,23 +62,12 @@ def _normalize_for_spam(text: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _monitor_public_message_activity(user: User, content: str, message_id: int, db: Session) -> None:
|
||||
def _monitor_public_message_activity(user: User, content: str, db: Session) -> None:
|
||||
now = time.time()
|
||||
|
||||
def suspend(reason: str, event: str, message_ids_to_delete: list[int] = None, **extra: Any) -> None:
|
||||
def suspend(reason: str, event: str, **extra: Any) -> None:
|
||||
if user.suspended or user.id == 1:
|
||||
return
|
||||
|
||||
# Delete spam messages that triggered the ban
|
||||
if message_ids_to_delete:
|
||||
try:
|
||||
deleted_count = db.query(Message).filter(Message.id.in_(message_ids_to_delete)).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
logger.info(f"Deleted {deleted_count} spam messages for user {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete spam messages: {e}")
|
||||
db.rollback()
|
||||
|
||||
user.suspended = True
|
||||
user.suspension_reason = reason
|
||||
db.commit()
|
||||
@@ -92,7 +77,6 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
reason=reason,
|
||||
deleted_messages=len(message_ids_to_delete) if message_ids_to_delete else 0,
|
||||
**extra,
|
||||
)
|
||||
try:
|
||||
@@ -102,8 +86,8 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
|
||||
|
||||
# Rate tracking for burst detection
|
||||
rate_bucket = _message_rate_cache[user.id]
|
||||
rate_bucket.append((now, message_id))
|
||||
while rate_bucket and now - rate_bucket[0][0] > _BURST_WINDOW_SECONDS:
|
||||
rate_bucket.append(now)
|
||||
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
|
||||
rate_bucket.popleft()
|
||||
|
||||
burst_count = len(rate_bucket)
|
||||
@@ -119,17 +103,12 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
|
||||
window_seconds=_BURST_WINDOW_SECONDS,
|
||||
)
|
||||
_burst_last_logged[user.id] = now
|
||||
|
||||
# Get all message IDs from the burst window
|
||||
burst_message_ids = [msg_id for _, msg_id in rate_bucket]
|
||||
suspend(
|
||||
"Automatic suspension: excessive message rate",
|
||||
"auto_suspension_public_burst",
|
||||
message_ids_to_delete=burst_message_ids,
|
||||
count=burst_count,
|
||||
window_seconds=_BURST_WINDOW_SECONDS,
|
||||
)
|
||||
return
|
||||
|
||||
# Similarity-based spam detection
|
||||
normalized = _normalize_for_spam(content)
|
||||
@@ -137,25 +116,21 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
|
||||
while history and now - history[0][2] > _SPAM_WINDOW_SECONDS:
|
||||
history.popleft()
|
||||
|
||||
prior_same = sum(1 for prev_norm, _, _, _ in history if prev_norm == normalized)
|
||||
prior_same = sum(1 for prev_norm, _, _ in history if prev_norm == normalized)
|
||||
prior_similar = sum(
|
||||
1
|
||||
for prev_norm, _, _, _ in history
|
||||
for prev_norm, _, _ in history
|
||||
if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD
|
||||
)
|
||||
|
||||
history.append((normalized, content, now, message_id))
|
||||
history.append((normalized, content, now))
|
||||
|
||||
total_matches = prior_same + prior_similar + 1
|
||||
|
||||
if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT:
|
||||
# Get message IDs of all matching short messages
|
||||
spam_message_ids = [msg_id for prev_norm, _, _, msg_id in history if prev_norm == normalized]
|
||||
spam_message_ids.append(message_id) # Include current message
|
||||
suspend(
|
||||
"Automatic suspension: repeated short messages",
|
||||
"auto_suspension_public_spam",
|
||||
message_ids_to_delete=spam_message_ids,
|
||||
occurrences=prior_same + 1,
|
||||
window_seconds=_SPAM_WINDOW_SECONDS,
|
||||
match_type="short",
|
||||
@@ -163,20 +138,9 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
|
||||
return
|
||||
|
||||
if total_matches >= _SPAM_MESSAGE_LIMIT:
|
||||
# Get message IDs of all matching similar messages
|
||||
spam_message_ids = []
|
||||
for prev_norm, _, _, msg_id in history:
|
||||
if prev_norm == normalized:
|
||||
spam_message_ids.append(msg_id)
|
||||
elif prev_norm and normalized and prev_norm != normalized:
|
||||
similarity = SequenceMatcher(None, normalized, prev_norm).ratio()
|
||||
if similarity >= _SPAM_SIMILARITY_THRESHOLD:
|
||||
spam_message_ids.append(msg_id)
|
||||
spam_message_ids.append(message_id) # Include current message
|
||||
suspend(
|
||||
"Automatic suspension: repeated similar public messages",
|
||||
"auto_suspension_public_spam",
|
||||
message_ids_to_delete=spam_message_ids,
|
||||
similar_messages=total_matches,
|
||||
window_seconds=_SPAM_WINDOW_SECONDS,
|
||||
match_type="similar",
|
||||
@@ -394,16 +358,7 @@ async def _send_message_internal(
|
||||
|
||||
# Send push notifications for public messages
|
||||
try:
|
||||
push_service_url = os.getenv("PUSH_SERVICE_URL", "http://push_service:8306")
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{push_service_url}/push/send-public-notification",
|
||||
json={
|
||||
"message_id": new_message.id,
|
||||
"exclude_user_id": current_user.id
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
|
||||
|
||||
@@ -416,7 +371,7 @@ async def _send_message_internal(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_monitor_public_message_activity(current_user, raw_content, new_message.id, db)
|
||||
_monitor_public_message_activity(current_user, raw_content, db)
|
||||
|
||||
message_payload = convert_message(new_message)
|
||||
|
||||
@@ -465,97 +420,6 @@ async def send_message(
|
||||
return await _send_message_internal(message_request, current_user, db, files)
|
||||
|
||||
|
||||
class RegisterFcmRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@router.post("/push/register")
|
||||
async def register_fcm_token(request: Request, body: RegisterFcmRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""
|
||||
Register or update an FCM token for the authenticated user.
|
||||
"""
|
||||
token = body.token.strip() if body and body.token else None
|
||||
if not token:
|
||||
raise HTTPException(status_code=400, detail="Missing token")
|
||||
|
||||
try:
|
||||
# If token already exists (from another device), reassign it to this user.
|
||||
token_row = db.query(FcmToken).filter(FcmToken.token == token).first()
|
||||
if token_row:
|
||||
token_row.user_id = current_user.id
|
||||
else:
|
||||
# Create new token record (allow multiple tokens per user)
|
||||
new = FcmToken(user_id=current_user.id, token=token)
|
||||
db.add(new)
|
||||
db.commit()
|
||||
logger.info(f"Registered FCM token for user {current_user.id}: {token}")
|
||||
except Exception as e:
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail="Failed to save token")
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/push/unregister")
|
||||
async def unregister_fcm_token(request: Request, body: RegisterFcmRequest | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""
|
||||
Unregister an FCM token. If `body.token` provided, remove only that token for the user.
|
||||
If no token provided, remove all tokens for the user.
|
||||
"""
|
||||
try:
|
||||
if body and body.token:
|
||||
db.query(FcmToken).filter(FcmToken.user_id == current_user.id, FcmToken.token == body.token.strip()).delete()
|
||||
else:
|
||||
db.query(FcmToken).filter(FcmToken.user_id == current_user.id).delete()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail="Failed to remove token")
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/push/test")
|
||||
async def push_test(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""
|
||||
Send a test push to the current user's registered FCM token (for manual testing).
|
||||
"""
|
||||
try:
|
||||
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == current_user.id).all()
|
||||
if not fcm_rows:
|
||||
raise HTTPException(status_code=404, detail="No FCM token registered for user")
|
||||
|
||||
title = "FromChat test"
|
||||
body = "This is a test push from the server"
|
||||
data = {"type": "test", "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
# Use push_service which uses Admin SDK internally; attempt to send to all tokens
|
||||
failures = []
|
||||
for fcm in fcm_rows:
|
||||
try:
|
||||
push_service._send_fcm_to_token(fcm.token, title, body, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send test push to user {current_user.id} token {fcm.token}: {e}")
|
||||
failures.append(str(e))
|
||||
|
||||
if failures and len(failures) == len(fcm_rows):
|
||||
# All failed
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send push to any token: {failures}")
|
||||
|
||||
return {"status": "success", "sent": len(fcm_rows) - len(failures), "failed": len(failures)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"push_test error: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal error")
|
||||
|
||||
|
||||
@router.get("/get_messages")
|
||||
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
||||
async def get_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
@@ -571,43 +435,6 @@ async def get_messages(request: Request, current_user: User = Depends(get_curren
|
||||
}
|
||||
|
||||
|
||||
class MarkReadRequest(BaseModel):
|
||||
messageIds: list[int]
|
||||
|
||||
|
||||
@router.get("/messages/new")
|
||||
@rate_limit_per_ip("60/minute")
|
||||
async def get_new_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""
|
||||
Return unread public messages (Message.is_read == False).
|
||||
"""
|
||||
new_messages = db.query(Message).filter(Message.is_read == False).order_by(Message.timestamp.asc()).all()
|
||||
messages_data = [convert_message(msg) for msg in new_messages]
|
||||
return {"status": "success", "messages": messages_data}
|
||||
|
||||
|
||||
@router.post("/messages/read")
|
||||
@rate_limit_per_ip("60/minute")
|
||||
async def mark_messages_read(request: Request, read_request: MarkReadRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""
|
||||
Mark specified message IDs as read (set Message.is_read = True).
|
||||
"""
|
||||
if not read_request or not isinstance(read_request.messageIds, list) or len(read_request.messageIds) == 0:
|
||||
return {"status": "success", "updated": 0}
|
||||
|
||||
try:
|
||||
updated_count = db.query(Message).filter(Message.id.in_(read_request.messageIds)).update({Message.is_read: True}, synchronize_session=False)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail="Failed to mark messages as read")
|
||||
|
||||
return {"status": "success", "updated": int(updated_count)}
|
||||
|
||||
|
||||
@router.post("/dm/send")
|
||||
@rate_limit_per_ip("20/minute")
|
||||
async def dm_send(
|
||||
@@ -851,16 +678,15 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge
|
||||
}
|
||||
|
||||
|
||||
async def _edit_message_internal(
|
||||
@router.put("/edit_message/{message_id}")
|
||||
@rate_limit_per_ip("20/minute")
|
||||
async def edit_message(
|
||||
request: Request,
|
||||
message_id: int,
|
||||
edit_request: EditMessageRequest,
|
||||
current_user: User,
|
||||
db: Session
|
||||
) -> dict:
|
||||
"""Internal function to edit a message without requiring a Request object.
|
||||
|
||||
This can be called from both HTTP endpoints and WebSocket handlers.
|
||||
"""
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
message = db.query(Message).filter(Message.id == message_id).first()
|
||||
|
||||
if not message:
|
||||
@@ -909,18 +735,6 @@ async def _edit_message_internal(
|
||||
return {"status": "success", "message": payload}
|
||||
|
||||
|
||||
@router.put("/edit_message/{message_id}")
|
||||
@rate_limit_per_ip("20/minute")
|
||||
async def edit_message(
|
||||
request: Request,
|
||||
message_id: int,
|
||||
edit_request: EditMessageRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return await _edit_message_internal(message_id, edit_request, current_user, db)
|
||||
|
||||
|
||||
@router.delete("/delete_message/{message_id}")
|
||||
async def delete_message(
|
||||
message_id: int,
|
||||
@@ -1190,7 +1004,6 @@ class MessaggingSocketManager:
|
||||
|
||||
# Skip if this exact update was recently added
|
||||
if signature in self.recent_updates[websocket]:
|
||||
logger.warning(f"Update was skipped due to duplicate signature {signature}")
|
||||
return
|
||||
|
||||
# Add to pending updates and track signature
|
||||
@@ -1198,8 +1011,10 @@ class MessaggingSocketManager:
|
||||
self.recent_updates[websocket].add(signature)
|
||||
|
||||
# Limit recent updates cache size (keep last 100 signatures per websocket)
|
||||
if len(self.recent_updates[websocket]) > 1:
|
||||
self.recent_updates[websocket] = set(list(self.recent_updates[websocket])[-1])
|
||||
if len(self.recent_updates[websocket]) > 100:
|
||||
# Remove oldest entries (simple FIFO by converting to list and keeping last 100)
|
||||
# Actually, we'll just clear and rebuild on next flush - simpler approach
|
||||
pass
|
||||
|
||||
async def _flush_updates(self, websocket: WebSocket, db: Session | None = None):
|
||||
"""Flush pending updates for a WebSocket connection"""
|
||||
@@ -1292,7 +1107,7 @@ class MessaggingSocketManager:
|
||||
self.ws_subscriptions[websocket] = set()
|
||||
|
||||
# Import here to avoid circular import
|
||||
from backend.services.messaging.files.websocket.handlers import handler_registry
|
||||
from websocket.handlers import handler_registry
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -1570,22 +1385,4 @@ async def get_file_encrypted(filename: str, current_user: User = Depends(get_cur
|
||||
else:
|
||||
raise HTTPException(500)
|
||||
|
||||
return FileResponse(str(path))
|
||||
|
||||
|
||||
class SendSuspensionRequest(BaseModel):
|
||||
user_id: int
|
||||
reason: str
|
||||
|
||||
|
||||
@router.post("/send-suspension")
|
||||
async def send_suspension_to_user(
|
||||
request: SendSuspensionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Send suspension message to user via WebSocket (called by profile service)"""
|
||||
try:
|
||||
await messagingManager.send_suspension_to_user(request.user_id, request.reason)
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return FileResponse(str(path))
|
||||
@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
from backend.shared.constants import OWNER_USERNAME
|
||||
from backend.shared.dependencies import get_current_user
|
||||
from backend.shared.models import User
|
||||
from backend.security.audit import log_security
|
||||
from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||
from backend.security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user
|
||||
from models import User
|
||||
from security.audit import log_security
|
||||
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||
from security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
||||
|
||||
|
||||
class BlocklistUpdateRequest(BaseModel):
|
||||
|
||||
+10
-20
@@ -9,16 +9,15 @@ import uuid
|
||||
import io
|
||||
from fastapi import Request
|
||||
|
||||
from backend.shared.dependencies import get_db, get_current_user
|
||||
from backend.shared.models import User, UpdateBioRequest, UserProfileResponse
|
||||
from dependencies import get_db, get_current_user
|
||||
from models import User, UpdateBioRequest, UserProfileResponse
|
||||
from pydantic import BaseModel
|
||||
from backend.shared.validation import is_valid_username, is_valid_display_name
|
||||
from backend.shared.similarity import is_user_similar_to_verified
|
||||
import os
|
||||
import httpx
|
||||
from backend.security.audit import log_security
|
||||
from backend.security.profanity import contains_profanity
|
||||
from backend.security.rate_limit import rate_limit_per_ip
|
||||
from validation import is_valid_username, is_valid_display_name
|
||||
from similarity import is_user_similar_to_verified
|
||||
from .messaging import messagingManager
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -37,7 +36,7 @@ class UpdateProfileRequest(BaseModel):
|
||||
description: str | None = None
|
||||
|
||||
# Create uploads directory if it doesn't exist
|
||||
PROFILE_PICTURES_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "pfp"
|
||||
PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
|
||||
|
||||
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
|
||||
|
||||
@@ -480,16 +479,7 @@ async def suspend_user(
|
||||
|
||||
# Send WebSocket suspension message
|
||||
try:
|
||||
messaging_service_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305")
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{messaging_service_url}/messaging/send-suspension",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"reason": request.reason
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
await messagingManager.send_suspension_to_user(user_id, request.reason)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the request
|
||||
pass
|
||||
|
||||
+4
-27
@@ -1,16 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from backend.shared.dependencies import get_current_user, get_db
|
||||
from backend.shared.models import User, PushSubscriptionRequest
|
||||
from backend.services.push.files import push_service
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, PushSubscriptionRequest
|
||||
from push_service import push_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class SendPublicNotificationRequest(BaseModel):
|
||||
message_id: int
|
||||
exclude_user_id: int
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_to_push_notifications(
|
||||
request: PushSubscriptionRequest,
|
||||
@@ -42,28 +37,10 @@ async def unsubscribe_from_push_notifications(
|
||||
"""Unsubscribe user from push notifications"""
|
||||
try:
|
||||
success = await push_service.unsubscribe_user(db=db, user_id=current_user.id)
|
||||
|
||||
|
||||
if success:
|
||||
return {"status": "success", "message": "Push notifications disabled"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/send-public-notification")
|
||||
async def send_public_message_notification(
|
||||
request: SendPublicNotificationRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Send push notification for public message (called by messaging service)"""
|
||||
try:
|
||||
# Get the message from database
|
||||
from backend.shared.models import Message
|
||||
message = db.query(Message).filter(Message.id == request.message_id).first()
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
await push_service.send_public_message_notification(db, message, exclude_user_id=request.exclude_user_id)
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -4,7 +4,7 @@ import hmac
|
||||
import hashlib
|
||||
import time
|
||||
from fastapi import APIRouter, Depends
|
||||
from backend.shared.dependencies import get_current_user
|
||||
from dependencies import get_current_user
|
||||
import traceback
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/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 backend.routes.account import router as account_router
|
||||
from backend.routes.profile import router as profile_router
|
||||
from backend.routes.devices import router as device_router
|
||||
from backend.routes.messaging import router as messaging_router
|
||||
from backend.routes.push import router as push_router
|
||||
from backend.routes.webrtc import router as webrtc_router
|
||||
from backend.routes.moderation import router as moderation_router
|
||||
|
||||
# Import security modules
|
||||
from security.audit import log_access
|
||||
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 typing import Any, Callable, Dict, List
|
||||
|
||||
from backend.logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
||||
from logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
||||
|
||||
|
||||
def _clean_username(username: Any) -> str:
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Iterable, List, Set, Tuple
|
||||
|
||||
from better_profanity import Profanity
|
||||
|
||||
BLOCKLIST_PATH = Path(__file__).resolve().parent.parent / "data" / "profanity" / "blocklist.json"
|
||||
BLOCKLIST_PATH = Path("data/profanity/blocklist.json")
|
||||
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_CUSTOM_RU_TERMS: Set[str] = {
|
||||
@@ -17,7 +17,7 @@ _CUSTOM_RU_TERMS: Set[str] = {
|
||||
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
|
||||
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
|
||||
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
||||
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор",
|
||||
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
|
||||
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
||||
}
|
||||
|
||||
@@ -46,18 +46,6 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||
)
|
||||
|
||||
# Patterns to check in original text (before normalization) to catch visual bypasses
|
||||
# These patterns check for special character combinations that visually form letters
|
||||
_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
||||
# Catch "}{" used to visually form "х" followed by "С0С" or similar patterns
|
||||
# This catches "хуесос" written as "}{¥€С0С" or variations
|
||||
# Matches: }{ + any characters (including special chars) + С/с + 0 + С/с
|
||||
# The pattern allows any characters between to catch special chars like ¥€
|
||||
re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
||||
# Also catch "}{" followed by "уесос" with 0 instead of о
|
||||
re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
||||
)
|
||||
|
||||
# Map for normalizing homoglyphs (similar-looking characters)
|
||||
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
|
||||
# Also includes Greek, full-width, and other Unicode variants
|
||||
@@ -194,8 +182,6 @@ _LEET_MAP = {
|
||||
"н": "н", # Already mapped, but explicit
|
||||
# Special characters
|
||||
"@": "а",
|
||||
# Multi-character visual bypasses (handled separately in preprocessing)
|
||||
# "}{" visually forms "х" - handled in _preprocess_visual_bypasses
|
||||
}
|
||||
|
||||
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
||||
@@ -207,18 +193,6 @@ _SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
|
||||
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
||||
|
||||
|
||||
def _preprocess_visual_bypasses(text: str) -> str:
|
||||
"""
|
||||
Preprocess text to convert multi-character visual bypasses to their intended letters.
|
||||
This handles cases like "}{" visually forming "х".
|
||||
"""
|
||||
result = text
|
||||
# Convert "}{" to "х" (visual bypass for Cyrillic х)
|
||||
# The curly braces visually form the letter х when placed together
|
||||
result = result.replace("}{", "х")
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_char(ch: str) -> str:
|
||||
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
||||
# First try direct mapping (preserves case for non-mapped chars)
|
||||
@@ -283,10 +257,7 @@ def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False)
|
||||
(normalized_text, position_map) where position_map[i] is the original
|
||||
position of the i-th character in normalized_text
|
||||
"""
|
||||
# First preprocess visual bypasses (like "}{" -> "х")
|
||||
text = _preprocess_visual_bypasses(text)
|
||||
|
||||
# Then normalize Unicode (composed vs decomposed)
|
||||
# First normalize Unicode (composed vs decomposed)
|
||||
normalized_unicode = unicodedata.normalize('NFKC', text)
|
||||
|
||||
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
||||
@@ -345,49 +316,45 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -
|
||||
start = pos + 1
|
||||
|
||||
# Also check if profane word appears as a subsequence (allowing extra chars)
|
||||
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
|
||||
# Now applies to ALL words, not just length >= 4, to prevent bypasses
|
||||
word_chars = list(word_lower)
|
||||
text_chars = list(normalized_lower)
|
||||
|
||||
# Stricter span limits based on word length to prevent false positives
|
||||
# Shorter words get much stricter limits
|
||||
if len(word_lower) <= 3:
|
||||
max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй")
|
||||
elif len(word_lower) == 4:
|
||||
max_span_ratio = 1.4 # Strict for 4-char words
|
||||
elif len(word_lower) <= 5:
|
||||
max_span_ratio = 1.5 # Moderate for 5-char words
|
||||
else:
|
||||
max_span_ratio = 1.8 # Slightly more lenient for longer words
|
||||
|
||||
# Try to find the word as a subsequence
|
||||
i = 0 # position in text
|
||||
j = 0 # position in word
|
||||
seq_start = None
|
||||
|
||||
while i < len(text_chars) and j < len(word_chars):
|
||||
if text_chars[i] == word_chars[j]:
|
||||
if seq_start is None:
|
||||
seq_start = i
|
||||
j += 1
|
||||
if j == len(word_chars):
|
||||
# Found the word as subsequence
|
||||
seq_end = i + 1
|
||||
# Check if the span is reasonable (not too long)
|
||||
span_length = seq_end - seq_start
|
||||
max_allowed_span = int(len(word_lower) * max_span_ratio)
|
||||
if span_length <= max_allowed_span:
|
||||
# Only add if it's not already covered by exact match
|
||||
if (seq_start, seq_end) not in spans:
|
||||
spans.append((seq_start, seq_end))
|
||||
# Reset to find next occurrence - continue from after the end of this match
|
||||
next_start = seq_start + 1
|
||||
seq_start = None
|
||||
j = 0
|
||||
i = next_start
|
||||
continue
|
||||
i += 1
|
||||
# This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй"
|
||||
# Only do subsequence matching for words of length 4 or more to avoid false positives
|
||||
# Use stricter span limits for shorter words to prevent false matches in long legitimate words
|
||||
if len(word_lower) >= 4:
|
||||
word_chars = list(word_lower)
|
||||
text_chars = list(normalized_lower)
|
||||
# Stricter ratio for shorter words, more lenient for longer words
|
||||
if len(word_lower) <= 5:
|
||||
max_span_ratio = 1.5 # Very strict for short words
|
||||
else:
|
||||
max_span_ratio = 2.0 # Slightly more lenient for longer words
|
||||
|
||||
# Try to find the word as a subsequence
|
||||
i = 0 # position in text
|
||||
j = 0 # position in word
|
||||
seq_start = None
|
||||
|
||||
while i < len(text_chars) and j < len(word_chars):
|
||||
if text_chars[i] == word_chars[j]:
|
||||
if seq_start is None:
|
||||
seq_start = i
|
||||
j += 1
|
||||
if j == len(word_chars):
|
||||
# Found the word as subsequence
|
||||
seq_end = i + 1
|
||||
# Check if the span is reasonable (not too long)
|
||||
span_length = seq_end - seq_start
|
||||
max_allowed_span = int(len(word_lower) * max_span_ratio)
|
||||
if span_length <= max_allowed_span:
|
||||
# Only add if it's not already covered by exact match
|
||||
if (seq_start, seq_end) not in spans:
|
||||
spans.append((seq_start, seq_end))
|
||||
# Reset to find next occurrence - continue from after the end of this match
|
||||
next_start = seq_start + 1
|
||||
seq_start = None
|
||||
j = 0
|
||||
i = next_start
|
||||
continue
|
||||
i += 1
|
||||
|
||||
return spans
|
||||
|
||||
@@ -615,13 +582,7 @@ def contains_profanity(text: str) -> bool:
|
||||
|
||||
_rebuild_dictionary()
|
||||
|
||||
# Check original text patterns first (before normalization) to catch visual bypasses
|
||||
# like "}{" used to form "х"
|
||||
for pattern in _ORIGINAL_TEXT_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return True
|
||||
|
||||
# Check phrase patterns
|
||||
# Check phrase patterns first
|
||||
if _check_phrase_patterns(text):
|
||||
return True
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import Request
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from backend.shared.utils import get_client_ip
|
||||
from utils import get_client_ip
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
@@ -1,9 +0,0 @@
|
||||
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)))
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
@@ -1,9 +0,0 @@
|
||||
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)))
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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
|
||||
@@ -1,6 +0,0 @@
|
||||
# Gateway service - runs the main gateway app from backend/app.py
|
||||
|
||||
if __name__ == "__main__":
|
||||
from backend.app import app
|
||||
import os, uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8300)))
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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
|
||||
@@ -1,9 +0,0 @@
|
||||
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)))
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
@@ -1,9 +0,0 @@
|
||||
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)))
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
@@ -1,9 +0,0 @@
|
||||
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)))
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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
|
||||
@@ -1,243 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from pywebpush import webpush, WebPushException
|
||||
from backend.shared.models import PushSubscription, User, Message, DMEnvelope, FcmToken
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials as firebase_credentials
|
||||
from firebase_admin import messaging as firebase_messaging
|
||||
import base64
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
class PushNotificationService:
|
||||
def __init__(self):
|
||||
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
|
||||
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
|
||||
# Firebase Admin initialization (modern API). Only FIREBASE_CERT env is supported.
|
||||
self.firebase_initialized = False
|
||||
try:
|
||||
firebase_cert = os.getenv("FIREBASE_CERT")
|
||||
if not firebase_cert:
|
||||
raise RuntimeError("FIREBASE_CERT env variable is required for Firebase Admin SDK initialization")
|
||||
|
||||
# Support raw JSON or base64-encoded JSON in FIREBASE_CERT
|
||||
decoded = base64.b64decode(firebase_cert).decode("utf-8")
|
||||
sa_dict = json.loads(decoded)
|
||||
|
||||
cred = firebase_credentials.Certificate(sa_dict)
|
||||
firebase_admin.initialize_app(cred)
|
||||
self.firebase_initialized = True
|
||||
logger.info("Firebase Admin SDK initialized for push sending (FIREBASE_CERT)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Firebase Admin SDK from FIREBASE_CERT: {e}")
|
||||
raise
|
||||
|
||||
if (not self.vapid_public_key) or (not self.vapid_private_key):
|
||||
raise ValueError("VAPID public or private key is None")
|
||||
|
||||
self.vapid_claims = {
|
||||
"sub": "mailto:support@fromchat.ru",
|
||||
"aud": "https://fcm.googleapis.com"
|
||||
}
|
||||
|
||||
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
|
||||
"""Subscribe a user to push notifications"""
|
||||
try:
|
||||
# Check if user already has a subscription
|
||||
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
existing_sub.endpoint = endpoint
|
||||
existing_sub.p256dh_key = p256dh_key
|
||||
existing_sub.auth_key = auth_key
|
||||
else:
|
||||
# Create new subscription
|
||||
new_sub = PushSubscription(
|
||||
user_id=user_id,
|
||||
endpoint=endpoint,
|
||||
p256dh_key=p256dh_key,
|
||||
auth_key=auth_key
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Push subscription saved for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
|
||||
"""Send push notification for a new public chat message"""
|
||||
try:
|
||||
# Get all users except the sender
|
||||
users = db.query(User).filter(User.id != message.user_id)
|
||||
if exclude_user_id:
|
||||
users = users.filter(User.id != exclude_user_id)
|
||||
|
||||
for user in users:
|
||||
# Check if user has push subscription before trying to send
|
||||
# Try all FCM tokens first (Android). If none or all fail, fall back to web push subscription.
|
||||
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == user.id).all()
|
||||
payload_data = {
|
||||
"type": "public_message",
|
||||
"message_id": message.id,
|
||||
"sender_id": message.user_id,
|
||||
"sender_username": message.author.username
|
||||
}
|
||||
title = f"{message.author.username}"
|
||||
body = message.content[:100] + ("..." if len(message.content) > 100 else "")
|
||||
|
||||
if fcm_rows and self.firebase_initialized:
|
||||
for fcm in fcm_rows:
|
||||
try:
|
||||
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send FCM to user {user.id} token {fcm.token}: {e}")
|
||||
# Check if this is a permanent failure and clean up the token
|
||||
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
||||
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
||||
if subscription:
|
||||
await self._send_notification_to_user(
|
||||
db, user.id, title, body, message.author.profile_picture, payload_data
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send public message notifications: {e}")
|
||||
|
||||
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
||||
"""Send push notification for a new DM"""
|
||||
try:
|
||||
title = f"{sender.username}"
|
||||
body = "New direct message"
|
||||
payload_data = {
|
||||
"type": "dm",
|
||||
"dm_id": dm_envelope.id,
|
||||
"sender_id": sender.id,
|
||||
"sender_username": sender.username
|
||||
}
|
||||
|
||||
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == dm_envelope.recipient_id).all()
|
||||
if fcm_rows and self.firebase_initialized:
|
||||
for fcm in fcm_rows:
|
||||
try:
|
||||
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send FCM to user {dm_envelope.recipient_id} token {fcm.token}: {e}")
|
||||
# Check if this is a permanent failure and clean up the token
|
||||
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
||||
|
||||
await self._send_notification_to_user(
|
||||
db, dm_envelope.recipient_id, title, body, sender.profile_picture, payload_data
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send DM notification: {e}")
|
||||
|
||||
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
|
||||
"""Send a push notification to a specific user"""
|
||||
try:
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"icon": icon or "about:blank",
|
||||
"tag": f"message_{user_id}",
|
||||
"data": data
|
||||
}
|
||||
|
||||
subscription_info = {
|
||||
"endpoint": subscription.endpoint,
|
||||
"keys": {
|
||||
"p256dh": subscription.p256dh_key,
|
||||
"auth": subscription.auth_key
|
||||
}
|
||||
}
|
||||
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=self.vapid_private_key,
|
||||
vapid_claims=self.vapid_claims
|
||||
)
|
||||
|
||||
except WebPushException as e:
|
||||
logger.error(f"WebPush error for user {user_id}: {e}")
|
||||
# If the subscription is invalid, remove it
|
||||
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
||||
|
||||
def _send_fcm_to_token(self, token: str, title: str, body: str, data: dict):
|
||||
"""Send an FCM data-only push to a single device token using Firebase Admin SDK.
|
||||
Notification display is handled by the app, not FCM."""
|
||||
if not self.firebase_initialized:
|
||||
raise RuntimeError("Firebase Admin SDK not initialized (FIREBASE_CERT required)")
|
||||
|
||||
try:
|
||||
# Send only data payload - let the app handle notification display
|
||||
# This prevents FCM from auto-showing notifications
|
||||
msg = firebase_messaging.Message(
|
||||
token=token,
|
||||
data={
|
||||
"title": title,
|
||||
"body": body,
|
||||
**{k: str(v) for k, v in (data or {}).items()}
|
||||
},
|
||||
android=firebase_messaging.AndroidConfig(priority="high"),
|
||||
apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"})
|
||||
)
|
||||
resp = firebase_messaging.send(msg)
|
||||
return resp
|
||||
except Exception as e:
|
||||
logger.error(f"Firebase Admin send failed for token {token}: {e}")
|
||||
raise
|
||||
|
||||
def _cleanup_failed_fcm_token(self, db: Session, fcm_token_entry, error_message: str):
|
||||
"""Clean up FCM tokens that have permanent failures"""
|
||||
try:
|
||||
# Check for permanent failure indicators in the error message
|
||||
permanent_errors = [
|
||||
"unregistered", "invalidregistration", "notregistered",
|
||||
"sender_id_mismatch", "invalid_argument"
|
||||
]
|
||||
|
||||
error_lower = error_message.lower()
|
||||
is_permanent = any(permanent_error in error_lower for permanent_error in permanent_errors)
|
||||
|
||||
if is_permanent:
|
||||
logger.info(f"Removing permanently failed FCM token for user {fcm_token_entry.user_id}: {fcm_token_entry.token}")
|
||||
db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete()
|
||||
db.commit()
|
||||
else:
|
||||
logger.debug(f"Temporary FCM failure for token {fcm_token_entry.token}, keeping token: {error_message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup FCM token {fcm_token_entry.token}: {e}")
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
||||
"""Unsubscribe a user from push notifications"""
|
||||
try:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
logger.info(f"Push subscription removed for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
# Global instance
|
||||
push_service = PushNotificationService()
|
||||
@@ -1,9 +0,0 @@
|
||||
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)))
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
@@ -1,9 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
from backend.routes.webrtc import router as webrtc_router
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = FastAPI(title="WebRTC Service")
|
||||
app.include_router(webrtc_router, prefix="/webrtc")
|
||||
|
||||
import os, uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
||||
@@ -1 +0,0 @@
|
||||
# Shared modules package
|
||||
@@ -1,50 +0,0 @@
|
||||
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"
|
||||
@@ -1,62 +0,0 @@
|
||||
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()
|
||||
@@ -1,122 +0,0 @@
|
||||
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
|
||||
@@ -1,423 +0,0 @@
|
||||
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"
|
||||
__table_args__ = {"schema": "account_schema"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
username = Column(String(50), 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)
|
||||
suspended = Column(Boolean, default=False)
|
||||
suspension_reason = Column(Text, nullable=True)
|
||||
deleted = 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"
|
||||
__table_args__ = {"schema": "messaging_schema"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||
content = Column(Text, nullable=False)
|
||||
content_type = Column(String(50), default="text")
|
||||
encrypted_content = Column(Text, nullable=True)
|
||||
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("messaging_schema.messages.id"), nullable=True)
|
||||
thread_id = Column(BigInteger, ForeignKey("messaging_schema.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")
|
||||
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan")
|
||||
|
||||
class MessageRecipient(Base):
|
||||
__tablename__ = "message_recipients"
|
||||
__table_args__ = {"schema": "messaging_schema"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||
read_at = Column(DateTime, nullable=True)
|
||||
delivered_at = Column(DateTime, nullable=True)
|
||||
encrypted_key = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
message = relationship("Message", back_populates="recipients")
|
||||
recipient = relationship("User", back_populates="message_recipients")
|
||||
|
||||
class MessageReaction(Base):
|
||||
__tablename__ = "message_reactions"
|
||||
__table_args__ = {"schema": "messaging_schema"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||
reaction = Column(String(50), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
message = relationship("Message", back_populates="reactions")
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "devices"
|
||||
__table_args__ = {"schema": "device_schema"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||
device_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
device_name = Column(String(255), nullable=True)
|
||||
device_type = Column(String(50), nullable=True)
|
||||
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"
|
||||
__table_args__ = {"schema": "push_schema"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||
device_id = Column(BigInteger, ForeignKey("device_schema.devices.id"), nullable=True, index=True)
|
||||
endpoint = Column(String(500), nullable=False)
|
||||
p256dh = Column(String(255), nullable=False)
|
||||
auth = Column(String(255), nullable=False)
|
||||
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"
|
||||
__table_args__ = {"schema": "webrtc_schema"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
session_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
initiator_id = Column(BigInteger, ForeignKey("account_schema.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"
|
||||
__table_args__ = {"schema": "moderation_schema"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
moderator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||
target_user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=True)
|
||||
target_message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
||||
action_type = Column(String(50), nullable=False)
|
||||
reason = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class MessageFile(Base):
|
||||
__tablename__ = "message_file"
|
||||
__table_args__ = {"schema": "messaging_schema"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||
path = Column(Text, nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("Message", back_populates="files")
|
||||
|
||||
|
||||
class CryptoPublicKey(Base):
|
||||
__tablename__ = "crypto_public_key"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.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(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
|
||||
blob_json = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class DMEnvelope(Base):
|
||||
__tablename__ = "dm_envelope"
|
||||
__table_args__ = {"schema": "messaging_schema"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||
iv_b64 = Column(Text, nullable=False)
|
||||
ciphertext_b64 = Column(Text, nullable=False)
|
||||
salt_b64 = Column(Text, nullable=False)
|
||||
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"
|
||||
__table_args__ = {"schema": "messaging_schema"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
|
||||
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
path = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("DMEnvelope", back_populates="files")
|
||||
|
||||
|
||||
class FcmToken(Base):
|
||||
__tablename__ = "fcm_token"
|
||||
__table_args__ = {"schema": "push_schema"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||
token = Column(Text, nullable=False, unique=True)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reaction"
|
||||
__table_args__ = {"schema": "messaging_schema"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||
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"
|
||||
__table_args__ = {"schema": "messaging_schema"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
dm_envelope_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# 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"
|
||||
__table_args__ = {"schema": "device_schema"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||
|
||||
# Raw User-Agent for reference/debugging
|
||||
raw_user_agent = Column(Text, nullable=True)
|
||||
|
||||
# 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"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
||||
sequence = Column(Integer, nullable=False, index=True)
|
||||
updates = Column(Text, nullable=False) # JSON array of updates
|
||||
timestamp = Column(DateTime, default=datetime.now, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "sequence", name="uq_user_sequence"),
|
||||
)
|
||||
@@ -1,234 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
import re
|
||||
|
||||
|
||||
def is_valid_username(username: str) -> bool:
|
||||
if len(username) < 3 or len(username) > 20:
|
||||
return False
|
||||
# Only allow English letters, numbers, dashes and underscores
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_display_name(display_name: str) -> bool:
|
||||
if len(display_name) < 1 or len(display_name) > 64:
|
||||
return False
|
||||
# Check if not blank (only whitespace)
|
||||
if not display_name.strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_password(password: str) -> bool:
|
||||
if len(password) < 5 or len(password) > 50:
|
||||
return False
|
||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
|
||||
return False
|
||||
return True
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
display_name: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
@validator('username')
|
||||
def username_alphanumeric(cls, v):
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
|
||||
raise ValueError('Username must be alphanumeric with underscores or hyphens')
|
||||
return v
|
||||
|
||||
@validator('password')
|
||||
def password_strength(cls, v):
|
||||
if not re.search(r'[A-Z]', v):
|
||||
raise ValueError('Password must contain at least one uppercase letter')
|
||||
if not re.search(r'[a-z]', v):
|
||||
raise ValueError('Password must contain at least one lowercase letter')
|
||||
if not re.search(r'\d', v):
|
||||
raise ValueError('Password must contain at least one digit')
|
||||
return v
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username_or_email: str = Field(min_length=1, max_length=100)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
display_name: Optional[str] = Field(None, max_length=100)
|
||||
bio: Optional[str] = Field(None, max_length=500)
|
||||
avatar_url: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
@validator('avatar_url')
|
||||
def validate_avatar_url(cls, v):
|
||||
if v and not v.startswith(('http://', 'https://')):
|
||||
raise ValueError('Avatar URL must be a valid HTTP/HTTPS URL')
|
||||
return v
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=10000)
|
||||
content_type: str = Field(default="text", pattern=r'^(text|image|video|audio|file)$')
|
||||
reply_to_id: Optional[int] = None
|
||||
recipient_ids: list[int] = Field(min_items=1, max_items=100)
|
||||
|
||||
class MessageUpdate(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=10000)
|
||||
|
||||
class DeviceRegister(BaseModel):
|
||||
device_id: str = Field(min_length=1, max_length=255)
|
||||
device_name: Optional[str] = Field(None, max_length=255)
|
||||
device_type: Optional[str] = Field(None, max_length=50)
|
||||
public_key: Optional[str] = Field(None, max_length=10000)
|
||||
|
||||
class PushSubscriptionCreate(BaseModel):
|
||||
endpoint: str = Field(max_length=500)
|
||||
p256dh: str = Field(max_length=255)
|
||||
auth: str = Field(max_length=255)
|
||||
device_id: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
class WebRTCOffer(BaseModel):
|
||||
offer: dict
|
||||
participant_ids: list[int] = Field(min_items=1, max_items=10)
|
||||
|
||||
class WebRTCAnswer(BaseModel):
|
||||
answer: dict
|
||||
session_id: str = Field(max_length=255)
|
||||
|
||||
class WebRTCIceCandidate(BaseModel):
|
||||
candidate: dict
|
||||
session_id: str = Field(max_length=255)
|
||||
|
||||
class ModerationActionCreate(BaseModel):
|
||||
target_user_id: Optional[int] = None
|
||||
target_message_id: Optional[int] = None
|
||||
action_type: str = Field(pattern=r'^(ban|mute|delete|warn)$')
|
||||
reason: Optional[str] = Field(None, max_length=1000)
|
||||
duration_hours: Optional[int] = Field(None, gt=0, le=8760) # Max 1 year
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
token: str = Field(min_length=64, max_length=64)
|
||||
new_password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
@validator('new_password')
|
||||
def password_strength(cls, v):
|
||||
if not re.search(r'[A-Z]', v):
|
||||
raise ValueError('Password must contain at least one uppercase letter')
|
||||
if not re.search(r'[a-z]', v):
|
||||
raise ValueError('Password must contain at least one lowercase letter')
|
||||
if not re.search(r'\d', v):
|
||||
raise ValueError('Password must contain at least one digit')
|
||||
return v
|
||||
|
||||
class TwoFactorSetup(BaseModel):
|
||||
code: str = Field(pattern=r'^\d{6}$')
|
||||
|
||||
class TwoFactorVerify(BaseModel):
|
||||
code: str = Field(pattern=r'^\d{6}$')
|
||||
|
||||
class EmailVerification(BaseModel):
|
||||
token: str = Field(min_length=64, max_length=64)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
from .registry import WebSocketHandlerRegistry
|
||||
from websocket.registry import WebSocketHandlerRegistry
|
||||
|
||||
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
|
||||
# Import them directly from websocket.handlers when needed
|
||||
+10
-8
@@ -3,21 +3,20 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from fastapi import HTTPException, WebSocket, Request
|
||||
from fastapi import HTTPException, WebSocket
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.services.messaging.files.websocket.registry import WebSocketHandlerRegistry
|
||||
from backend.routes.messaging import (
|
||||
from websocket.registry import WebSocketHandlerRegistry
|
||||
from routes.messaging import (
|
||||
MessaggingSocketManager,
|
||||
_send_message_internal,
|
||||
_edit_message_internal,
|
||||
get_messages,
|
||||
edit_message,
|
||||
delete_message,
|
||||
add_reaction,
|
||||
add_dm_reaction,
|
||||
)
|
||||
from backend.shared.models import (
|
||||
from models import (
|
||||
User,
|
||||
SendMessageRequest,
|
||||
EditMessageRequest,
|
||||
@@ -26,7 +25,7 @@ from backend.shared.models import (
|
||||
DMReactionRequest,
|
||||
UpdateLog,
|
||||
)
|
||||
from backend.security.audit import log_access, log_dm
|
||||
from security.audit import log_access, log_dm
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
@@ -212,11 +211,14 @@ async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Ses
|
||||
@websocket_handler("editMessage", authRequired=True)
|
||||
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Edit a public chat message."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
message_id = data["message_id"]
|
||||
edit_request: EditMessageRequest = EditMessageRequest.model_validate(data)
|
||||
request: EditMessageRequest = EditMessageRequest.model_validate(data)
|
||||
|
||||
response = await _edit_message_internal(message_id, edit_request, user, db)
|
||||
# Create a dummy request object for the HTTP endpoint function
|
||||
dummy_request = SimpleNamespace()
|
||||
response = await edit_message(dummy_request, message_id, request, user, db)
|
||||
await manager.broadcast({
|
||||
"type": "messageEdited",
|
||||
"data": response["message"]
|
||||
@@ -2,8 +2,8 @@ from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from types import SimpleNamespace
|
||||
from backend.shared.dependencies import get_current_user
|
||||
from backend.shared.models import User
|
||||
from dependencies import get_current_user
|
||||
from models import User
|
||||
|
||||
|
||||
def extract_token_from_data(data: dict) -> str | None:
|
||||
@@ -1,88 +0,0 @@
|
||||
fromchat.ru {
|
||||
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 {
|
||||
lb_policy first
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||
X-Frame-Options "DENY" # Prevent clickjacking
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||
}
|
||||
|
||||
rate_limit {
|
||||
zone global {
|
||||
key {remote_ip}
|
||||
window 1m
|
||||
burst 20
|
||||
events 500
|
||||
}
|
||||
}
|
||||
|
||||
handle_errors {
|
||||
@errors {
|
||||
expression {err.status_code} >= 400
|
||||
}
|
||||
|
||||
handle @errors {
|
||||
rewrite * /{err.status_code}
|
||||
reverse_proxy https://http.cat {
|
||||
header_up Host {upstream_hostport}
|
||||
replace_status {err.status_code}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beta.fromchat.ru {
|
||||
reverse_proxy 95.165.0.162:8301 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||
X-Frame-Options "DENY" # Prevent clickjacking
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||
}
|
||||
|
||||
rate_limit {
|
||||
zone global {
|
||||
key {remote_ip}
|
||||
window 1m
|
||||
burst 20
|
||||
events 1000
|
||||
}
|
||||
}
|
||||
|
||||
handle_errors {
|
||||
@errors {
|
||||
expression {err.status_code} >= 400
|
||||
}
|
||||
|
||||
handle @errors {
|
||||
rewrite * /{err.status_code}
|
||||
reverse_proxy https://http.cat {
|
||||
header_up Host {upstream_hostport}
|
||||
replace_status {err.status_code}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api.getgadgets.toolbox-io.ru {
|
||||
reverse_proxy 95.165.0.162:8400
|
||||
}
|
||||
|
||||
getgadgets.toolbox-io.ru {
|
||||
reverse_proxy 95.165.0.162:8401
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
#
|
||||
# Custom Caddy built with:
|
||||
# - Rate limit plugin
|
||||
#
|
||||
|
||||
FROM caddy:2-builder AS builder
|
||||
RUN xcaddy build \
|
||||
--with github.com/mholt/caddy-ratelimit
|
||||
|
||||
FROM caddy:2
|
||||
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
@@ -1,70 +0,0 @@
|
||||
-- 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
|
||||
-- All services use the same password
|
||||
CREATE ROLE account_service_user LOGIN PASSWORD 'development';
|
||||
CREATE ROLE profile_service_user LOGIN PASSWORD 'development';
|
||||
CREATE ROLE device_service_user LOGIN PASSWORD 'development';
|
||||
CREATE ROLE messaging_service_user LOGIN PASSWORD 'development';
|
||||
CREATE ROLE push_service_user LOGIN PASSWORD 'development';
|
||||
CREATE ROLE webrtc_service_user LOGIN PASSWORD 'development';
|
||||
CREATE ROLE moderation_service_user LOGIN PASSWORD 'development';
|
||||
CREATE ROLE gateway_user LOGIN PASSWORD 'development';
|
||||
|
||||
-- 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;
|
||||
@@ -1,158 +0,0 @@
|
||||
-- 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,
|
||||
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,
|
||||
suspended BOOLEAN DEFAULT FALSE,
|
||||
suspension_reason TEXT,
|
||||
deleted 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_schema.device_session (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
||||
raw_user_agent TEXT,
|
||||
device_name VARCHAR(128),
|
||||
device_type VARCHAR(32),
|
||||
os_name VARCHAR(64),
|
||||
os_version VARCHAR(64),
|
||||
browser_name VARCHAR(64),
|
||||
browser_version VARCHAR(64),
|
||||
brand VARCHAR(64),
|
||||
model VARCHAR(64),
|
||||
session_id VARCHAR(64) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 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
|
||||
);
|
||||
|
||||
-- Grant permissions on sequences (after all tables are created)
|
||||
GRANT USAGE ON SEQUENCE account_schema.users_id_seq TO account_service_user;
|
||||
GRANT USAGE ON SEQUENCE device_schema.devices_id_seq TO device_service_user;
|
||||
GRANT USAGE ON SEQUENCE device_schema.device_session_id_seq TO account_service_user;
|
||||
GRANT USAGE ON SEQUENCE messaging_schema.messages_id_seq TO messaging_service_user;
|
||||
GRANT USAGE ON SEQUENCE messaging_schema.message_recipients_id_seq TO messaging_service_user;
|
||||
GRANT USAGE ON SEQUENCE messaging_schema.message_reactions_id_seq TO messaging_service_user;
|
||||
GRANT USAGE ON SEQUENCE push_schema.push_subscriptions_id_seq TO push_service_user;
|
||||
GRANT USAGE ON SEQUENCE webrtc_schema.webrtc_sessions_id_seq TO webrtc_service_user;
|
||||
GRANT USAGE ON SEQUENCE moderation_schema.moderation_actions_id_seq TO moderation_service_user;
|
||||
+31
-351
@@ -1,368 +1,48 @@
|
||||
services:
|
||||
# Database service
|
||||
database:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: fromchat
|
||||
POSTGRES_USER: fromchat_admin
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- database:/var/lib/postgresql/data
|
||||
- ./db-init:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- fromchat_internal
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fromchat_admin -d fromchat"]
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Migration runner - runs once before other services
|
||||
migration_runner:
|
||||
build:
|
||||
backend:
|
||||
build:
|
||||
dockerfile: deployment/Dockerfile.backend
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.multi
|
||||
target: migration_runner
|
||||
environment:
|
||||
DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD}@database:5432/fromchat
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
depends_on:
|
||||
database:
|
||||
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
|
||||
ports: ["8300:8300"]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD}@database:5432/fromchat
|
||||
PORT: 8300
|
||||
ACCOUNT_SERVICE_URL: http://account_service:8302
|
||||
PROFILE_SERVICE_URL: http://profile_service:8303
|
||||
DEVICE_SERVICE_URL: http://device_service:8304
|
||||
MESSAGING_SERVICE_URL: http://messaging_service:8305
|
||||
PUSH_SERVICE_URL: http://push_service:8306
|
||||
WEBRTC_SERVICE_URL: http://webrtc_service:8307
|
||||
MODERATION_SERVICE_URL: http://moderation_service:8308
|
||||
depends_on:
|
||||
migration_runner:
|
||||
condition: service_completed_successfully
|
||||
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}@database:5432/fromchat
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
PORT: 8302
|
||||
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}@database:5432/fromchat
|
||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||
MESSAGING_SERVICE_URL: http://messaging_service:8305
|
||||
PORT: 8303
|
||||
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}@database:5432/fromchat
|
||||
PORT: 8304
|
||||
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
|
||||
ports:
|
||||
- "8305:8305"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD}@database:5432/fromchat
|
||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||
PUSH_SERVICE_URL: http://push_service:8306
|
||||
PORT: 8305
|
||||
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}@database:5432/fromchat
|
||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||
PORT: 8306
|
||||
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}@database:5432/fromchat
|
||||
PORT: 8307
|
||||
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}@database:5432/fromchat
|
||||
PORT: 8308
|
||||
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:
|
||||
build:
|
||||
context: ./caddy
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- certs:/root/site/certs
|
||||
networks:
|
||||
- fromchat_external
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- XDG_DATA_HOME=/root/site/certs
|
||||
- XDG_CONFIG_HOME=/root/site/certs
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- gateway
|
||||
profiles: ["prod"]
|
||||
- data:/app/data
|
||||
- logs:/app/logs
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- action: sync+restart
|
||||
path: ../backend
|
||||
target: /app
|
||||
- action: rebuild
|
||||
path: ../backend/requirements.txt
|
||||
|
||||
# Frontend service - serves the React app
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
build:
|
||||
dockerfile: deployment/frontend/Dockerfile
|
||||
context: ..
|
||||
environment:
|
||||
PORT: 8301
|
||||
BACKEND_HOST: http://backend:8300
|
||||
ports:
|
||||
- "8301:8301"
|
||||
environment:
|
||||
- PORT=8301
|
||||
- BACKEND_HOST=http://gateway:8300
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- fromchat_external
|
||||
- fromchat_internal
|
||||
depends_on:
|
||||
- backend
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ../frontend
|
||||
- action: sync+restart
|
||||
path: server.js
|
||||
target: /server/server.js
|
||||
- action: rebuild
|
||||
path: package.json
|
||||
|
||||
volumes:
|
||||
database:
|
||||
name: fromchat-database
|
||||
certs:
|
||||
name: fromchat-certs
|
||||
logs:
|
||||
name: fromchat-logs
|
||||
data:
|
||||
name: fromchat-data
|
||||
|
||||
networks:
|
||||
fromchat_internal:
|
||||
driver: bridge
|
||||
internal: true
|
||||
fromchat_external:
|
||||
driver: bridge
|
||||
logs:
|
||||
name: fromchat-logs
|
||||
@@ -3,13 +3,11 @@ FROM node:24 AS frontend
|
||||
|
||||
# 1.1. Install npm dependencies
|
||||
WORKDIR /app
|
||||
# Copy package.json and workspace package directory first (needed for workspace resolution)
|
||||
COPY package.json .
|
||||
COPY frontend/packages/ frontend/packages/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 1.2. Copy remaining frontend code and build
|
||||
# 1.2. Build
|
||||
COPY frontend frontend
|
||||
RUN npm run frontend:build
|
||||
|
||||
|
||||
@@ -1,44 +1,28 @@
|
||||
import express from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT) || 8301;
|
||||
const port = process.env.PORT || 3000;
|
||||
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
||||
const filePath = process.env.STATIC_FILE_PATH || ".";
|
||||
|
||||
// Direct WebSocket proxy for chat - bypass gateway (must come before general API proxy)
|
||||
app.use('/api/chat/ws', createProxyMiddleware({
|
||||
target: 'http://messaging_service:8305',
|
||||
// API proxy middleware
|
||||
app.use('/api', createProxyMiddleware({
|
||||
target: backendHost,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api/chat/ws': '/messaging/chat/ws' },
|
||||
pathRewrite: { '^/api': '' },
|
||||
ws: true
|
||||
}));
|
||||
|
||||
// API proxy middleware (exclude WebSocket paths)
|
||||
app.use('/api', (req, res, next) => {
|
||||
// Skip WebSocket upgrade requests - let them be handled by specific proxies
|
||||
if (req.headers.upgrade === 'websocket') {
|
||||
return next();
|
||||
}
|
||||
createProxyMiddleware({
|
||||
target: backendHost,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
ws: true
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(resolve(filePath)));
|
||||
|
||||
// SPA routing - catch all handler for client-side routing
|
||||
app.use((_req: Request, res: Response) => {
|
||||
app.use((_req, res) => {
|
||||
res.sendFile(resolve(filePath, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`Backend host: ${backendHost}`);
|
||||
console.log(`Server launched on http://0.0.0.0:${port}`);
|
||||
app.listen(port, () => {
|
||||
console.log(`Server launched on http://localhost:${port}`);
|
||||
});
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# 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/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
||||
COPY backend/security /app/backend/security/
|
||||
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
|
||||
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
|
||||
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
|
||||
ENV SERVICE_NAME=profile
|
||||
|
||||
# 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/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
||||
COPY backend/security /app/backend/security/
|
||||
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
|
||||
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
|
||||
ENV SERVICE_NAME=messaging
|
||||
|
||||
# Push service - minimal files only
|
||||
FROM base AS push_service
|
||||
COPY backend/routes/push.py /app/backend/routes/push.py
|
||||
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
||||
COPY backend/services/push/main.py /app/backend/services/push/main.py
|
||||
ENV SERVICE_NAME=push
|
||||
|
||||
# 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/shared/similarity.py /app/backend/shared/similarity.py
|
||||
COPY backend/logging_config.py /app/backend/logging_config.py
|
||||
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
|
||||
ENV SERVICE_NAME=moderation
|
||||
|
||||
# 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/logging_config.py /app/backend/logging_config.py
|
||||
COPY backend/security /app/backend/security/
|
||||
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
|
||||
ENV SERVICE_NAME=gateway
|
||||
|
||||
# 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
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Entrypoint script for FromChat microservices
|
||||
|
||||
# Run the service module
|
||||
exec python -m backend.services.${SERVICE_NAME}.main
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
src/
|
||||
tsconfig.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
# Publishing FromChat Protocol
|
||||
|
||||
This guide explains how to publish the `@fromchat/protocol` package to npm or GitHub Packages.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **npm account**: Create one at [npmjs.com](https://www.npmjs.com/signup)
|
||||
2. **GitHub account**: For GitHub Packages
|
||||
3. **Node.js**: Version 18 or higher
|
||||
|
||||
## Publishing to npm
|
||||
|
||||
### Important: Scoped Package Setup
|
||||
|
||||
The package uses the `@fromchat` scope. You have two options:
|
||||
|
||||
**Option A: Create an npm organization (Recommended)**
|
||||
1. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create)
|
||||
2. Create an organization named `fromchat`
|
||||
3. Add yourself as a member
|
||||
4. Then proceed with publishing below
|
||||
|
||||
**Option B: Use unscoped package name**
|
||||
If you prefer not to create an organization, change the package name in `package.json`:
|
||||
```json
|
||||
{
|
||||
"name": "fromchat-protocol" // Remove the @fromchat/ scope
|
||||
}
|
||||
```
|
||||
Then update all imports in your codebase from `@fromchat/protocol` to `fromchat-protocol`.
|
||||
|
||||
### 1. Build the package
|
||||
|
||||
```bash
|
||||
cd frontend/packages/fromchat-protocol
|
||||
npm run build
|
||||
```
|
||||
|
||||
This compiles TypeScript to JavaScript in the `dist/` directory.
|
||||
|
||||
### 2. Login to npm
|
||||
|
||||
```bash
|
||||
npm login
|
||||
```
|
||||
|
||||
Enter your npm username, password, and email.
|
||||
|
||||
### 3. Publish
|
||||
|
||||
**If using scoped package (`@fromchat/protocol`):**
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
**If using unscoped package (`fromchat-protocol`):**
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
The `--access public` flag is required for scoped packages (packages starting with `@`).
|
||||
|
||||
### 4. Verify
|
||||
|
||||
Check your package at: `https://www.npmjs.com/package/@fromchat/protocol`
|
||||
|
||||
### 5. Update version for future releases
|
||||
|
||||
```bash
|
||||
# Patch version (1.0.0 -> 1.0.1)
|
||||
npm version patch
|
||||
|
||||
# Minor version (1.0.0 -> 1.1.0)
|
||||
npm version minor
|
||||
|
||||
# Major version (1.0.0 -> 2.0.0)
|
||||
npm version major
|
||||
|
||||
# Then publish
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
## Publishing to GitHub Packages
|
||||
|
||||
### 1. Create a GitHub Personal Access Token
|
||||
|
||||
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
|
||||
2. Generate a new token with `write:packages` and `read:packages` permissions
|
||||
3. Save the token securely
|
||||
|
||||
### 2. Configure npm to use GitHub Packages
|
||||
|
||||
Create or edit `~/.npmrc`:
|
||||
|
||||
```
|
||||
@fromchat:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
|
||||
```
|
||||
|
||||
Or add to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update package.json
|
||||
|
||||
Update the repository URL to match your GitHub repository:
|
||||
|
||||
```json
|
||||
{
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/YOUR_USERNAME/YOUR_REPO.git",
|
||||
"directory": "frontend/packages/fromchat-protocol"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Build and publish
|
||||
|
||||
```bash
|
||||
cd frontend/packages/fromchat-protocol
|
||||
npm run build
|
||||
npm publish
|
||||
```
|
||||
|
||||
### 5. Install from GitHub Packages
|
||||
|
||||
Users can install your package with:
|
||||
|
||||
```bash
|
||||
npm install @fromchat/protocol@npm:@fromchat/protocol
|
||||
```
|
||||
|
||||
Or add to `.npmrc`:
|
||||
|
||||
```
|
||||
@fromchat:registry=https://npm.pkg.github.com
|
||||
```
|
||||
|
||||
## Using the Published Package
|
||||
|
||||
### From npm
|
||||
|
||||
```bash
|
||||
npm install @fromchat/protocol
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { FromChatProtocol } from "@fromchat/protocol";
|
||||
```
|
||||
|
||||
### From GitHub Packages
|
||||
|
||||
```bash
|
||||
npm install @fromchat/protocol@npm:@fromchat/protocol
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The package is built to `dist/` directory
|
||||
- Source files in `src/` are excluded from the published package
|
||||
- Only `dist/` and `README.md` are included in the published package
|
||||
- The package uses ES modules (ESM) format
|
||||
- TypeScript definitions are included in `dist/`
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# FromChat Protocol
|
||||
|
||||
Simple ECDH-based encryption protocol for direct messages.
|
||||
|
||||
## Overview
|
||||
|
||||
The FromChat Protocol provides end-to-end encryption for direct messages using:
|
||||
- **X25519** (ECDH) for key exchange
|
||||
- **HKDF** for key derivation
|
||||
- **AES-GCM** for symmetric encryption
|
||||
|
||||
This module is completely independent and can be used in any JavaScript/TypeScript project.
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
### Encryption
|
||||
|
||||
1. Generate a random message key (mk) - 32 bytes
|
||||
2. Generate a random salt (wkSalt) - 16 bytes
|
||||
3. Derive shared secret from ECDH: `ecdhSharedSecret(myPrivateKey, theirPublicKey)`
|
||||
4. Derive wrapping key: `deriveWrappingKey(sharedSecret, wkSalt, info)` using HKDF
|
||||
5. Encrypt message with mk using AES-GCM → (iv, ciphertext)
|
||||
6. Encrypt (wrap) mk with wrapping key using AES-GCM → (iv2, wrappedMk)
|
||||
7. Send: `{ iv, ciphertext, salt, iv2, wrappedMk }`
|
||||
|
||||
### Decryption
|
||||
|
||||
1. Derive shared secret from ECDH
|
||||
2. Derive wrapping key from shared secret using salt from message
|
||||
3. Decrypt wrappedMk to get mk
|
||||
4. Decrypt ciphertext with mk
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { FromChatProtocol } from "@fromchat/protocol";
|
||||
|
||||
// Initialize with your private key
|
||||
const protocol = new FromChatProtocol(privateKey);
|
||||
|
||||
// Encrypt a message
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, "Hello!");
|
||||
|
||||
// Decrypt a message
|
||||
const decrypted = await protocol.decryptMessage(senderPublicKey, encrypted);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `FromChatProtocol`
|
||||
|
||||
#### Constructor
|
||||
- `constructor(privateKey: Uint8Array)` - Initialize protocol with your X25519 private key
|
||||
|
||||
#### Methods
|
||||
- `encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage>` - Encrypt a message
|
||||
- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string>` - Decrypt a message
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
interface EncryptedMessage {
|
||||
iv: string; // Base64 encoded IV for message encryption
|
||||
ciphertext: string; // Base64 encoded encrypted message
|
||||
salt: string; // Base64 encoded salt for wrapping key derivation
|
||||
iv2: string; // Base64 encoded IV for message key wrapping
|
||||
wrappedMk: string; // Base64 encoded wrapped message key
|
||||
}
|
||||
```
|
||||
|
||||
## Backup & Key Management
|
||||
|
||||
The protocol also includes utilities for backing up and restoring private keys:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
encryptBackupWithPassword,
|
||||
decryptBackupWithPassword,
|
||||
encodeBlob,
|
||||
decodeBlob
|
||||
} from "@fromchat/protocol";
|
||||
|
||||
// Create a backup of a private key
|
||||
const bundle = { version: 1, privateKey: myPrivateKey };
|
||||
const encrypted = await encryptBackupWithPassword("my-password", bundle);
|
||||
const backupString = encodeBlob(encrypted); // Store this string
|
||||
|
||||
// Restore from backup
|
||||
const encryptedBlob = decodeBlob(backupString);
|
||||
const restored = await decryptBackupWithPassword("my-password", encryptedBlob);
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Each message uses a fresh random message key
|
||||
- The protocol does not provide forward secrecy
|
||||
- Keys are derived using HKDF with SHA-256
|
||||
- All encryption uses AES-GCM with 12-byte IVs
|
||||
- Backup encryption uses PBKDF2 with 210,000 iterations
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"name": "@fromchat/protocol",
|
||||
"version": "1.0.0",
|
||||
"description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"encryption",
|
||||
"ecdh",
|
||||
"e2ee",
|
||||
"end-to-end-encryption",
|
||||
"x25519",
|
||||
"aes-gcm",
|
||||
"hkdf"
|
||||
],
|
||||
"author": "denis0001-dev",
|
||||
"license": "GPL-3.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Toolbox-io/FromChat.git",
|
||||
"directory": "frontend/packages/fromchat-protocol"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Toolbox-io/FromChat/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Toolbox-io/FromChat#readme",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.2",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// Re-export all crypto functions for convenience
|
||||
export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./asymmetric";
|
||||
export type { X25519KeyPair } from "./asymmetric";
|
||||
export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./symmetric";
|
||||
export type { AesGcmCiphertext } from "./symmetric";
|
||||
export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./kdf";
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export { FromChatProtocol } from "./protocol/FromChatProtocol";
|
||||
export type { EncryptedMessage } from "./protocol/types";
|
||||
|
||||
// Export crypto functions
|
||||
export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./crypto/asymmetric";
|
||||
export type { X25519KeyPair } from "./crypto/asymmetric";
|
||||
export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./crypto/symmetric";
|
||||
export type { AesGcmCiphertext } from "./crypto/symmetric";
|
||||
export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./crypto/kdf";
|
||||
|
||||
// Export backup functions
|
||||
export {
|
||||
encryptBackupWithPassword,
|
||||
decryptBackupWithPassword,
|
||||
encodeBlob,
|
||||
decodeBlob,
|
||||
serializeBundle,
|
||||
deserializeBundle
|
||||
} from "./backup/backup";
|
||||
export type { PrivateKeyBundle, EncryptedBackupBlob } from "./backup/backup";
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
|
||||
import { randomBytes } from "../crypto/kdf";
|
||||
import type { EncryptedMessage } from "./types";
|
||||
|
||||
/**
|
||||
* FromChat Protocol - Simple ECDH-based encryption
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Generate random message key (mk) - 32 bytes
|
||||
* 2. Generate random salt (wkSalt) - 16 bytes
|
||||
* 3. Derive shared secret from ECDH (X25519)
|
||||
* 4. Derive wrapping key from shared secret using HKDF with salt
|
||||
* 5. Encrypt message with mk using AES-GCM
|
||||
* 6. Encrypt (wrap) mk with wrapping key using AES-GCM
|
||||
* 7. Send: { iv, ciphertext, salt, iv2, wrappedMk }
|
||||
*/
|
||||
export class FromChatProtocol {
|
||||
private privateKey: Uint8Array;
|
||||
|
||||
constructor(privateKey: Uint8Array) {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message for a recipient
|
||||
* @param recipientPublicKey - Recipient's X25519 public key
|
||||
* @param plaintext - Message to encrypt
|
||||
* @returns Encrypted message with all necessary fields
|
||||
*/
|
||||
async encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage> {
|
||||
// Generate random message key
|
||||
const mk = randomBytes(32);
|
||||
|
||||
// Generate random salt for wrapping key derivation
|
||||
const wkSalt = randomBytes(16);
|
||||
|
||||
// Derive shared secret from ECDH
|
||||
const shared = ecdhSharedSecret(this.privateKey, recipientPublicKey);
|
||||
|
||||
// Derive wrapping key from shared secret using HKDF
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message with message key
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), plaintextBytes);
|
||||
|
||||
// Encrypt (wrap) the message key with wrapping key
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
// Convert to base64 for transmission
|
||||
return {
|
||||
iv: btoa(String.fromCharCode(...encMsg.iv)),
|
||||
ciphertext: btoa(String.fromCharCode(...encMsg.ciphertext)),
|
||||
salt: btoa(String.fromCharCode(...wkSalt)),
|
||||
iv2: btoa(String.fromCharCode(...wrap.iv)),
|
||||
wrappedMk: btoa(String.fromCharCode(...wrap.ciphertext))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message from a sender
|
||||
* @param senderPublicKey - Sender's X25519 public key
|
||||
* @param message - Encrypted message
|
||||
* @returns Decrypted plaintext
|
||||
*/
|
||||
async decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string> {
|
||||
// Decode base64 fields
|
||||
const salt = new Uint8Array(
|
||||
atob(message.salt).split("").map(c => c.charCodeAt(0))
|
||||
);
|
||||
const iv2 = new Uint8Array(
|
||||
atob(message.iv2).split("").map(c => c.charCodeAt(0))
|
||||
);
|
||||
const wrappedMk = new Uint8Array(
|
||||
atob(message.wrappedMk).split("").map(c => c.charCodeAt(0))
|
||||
);
|
||||
const iv = new Uint8Array(
|
||||
atob(message.iv).split("").map(c => c.charCodeAt(0))
|
||||
);
|
||||
const ciphertext = new Uint8Array(
|
||||
atob(message.ciphertext).split("").map(c => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
// Derive shared secret from ECDH
|
||||
const shared = ecdhSharedSecret(this.privateKey, senderPublicKey);
|
||||
|
||||
// Derive wrapping key from shared secret using salt from message
|
||||
const wkRaw = await deriveWrappingKey(shared, salt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Decrypt (unwrap) the message key
|
||||
const mk = await aesGcmDecrypt(wk, iv2, wrappedMk);
|
||||
|
||||
// Decrypt the message with message key
|
||||
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Encrypted message format
|
||||
*/
|
||||
export interface EncryptedMessage {
|
||||
iv: string; // Base64 encoded IV for message encryption
|
||||
ciphertext: string; // Base64 encoded encrypted message
|
||||
salt: string; // Base64 encoded salt for wrapping key derivation
|
||||
iv2: string; // Base64 encoded IV for message key wrapping
|
||||
wrappedMk: string; // Base64 encoded wrapped message key
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
|
||||
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
|
||||
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
|
||||
import type { Headers } from "@/core/types";
|
||||
|
||||
@@ -125,6 +127,7 @@ export async function deriveAuthSecret(username: string, password: string): Prom
|
||||
return b64(derived);
|
||||
}
|
||||
|
||||
|
||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||
// Try to restore from backup
|
||||
const blobJson = await fetchBackupBlob(token);
|
||||
|
||||
@@ -1,19 +1,148 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import { getCurrentKeys } from "../user/auth";
|
||||
import api from "@/core/api";
|
||||
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "../crypto/identity";
|
||||
import { fetchUsers, searchUsers } from "../user/search";
|
||||
import { getOrInitProtocol } from "@/utils/crypto/fromchatInit";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64 } from "@/utils/utils";
|
||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
|
||||
|
||||
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const protocol = getOrInitProtocol();
|
||||
const senderPublicKey = ub64(senderPublicKeyB64);
|
||||
export async function decrypt(envelope: DmEnvelope, senderId: number): Promise<string> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
if (!envelope.ciphertext) {
|
||||
throw new Error("DM envelope missing ciphertext");
|
||||
}
|
||||
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
return await protocol.decryptMessage(senderPublicKey, envelope);
|
||||
// Remove padding (backward compatible with old messages)
|
||||
// Check if ciphertext is base64 (padded) or already JSON (unpadded)
|
||||
let ciphertextStr: string = envelope.ciphertext;
|
||||
|
||||
// Check if it's base64 (padded messages are base64)
|
||||
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
|
||||
|
||||
if (isBase64) {
|
||||
// Try to remove padding
|
||||
try {
|
||||
const unpadded = removePadding(envelope.ciphertext);
|
||||
// Verify it's valid JSON before using it
|
||||
JSON.parse(unpadded);
|
||||
ciphertextStr = unpadded;
|
||||
} catch {
|
||||
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
|
||||
try {
|
||||
JSON.parse(envelope.ciphertext);
|
||||
ciphertextStr = envelope.ciphertext;
|
||||
} catch {
|
||||
// If both fail, throw an error
|
||||
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not base64, assume it's already JSON (unpadded message)
|
||||
ciphertextStr = envelope.ciphertext;
|
||||
}
|
||||
|
||||
// Parse Signal Protocol message
|
||||
let signalCiphertext: { type: number; body: string };
|
||||
try {
|
||||
signalCiphertext = JSON.parse(ciphertextStr);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ciphertext as JSON: ${error instanceof Error ? error.message : String(error)}. Ciphertext length: ${ciphertextStr.length}, first 100 chars: ${ciphertextStr.substring(0, 100)}`);
|
||||
}
|
||||
|
||||
if (!signalCiphertext || typeof signalCiphertext !== "object") {
|
||||
throw new Error("Invalid Signal Protocol message format: not an object");
|
||||
}
|
||||
|
||||
if (typeof signalCiphertext.type !== "number") {
|
||||
throw new Error("Invalid Signal Protocol message format: type is not a number");
|
||||
}
|
||||
|
||||
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
|
||||
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
|
||||
}
|
||||
|
||||
// Check if body contains non-printable characters (corrupted binary data from old encryption)
|
||||
// This must be checked first, before any base64 validation
|
||||
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
|
||||
if (hasNonPrintable) {
|
||||
// This is a corrupted message from before the base64 conversion fix
|
||||
// It cannot be decrypted - the body contains raw binary data instead of base64
|
||||
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
|
||||
return "_This message is corrupted and cannot be displayed._";
|
||||
}
|
||||
|
||||
// Check if body contains Unicode escape sequences (from JSON.stringify escaping)
|
||||
// If so, we need to unescape them to get the actual base64 string
|
||||
let bodyToDecode = signalCiphertext.body;
|
||||
|
||||
// Check for literal backslash-u sequences (before JSON parsing, these would be "\\u")
|
||||
// After JSON parsing, Unicode escapes are converted to actual characters, so we check for
|
||||
// the pattern that indicates it might have been escaped
|
||||
if (bodyToDecode.includes("\\u") || bodyToDecode.match(/\\u[0-9a-fA-F]{4}/)) {
|
||||
// Try to unescape Unicode sequences by wrapping in JSON quotes
|
||||
try {
|
||||
bodyToDecode = JSON.parse(`"${bodyToDecode.replace(/\\/g, "\\\\")}"`);
|
||||
} catch {
|
||||
// If unescaping fails, use the original
|
||||
bodyToDecode = signalCiphertext.body;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that body is valid base64 before attempting decryption
|
||||
// Check if it's a valid base64 string (only contains base64 characters and padding)
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
if (!base64Regex.test(bodyToDecode)) {
|
||||
// Log for debugging - this should help identify the issue
|
||||
console.error("Invalid base64 in body:", {
|
||||
bodyType: typeof signalCiphertext.body,
|
||||
bodyLength: signalCiphertext.body.length,
|
||||
unescapedLength: bodyToDecode.length,
|
||||
first50: signalCiphertext.body.substring(0, 50),
|
||||
unescapedFirst50: bodyToDecode.substring(0, 50),
|
||||
envelopeId: envelope.id
|
||||
});
|
||||
throw new Error(`Invalid base64 format in ciphertext body`);
|
||||
}
|
||||
|
||||
// Use the unescaped body for decryption
|
||||
signalCiphertext.body = bodyToDecode;
|
||||
|
||||
try {
|
||||
// Try to decode a small portion to validate base64
|
||||
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
|
||||
} catch (error) {
|
||||
// Log for debugging
|
||||
console.error("Base64 decode failed:", {
|
||||
bodyLength: signalCiphertext.body.length,
|
||||
first50: signalCiphertext.body.substring(0, 50),
|
||||
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
|
||||
envelopeId: envelope.id,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
||||
return plaintext;
|
||||
} catch (error) {
|
||||
// If decryption fails, check if it's a session issue
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes("No session exists") || errorMessage.includes("No record for device")) {
|
||||
console.warn(`Session missing for sender ${senderId} (envelope ID: ${envelope.id}). This may happen after page reload if the session was not properly restored.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||
@@ -22,22 +151,106 @@ export async function fetchMessages(userId: number, token: string, limit: number
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
headers: api.user.auth.getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return { messages: [], has_more: false };
|
||||
const data = await response.json();
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const protocol = getOrInitProtocol();
|
||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||
export async function send(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext);
|
||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||
let hasSession = false;
|
||||
try {
|
||||
hasSession = await signalService.hasSession(recipientId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to check session, will attempt to establish new one:", error);
|
||||
}
|
||||
|
||||
if (!hasSession) {
|
||||
try {
|
||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||
} catch (error) {
|
||||
// Re-throw PrekeyExhaustedError as-is for proper handling
|
||||
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
||||
throw error;
|
||||
}
|
||||
// Log other errors for debugging
|
||||
console.error("Failed to establish session:", {
|
||||
recipientId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt with Signal Protocol
|
||||
let ciphertext: { type: number; body: string };
|
||||
try {
|
||||
ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
||||
} catch (error) {
|
||||
console.error("Failed to encrypt message:", {
|
||||
recipientId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Verify the body is valid base64 before stringifying
|
||||
if (ciphertext.body && typeof ciphertext.body === "string") {
|
||||
try {
|
||||
// Test that body is valid base64
|
||||
atob(ciphertext.body.substring(0, Math.min(4, ciphertext.body.length)));
|
||||
|
||||
// Verify the entire body is valid base64
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
if (!base64Regex.test(ciphertext.body)) {
|
||||
console.error("Invalid base64 characters in encrypted body:", {
|
||||
bodyLength: ciphertext.body.length,
|
||||
first100: ciphertext.body.substring(0, 100),
|
||||
last100: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 100))
|
||||
});
|
||||
throw new Error("Encrypted body contains invalid base64 characters");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Encrypted body is not valid base64: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Stringify the ciphertext - JSON.stringify should not escape base64 strings
|
||||
const ciphertextJson = JSON.stringify(ciphertext);
|
||||
|
||||
// Verify the stringified JSON doesn't have escaped characters in the body field
|
||||
const parsed = JSON.parse(ciphertextJson);
|
||||
if (parsed.body !== ciphertext.body) {
|
||||
console.error("Body was modified during JSON stringification:", {
|
||||
original: ciphertext.body.substring(0, 50),
|
||||
stringified: parsed.body.substring(0, 50),
|
||||
originalLength: ciphertext.body.length,
|
||||
stringifiedLength: parsed.body.length
|
||||
});
|
||||
throw new Error("Body was incorrectly escaped during JSON stringification");
|
||||
}
|
||||
|
||||
// Add padding to obfuscate message size (anti-censorship)
|
||||
const paddedCiphertext = addPadding(ciphertextJson);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
...encrypted
|
||||
iv: "", // Not used for Signal Protocol
|
||||
ciphertext: paddedCiphertext, // Padded Signal Protocol message
|
||||
salt: "", // Not used for Signal Protocol
|
||||
iv2: "", // Not used for Signal Protocol
|
||||
wrappedMk: "" // Not used for Signal Protocol
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
@@ -49,20 +262,44 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
|
||||
// Note: We'll cache the message when we receive the dmNew confirmation via WebSocket
|
||||
// which contains the actual message ID
|
||||
}
|
||||
|
||||
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
// For files, we need to use the same message key for both the message and files
|
||||
// So we'll do the encryption manually here to reuse the mk
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||
const hasSession = await signalService.hasSession(recipientId);
|
||||
if (!hasSession) {
|
||||
try {
|
||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
|
||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||
} catch (error) {
|
||||
// Re-throw PrekeyExhaustedError as-is for proper handling
|
||||
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
||||
throw error;
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate master key for file encryption
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
// Encrypt the master key using Signal Protocol
|
||||
const mkBase64 = b64(mk);
|
||||
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
||||
|
||||
// Add padding to obfuscate master key size
|
||||
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
@@ -75,42 +312,84 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name;
|
||||
const serverName = f.name; // server uses provided name
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Encrypt the plaintext JSON with the same mk
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson));
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
try {
|
||||
obj = JSON.parse(plaintextJson);
|
||||
} catch {
|
||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
||||
}
|
||||
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
salt: "", // Not used for Signal Protocol
|
||||
iv2: "", // Not used for Signal Protocol
|
||||
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
headers: api.user.auth.getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const protocol = getOrInitProtocol();
|
||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||
export async function edit(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson);
|
||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||
const hasSession = await signalService.hasSession(recipientId);
|
||||
if (!hasSession) {
|
||||
try {
|
||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||
} catch (error) {
|
||||
// Re-throw PrekeyExhaustedError as-is for proper handling
|
||||
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
||||
throw error;
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate fresh master key for the edited message
|
||||
const mk = randomBytes(32);
|
||||
|
||||
// Encrypt the master key using Signal Protocol
|
||||
const mkBase64 = b64(mk);
|
||||
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
||||
|
||||
// Add padding to obfuscate master key size
|
||||
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
||||
|
||||
// Encrypt the message content with the master key
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
...encrypted
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: "", // Not used for Signal Protocol
|
||||
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
|
||||
salt: "" // Not used for Signal Protocol
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
@@ -131,7 +410,7 @@ export interface ConversationResponse {
|
||||
|
||||
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
headers: api.user.auth.getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
@@ -150,4 +429,7 @@ export async function markRead(id: number, authToken: string): Promise<void> {
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
export { fetchUsers, searchUsers } from "@/core/api/users";
|
||||
export { fetchUserPublicKey } from "@/core/api/crypto/identity";
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
|
||||
|
||||
/**
|
||||
* Fetches the current user's public key
|
||||
@@ -74,3 +75,39 @@ export async function uploadBackupBlob(blobJson: string, token: string): Promise
|
||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads Signal Protocol prekey bundle for the current user
|
||||
*/
|
||||
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
|
||||
// Re-export from prekeys.ts
|
||||
const { uploadPreKeyBundle: upload } = await import("./crypto/prekeys");
|
||||
return upload(bundle, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads all available prekeys to the server for rotation
|
||||
*/
|
||||
export async function uploadAllPreKeys(
|
||||
baseBundle: Omit<PreKeyBundleData, "preKey">,
|
||||
prekeys: Array<{ keyId: number; publicKey: string }>,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
// Re-export from prekeys.ts
|
||||
const { uploadAllPreKeys: upload } = await import("./crypto/prekeys");
|
||||
return upload(baseBundle, prekeys, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Signal Protocol prekey bundle for another user
|
||||
*/
|
||||
export async function fetchPreKeyBundle(userId: number, token: string): Promise<any | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.bundle || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import type { BackupBlob } from "@/core/types";
|
||||
import api from "@/core/api";
|
||||
|
||||
/**
|
||||
* Fetches the current user's backup blob
|
||||
*/
|
||||
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "GET",
|
||||
headers
|
||||
@@ -25,7 +25,7 @@ export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* API functions for managing encrypted message plaintexts on the server
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import api from "@/core/api";
|
||||
|
||||
export interface MessagePlaintextData {
|
||||
messageId: number;
|
||||
recipientId: number;
|
||||
encryptedData: string;
|
||||
}
|
||||
|
||||
export interface MessagePlaintextResponse {
|
||||
messageId: number;
|
||||
recipientId: number;
|
||||
encryptedData: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload encrypted message plaintexts to the server
|
||||
*/
|
||||
export async function uploadMessagePlaintexts(
|
||||
messages: MessagePlaintextData[],
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/crypto/signal/message-plaintexts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...api.user.auth.getAuthHeaders(token, false)
|
||||
},
|
||||
body: JSON.stringify({ messages })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Failed to upload message plaintexts" }));
|
||||
throw new Error(error.detail || "Failed to upload message plaintexts");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch encrypted message plaintexts from the server
|
||||
*/
|
||||
export async function fetchMessagePlaintexts(
|
||||
token: string,
|
||||
recipientId?: number
|
||||
): Promise<MessagePlaintextResponse[]> {
|
||||
let url = `${API_BASE_URL}/crypto/signal/message-plaintexts`;
|
||||
if (recipientId !== undefined) {
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
url = `${url}${separator}recipient_id=${recipientId}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: api.user.auth.getAuthHeaders(token, false)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Failed to fetch message plaintexts" }));
|
||||
throw new Error(error.detail || "Failed to fetch message plaintexts");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
@@ -1,14 +1,89 @@
|
||||
// Placeholder for Signal Protocol pre-key management
|
||||
// Will be implemented when Signal Protocol is added
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
|
||||
|
||||
export async function upload(_bundle: unknown, _token: string): Promise<void> {
|
||||
// TODO: Implement Signal Protocol pre-key upload
|
||||
throw new Error("Not implemented yet");
|
||||
/**
|
||||
* Uploads Signal Protocol prekey bundle for the current user
|
||||
* This uploads the base bundle (identity, signed prekey) and one prekey
|
||||
*/
|
||||
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
|
||||
const payload = { bundle };
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload prekey bundle");
|
||||
}
|
||||
|
||||
export async function fetch(_userId: number, _token: string): Promise<unknown> {
|
||||
// TODO: Implement Signal Protocol pre-key fetch
|
||||
throw new Error("Not implemented yet");
|
||||
/**
|
||||
* Uploads all available prekeys to the server for rotation in a single request
|
||||
*/
|
||||
export async function uploadAllPreKeys(
|
||||
baseBundle: Omit<PreKeyBundleData, "preKey">,
|
||||
prekeys: Array<{ keyId: number; publicKey: string }>,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const payload = {
|
||||
baseBundle,
|
||||
prekeys
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekeys/bulk`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to upload prekeys: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error for prekey exhaustion
|
||||
*/
|
||||
export class PrekeyExhaustedError extends Error {
|
||||
constructor(public readonly recipientId: number) {
|
||||
super("Recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys.");
|
||||
this.name = "PrekeyExhaustedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Signal Protocol prekey bundle for another user
|
||||
* @throws {PrekeyExhaustedError} If the recipient has no unused prekeys available
|
||||
*/
|
||||
export async function fetchPreKeyBundle(userId: number, token: string): Promise<PreKeyBundleData> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Error("Recipient has not set up encryption. They need to log in to initialize their encryption keys.");
|
||||
}
|
||||
throw new Error("Failed to fetch prekey bundle");
|
||||
}
|
||||
const data = await res.json();
|
||||
const bundle = data.bundle;
|
||||
|
||||
// Check if bundle exists but has no prekey (all prekeys exhausted)
|
||||
if (!bundle) {
|
||||
throw new PrekeyExhaustedError(userId);
|
||||
}
|
||||
|
||||
// If bundle exists but has no preKey field, it means all prekeys are exhausted
|
||||
// The backend returns bundle without preKey when no unused prekeys are available
|
||||
if (!bundle.preKey) {
|
||||
throw new PrekeyExhaustedError(userId);
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* API functions for managing Signal Protocol sessions on the server
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
|
||||
export interface SessionData {
|
||||
recipientId: number;
|
||||
deviceId: number;
|
||||
encryptedData: string; // JSON string of encrypted session
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload encrypted Signal Protocol sessions to the server
|
||||
*/
|
||||
export async function uploadSessions(sessions: SessionData[], token: string): Promise<void> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
|
||||
const payload = {
|
||||
sessions
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to upload sessions: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all encrypted Signal Protocol sessions from the server
|
||||
*/
|
||||
export async function fetchSessions(token: string): Promise<SessionData[]> {
|
||||
console.log("[Session API] Fetching sessions from server...");
|
||||
console.log("[Session API] URL:", `${API_BASE_URL}/crypto/signal/sessions`);
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
|
||||
console.log("[Session API] Response status:", res.status, res.statusText);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "Unknown error");
|
||||
console.error("[Session API] Failed to fetch sessions:", {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
errorText
|
||||
});
|
||||
throw new Error(`Failed to fetch sessions: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
console.log("[Session API] Response data:", {
|
||||
hasSessions: !!data.sessions,
|
||||
sessionCount: data.sessions?.length || 0
|
||||
});
|
||||
|
||||
return data.sessions || [];
|
||||
}
|
||||
|
||||
+14
-174
@@ -1,176 +1,16 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||
import { getCurrentKeys } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
// Re-export from dmApi.ts which has Signal Protocol support
|
||||
export {
|
||||
decryptDm,
|
||||
fetchDMHistory,
|
||||
sendDMViaWebSocket,
|
||||
sendDmWithFiles,
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope,
|
||||
fetchDMConversations,
|
||||
fetchUsers,
|
||||
searchUsers,
|
||||
fetchUserPublicKey
|
||||
} from "./dmApi";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Obtain the key
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||
|
||||
// Decrypt
|
||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||
return new TextDecoder().decode(msg);
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// Encrypt file with same mk
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name; // server uses provided name
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
try {
|
||||
obj = JSON.parse(plaintextJson);
|
||||
} catch {
|
||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
||||
}
|
||||
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
export type { DMConversationResponse } from "./dmApi";
|
||||
|
||||
|
||||
+204
-65
@@ -1,62 +1,163 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||
import { getCurrentKeys } from "./account";
|
||||
import api from "@/core/api";
|
||||
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
import { b64 } from "@/utils/utils";
|
||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
// Obtain the key
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||
if (!envelope.ciphertext) {
|
||||
throw new Error("DM envelope missing ciphertext");
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||
return new TextDecoder().decode(msg);
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Remove padding (backward compatible with old messages)
|
||||
// Check if ciphertext is base64 (padded messages are base64)
|
||||
let ciphertextStr: string = envelope.ciphertext;
|
||||
|
||||
// Check if it's base64 (padded messages are base64)
|
||||
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
|
||||
|
||||
if (isBase64) {
|
||||
// Try to remove padding
|
||||
try {
|
||||
const unpadded = removePadding(envelope.ciphertext);
|
||||
// Verify it's valid JSON before using it
|
||||
JSON.parse(unpadded);
|
||||
ciphertextStr = unpadded;
|
||||
} catch {
|
||||
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
|
||||
try {
|
||||
JSON.parse(envelope.ciphertext);
|
||||
ciphertextStr = envelope.ciphertext;
|
||||
} catch {
|
||||
// If both fail, throw an error
|
||||
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not base64, assume it's already JSON (unpadded message)
|
||||
ciphertextStr = envelope.ciphertext;
|
||||
}
|
||||
|
||||
// Parse Signal Protocol message
|
||||
let signalCiphertext: { type: number; body: string };
|
||||
try {
|
||||
signalCiphertext = JSON.parse(ciphertextStr);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse Signal Protocol message: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (!signalCiphertext || typeof signalCiphertext !== "object") {
|
||||
throw new Error("Invalid Signal Protocol message format: not an object");
|
||||
}
|
||||
|
||||
if (typeof signalCiphertext.type !== "number") {
|
||||
throw new Error("Invalid Signal Protocol message format: type is not a number");
|
||||
}
|
||||
|
||||
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
|
||||
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
|
||||
}
|
||||
|
||||
// Validate that body is valid base64 before attempting decryption
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
if (!base64Regex.test(signalCiphertext.body)) {
|
||||
// Check if body contains non-printable characters (corrupted binary data)
|
||||
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
|
||||
if (hasNonPrintable) {
|
||||
// This is a corrupted message from before the base64 conversion fix
|
||||
// It cannot be decrypted - the body contains raw binary data instead of base64
|
||||
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
|
||||
|
||||
return "_This message is corrupted and cannot be displayed._";
|
||||
}
|
||||
|
||||
console.error("Invalid base64 in body:", {
|
||||
bodyType: typeof signalCiphertext.body,
|
||||
bodyLength: signalCiphertext.body.length,
|
||||
first50: signalCiphertext.body.substring(0, 50),
|
||||
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
|
||||
envelopeId: envelope.id
|
||||
});
|
||||
throw new Error(`Invalid base64 format in ciphertext body`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to decode a small portion to validate base64
|
||||
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
|
||||
} catch (error) {
|
||||
console.error("Base64 decode failed:", {
|
||||
bodyLength: signalCiphertext.body.length,
|
||||
first50: signalCiphertext.body.substring(0, 50),
|
||||
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
|
||||
envelopeId: envelope.id,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
||||
return plaintext;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decrypt DM: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
headers: api.user.auth.getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
export async function sendDMViaWebSocket(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||
const hasSession = await signalService.hasSession(recipientId);
|
||||
if (!hasSession) {
|
||||
// Fetch prekey bundle from server
|
||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
||||
if (!bundle) {
|
||||
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
|
||||
}
|
||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||
}
|
||||
|
||||
// Encrypt with Signal Protocol
|
||||
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
||||
|
||||
// Add padding to obfuscate message size (anti-censorship)
|
||||
const paddedCiphertext = addPadding(JSON.stringify(ciphertext));
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
iv: "", // Not used for Signal Protocol
|
||||
ciphertext: paddedCiphertext, // Padded Signal Protocol message
|
||||
salt: "", // Not used for Signal Protocol
|
||||
iv2: "", // Not used for Signal Protocol
|
||||
wrappedMk: "" // Not used for Signal Protocol
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
@@ -70,17 +171,33 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
export async function sendDmWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||
const hasSession = await signalService.hasSession(recipientId);
|
||||
if (!hasSession) {
|
||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
|
||||
if (!bundle) {
|
||||
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
|
||||
}
|
||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||
}
|
||||
|
||||
// Generate master key for file encryption
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
// Encrypt the master key using Signal Protocol
|
||||
const mkBase64 = b64(mk);
|
||||
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
||||
|
||||
// Add padding to obfuscate master key size
|
||||
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
@@ -112,30 +229,48 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
salt: "", // Not used for Signal Protocol
|
||||
iv2: "", // Not used for Signal Protocol
|
||||
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
headers: api.user.auth.getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
export async function editDmEnvelope(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||
const hasSession = await signalService.hasSession(recipientId);
|
||||
if (!hasSession) {
|
||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
||||
if (!bundle) {
|
||||
throw new Error("No Signal Protocol prekey bundle available for recipient");
|
||||
}
|
||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||
}
|
||||
|
||||
// Generate fresh master key for the edited message
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the master key using Signal Protocol
|
||||
const mkBase64 = b64(mk);
|
||||
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
||||
|
||||
// Add padding to obfuscate master key size
|
||||
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
||||
|
||||
// Encrypt the message content with the master key
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
@@ -144,9 +279,9 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string,
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
iv2: "", // Not used for Signal Protocol
|
||||
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
|
||||
salt: "" // Not used for Signal Protocol
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
@@ -165,9 +300,13 @@ export interface DMConversationResponse {
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export { fetchUsers, searchUsers } from "./users";
|
||||
export { fetchUserPublicKey } from "./crypto/identity";
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
headers: api.user.auth.getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
|
||||
@@ -7,6 +7,8 @@ import * as userSearch from "./user/search";
|
||||
import * as cryptoPrekeys from "./crypto/prekeys";
|
||||
import * as cryptoIdentity from "./crypto/identity";
|
||||
import * as cryptoBackup from "./crypto/backup";
|
||||
import * as cryptoSessions from "./crypto/sessions";
|
||||
import * as cryptoMessagePlaintexts from "./crypto/messagePlaintexts";
|
||||
import * as moderationBlocklist from "./moderation/blocklist";
|
||||
import * as moderationUsers from "./moderation/users";
|
||||
import * as callsModule from "./calls";
|
||||
@@ -27,7 +29,9 @@ const api = {
|
||||
crypto: {
|
||||
prekeys: cryptoPrekeys,
|
||||
identity: cryptoIdentity,
|
||||
backup: cryptoBackup
|
||||
backup: cryptoBackup,
|
||||
sessions: cryptoSessions,
|
||||
messagePlaintexts: cryptoMessagePlaintexts
|
||||
},
|
||||
moderation: {
|
||||
blocklist: moderationBlocklist,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
|
||||
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
|
||||
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
|
||||
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
|
||||
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes, ecdhSharedSecret, deriveWrappingKey } from "@fromchat/protocol";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import api from "@/core/api";
|
||||
import type { WrappedSessionKeyPayload } from "@/core/types";
|
||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { fetchPreKeyBundle } from "@/core/api/crypto";
|
||||
import { getAuthToken } from "@/core/api/account";
|
||||
|
||||
export interface CallSessionKey {
|
||||
key: Uint8Array;
|
||||
hash: string; // For emoji display
|
||||
}
|
||||
|
||||
export interface CallKeyExchange {
|
||||
type: "call_key_exchange";
|
||||
sessionKeyHash: string;
|
||||
encryptedSessionKey: EncryptedCallMessage;
|
||||
}
|
||||
|
||||
export interface EncryptedCallMessage {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedSessionKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new call session key for end-to-end encryption
|
||||
* @returns Promise that resolves to a session key with its hash for display
|
||||
@@ -58,97 +46,6 @@ export async function rotateCallSessionKey(): Promise<CallSessionKey> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create session key from hash (for backward compatibility)
|
||||
* @deprecated Use deriveCallSessionKeyFromSharedSecret instead
|
||||
*/
|
||||
export async function createCallSessionKeyFromHash(hash: string): Promise<CallSessionKey> {
|
||||
// For backward compatibility, generate a deterministic key from the hash
|
||||
const hashBytes = ub64(hash);
|
||||
const sessionKey = new Uint8Array(32);
|
||||
|
||||
// Repeat the hash bytes to fill 32 bytes
|
||||
for (let i = 0; i < 32; i++) {
|
||||
sessionKey[i] = hashBytes[i % hashBytes.length];
|
||||
}
|
||||
|
||||
return {
|
||||
key: sessionKey,
|
||||
hash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive session key from ECDH shared secret and session key hash
|
||||
* This creates a deterministic but cryptographically secure key
|
||||
*/
|
||||
export async function deriveCallSessionKeyFromSharedSecret(
|
||||
sharedSecret: Uint8Array,
|
||||
sessionKeyHash: string,
|
||||
isInitiator: boolean
|
||||
): Promise<CallSessionKey> {
|
||||
// Use HKDF to derive the session key from the shared secret
|
||||
// Include the session key hash and role to ensure uniqueness
|
||||
const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`);
|
||||
const salt = new Uint8Array(32); // Zero salt for deterministic derivation
|
||||
|
||||
// Import the shared secret as a raw key for HKDF
|
||||
const sharedKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedSecret.buffer as ArrayBuffer,
|
||||
{ name: 'HKDF' },
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
// Derive the session key using HKDF
|
||||
const sessionKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: salt,
|
||||
info: info
|
||||
},
|
||||
sharedKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, // Make the key extractable so we can export it
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Export the raw key material
|
||||
const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey);
|
||||
|
||||
return {
|
||||
key: new Uint8Array(sessionKeyMaterial),
|
||||
hash: sessionKeyHash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a call signaling message with the session key
|
||||
*/
|
||||
export async function encryptCallMessage(message: Record<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
|
||||
const messageKey = await importAesGcmKey(sessionKey);
|
||||
const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message)));
|
||||
|
||||
return {
|
||||
iv: b64(encrypted.iv),
|
||||
ciphertext: b64(encrypted.ciphertext),
|
||||
salt: "", // Not used for message encryption, only for key wrapping
|
||||
iv2: "",
|
||||
wrappedSessionKey: ""
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a call signaling message
|
||||
*/
|
||||
export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise<Record<string, unknown>> {
|
||||
const messageKey = await importAesGcmKey(sessionKey);
|
||||
const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext));
|
||||
return JSON.parse(new TextDecoder().decode(decrypted));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 4 emojis representing the call session key
|
||||
*/
|
||||
@@ -174,63 +71,65 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
|
||||
return emojis;
|
||||
}
|
||||
|
||||
// HKDF info for CALL key wrapping (distinct from DM's info)
|
||||
const CALL_INFO = new Uint8Array([2]);
|
||||
|
||||
/**
|
||||
* Wraps a call session key for a specific recipient using ECDH key exchange
|
||||
* @param recipientPublicKeyB64 - The recipient's public key in base64 format
|
||||
* @param sessionKey - The session key to wrap
|
||||
* @returns Promise that resolves to the wrapped session key payload
|
||||
* Encrypts a call session key using Signal Protocol
|
||||
* @param recipientId - The recipient's user ID
|
||||
* @param sessionKey - The session key to encrypt
|
||||
* @returns Promise that resolves to encrypted session key data
|
||||
*/
|
||||
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
export async function encryptCallSessionKey(recipientId: number, sessionKey: Uint8Array): Promise<{ type: number; body: string }> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
const salt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const wrap = await aesGcmEncrypt(wk, sessionKey);
|
||||
return {
|
||||
salt: b64(salt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrapped: b64(wrap.ciphertext)
|
||||
};
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Ensure we have a session with the recipient
|
||||
const hasSession = await signalService.hasSession(recipientId);
|
||||
if (!hasSession) {
|
||||
// Fetch prekey bundle and establish session
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error("No auth token");
|
||||
}
|
||||
|
||||
const bundle = await fetchPreKeyBundle(recipientId, token);
|
||||
if (!bundle) {
|
||||
throw new Error("No prekey bundle available for recipient");
|
||||
}
|
||||
|
||||
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||
}
|
||||
|
||||
// Encrypt the session key using Signal Protocol
|
||||
const sessionKeyString = b64(sessionKey);
|
||||
const encrypted = await signalService.encryptMessage(recipientId, sessionKeyString);
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shared secret and derive session key for the receiver
|
||||
* Decrypts a call session key using Signal Protocol
|
||||
* @param senderId - The sender's user ID
|
||||
* @param encryptedKey - The encrypted session key data
|
||||
* @returns Promise that resolves to the decrypted session key
|
||||
*/
|
||||
export async function createSharedSecretAndDeriveSessionKey(
|
||||
senderPublicKeyB64: string,
|
||||
sessionKeyHash: string,
|
||||
isInitiator: boolean
|
||||
): Promise<CallSessionKey> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
export async function decryptCallSessionKey(senderId: number, encryptedKey: { type: number; body: string }): Promise<Uint8Array> {
|
||||
const user = useUserStore.getState().user.currentUser;
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
// Create shared secret using ECDH
|
||||
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
|
||||
// Derive the session key from the shared secret
|
||||
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a call session key received from a sender using ECDH key exchange
|
||||
* @param senderPublicKeyB64 - The sender's public key in base64 format
|
||||
* @param payload - The wrapped session key payload
|
||||
* @returns Promise that resolves to the unwrapped session key
|
||||
*/
|
||||
export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const salt = ub64(payload.salt);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const sessionKey = await aesGcmDecrypt(wk, ub64(payload.iv2), ub64(payload.wrapped));
|
||||
return new Uint8Array(sessionKey);
|
||||
const signalService = new SignalProtocolService(user.id.toString());
|
||||
|
||||
// Decrypt using Signal Protocol
|
||||
const decryptedString = await signalService.decryptMessage(senderId, encryptedKey);
|
||||
|
||||
// Convert back to Uint8Array
|
||||
const sessionKey = new Uint8Array(
|
||||
atob(decryptedString).split("").map(c => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData } from "@/core/types";
|
||||
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData, CallSessionKeyData } from "@/core/types";
|
||||
import * as WebRTC from "./webrtc";
|
||||
|
||||
export interface CallState {
|
||||
@@ -133,12 +133,16 @@ export class CallSignalingHandler {
|
||||
|
||||
private handleCallSessionKey(message: CallSignalingMessage) {
|
||||
const state = this.getState();
|
||||
const { sessionKeyHash, data } = message;
|
||||
const { sessionKeyHash } = message;
|
||||
const data = message.data as CallSessionKeyData;
|
||||
|
||||
if (sessionKeyHash) {
|
||||
state.setCallSessionKeyHash(sessionKeyHash);
|
||||
}
|
||||
if (data && 'wrappedSessionKey' in data && data.wrappedSessionKey && message.fromUserId) {
|
||||
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash);
|
||||
|
||||
// Check if data is CallSessionKeyData and has encryptedSessionKey
|
||||
if (data && data.encryptedSessionKey && message.fromUserId) {
|
||||
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.encryptedSessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import api from "@/core/api";
|
||||
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
|
||||
import type { CallSignalingMessage } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
|
||||
import { importAesGcmKey } from "@fromchat/protocol";
|
||||
import { encryptCallSessionKey, decryptCallSessionKey, rotateCallSessionKey } from "./encryption";
|
||||
import { importAesGcmKey } from "@/utils/crypto/symmetric";
|
||||
import E2EEWorker from "./e2eeWorker?worker";
|
||||
import { delay } from "@/utils/utils";
|
||||
|
||||
@@ -855,21 +855,18 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string)
|
||||
|
||||
export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
|
||||
try {
|
||||
const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!);
|
||||
if (!recipientPublicKey) {
|
||||
console.warn("No recipient public key for", userId);
|
||||
return;
|
||||
}
|
||||
const wrapped = await wrapCallSessionKeyForRecipient(recipientPublicKey, sessionKey);
|
||||
// Encrypt session key using Signal Protocol
|
||||
const encrypted = await encryptCallSessionKey(userId, sessionKey);
|
||||
|
||||
await sendSignalingMessage({
|
||||
type: "call_session_key",
|
||||
fromUserId: 0,
|
||||
toUserId: userId,
|
||||
sessionKeyHash,
|
||||
data: { wrappedSessionKey: wrapped }
|
||||
data: { encryptedSessionKey: encrypted }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send wrapped session key:", e);
|
||||
console.error("Failed to send encrypted session key:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,31 +883,21 @@ export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promi
|
||||
|
||||
export async function receiveWrappedSessionKey(
|
||||
fromUserId: number,
|
||||
wrappedPayload: WrappedSessionKeyPayload,
|
||||
sessionKeyHash?: string
|
||||
encryptedKey: { type: number; body: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!);
|
||||
if (!senderPublicKey) {
|
||||
console.error("Failed to get sender public key");
|
||||
return;
|
||||
}
|
||||
if (!wrappedPayload || !sessionKeyHash) {
|
||||
console.error("Missing wrapped payload or session key hash");
|
||||
if (!encryptedKey) {
|
||||
console.error("Missing encrypted session key");
|
||||
return;
|
||||
}
|
||||
|
||||
// Unwrap the session key from the encrypted payload
|
||||
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, {
|
||||
salt: wrappedPayload.salt,
|
||||
iv2: wrappedPayload.iv2,
|
||||
wrapped: wrappedPayload.wrapped
|
||||
});
|
||||
// Decrypt the session key using Signal Protocol
|
||||
const sessionKey = await decryptCallSessionKey(fromUserId, encryptedKey);
|
||||
|
||||
// Use the unwrapped session key directly (both sides should have the same key)
|
||||
await setSessionKey(fromUserId, unwrappedSessionKey);
|
||||
// Use the decrypted session key for media encryption
|
||||
await setSessionKey(fromUserId, sessionKey);
|
||||
} catch (e) {
|
||||
console.error("Failed to unwrap session key:", e);
|
||||
console.error("Failed to decrypt session key:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-7
@@ -505,7 +505,7 @@ export interface CallEndData {
|
||||
}
|
||||
|
||||
export interface CallSessionKeyData {
|
||||
wrappedSessionKey?: WrappedSessionKeyPayload;
|
||||
encryptedSessionKey: { type: number; body: string };
|
||||
}
|
||||
|
||||
export interface CallVideoToggleData {
|
||||
@@ -526,12 +526,6 @@ export interface CallScreenShareToggleMessageData {
|
||||
data: CallScreenShareToggleData;
|
||||
}
|
||||
|
||||
export interface WrappedSessionKeyPayload {
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrapped: string;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleMessage extends CallSignalingMessage {
|
||||
type: "call_video_toggle";
|
||||
data: CallVideoToggleData;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { request } from "./websocket";
|
||||
import { send } from "./websocket";
|
||||
import type {
|
||||
TypingWebSocketMessage,
|
||||
StopTypingWebSocketMessage,
|
||||
@@ -39,21 +39,18 @@ export class TypingManager {
|
||||
async sendTyping(): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: TypingRequest = {
|
||||
type: "typing",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
const message: TypingRequest = {
|
||||
type: "typing",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.scheduleStopTyping("public");
|
||||
} catch (error) {
|
||||
console.error("Failed to send typing indicator:", error);
|
||||
}
|
||||
// Fire-and-forget - don't wait for response
|
||||
send(message);
|
||||
this.scheduleStopTyping("public");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,21 +59,18 @@ export class TypingManager {
|
||||
async sendStopTyping(): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: StopTypingRequest = {
|
||||
type: "stopTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
const message: StopTypingRequest = {
|
||||
type: "stopTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.clearStopTypingTimeout("public");
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop typing indicator:", error);
|
||||
}
|
||||
// Fire-and-forget - don't wait for response
|
||||
send(message);
|
||||
this.clearStopTypingTimeout("public");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,23 +79,20 @@ export class TypingManager {
|
||||
async sendDmTyping(recipientId: number): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: DmTypingRequest = {
|
||||
type: "dmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
const message: DmTypingRequest = {
|
||||
type: "dmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.scheduleStopDmTyping(recipientId);
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM typing indicator:", error);
|
||||
}
|
||||
// Fire-and-forget - don't wait for response
|
||||
send(message);
|
||||
this.scheduleStopDmTyping(recipientId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,23 +101,20 @@ export class TypingManager {
|
||||
async sendStopDmTyping(recipientId: number): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: StopDmTypingRequest = {
|
||||
type: "stopDmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
const message: StopDmTypingRequest = {
|
||||
type: "stopDmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop DM typing indicator:", error);
|
||||
}
|
||||
// Fire-and-forget - don't wait for response
|
||||
send(message);
|
||||
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { openDB, type IDBPDatabase } from "idb";
|
||||
import type { WebSocketCredentials, WebSocketMessage } from "./types";
|
||||
|
||||
interface UpdateMessage<T = any> {
|
||||
type: string;
|
||||
@@ -72,28 +71,18 @@ export async function setLastSequence(seq: number): Promise<void> {
|
||||
* Process a batched updates message
|
||||
* @param message - The batched updates message from the server
|
||||
* @param handler - Function to handle individual updates
|
||||
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
|
||||
*/
|
||||
export async function processBatchedUpdates(
|
||||
message: BatchedUpdatesMessage,
|
||||
handler: (update: UpdateMessage) => void,
|
||||
requestMissedFn?: (lastSeq: number) => Promise<void>
|
||||
handler: (update: UpdateMessage) => void
|
||||
): Promise<void> {
|
||||
const { seq, updates } = message;
|
||||
const lastSeq = await getLastSequence();
|
||||
|
||||
// Check for gap
|
||||
// Log gap for debugging, but don't try to recover (getUpdates doesn't work properly)
|
||||
if (seq !== lastSeq + 1 && lastSeq > 0) {
|
||||
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`);
|
||||
|
||||
// Request missing updates if function provided
|
||||
if (requestMissedFn) {
|
||||
try {
|
||||
await requestMissedFn(lastSeq);
|
||||
} catch (error) {
|
||||
console.error("Failed to request missed updates for gap:", error);
|
||||
}
|
||||
}
|
||||
const gapSize = seq - (lastSeq + 1);
|
||||
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq} (gap size: ${gapSize}). Skipping ${gapSize} updates.`);
|
||||
}
|
||||
|
||||
// Process all updates in the batch
|
||||
@@ -104,23 +93,3 @@ export async function processBatchedUpdates(
|
||||
// Update last sequence number
|
||||
await setLastSequence(seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request missed updates from the server
|
||||
* @param lastSeq - The last sequence number we received
|
||||
* @param requestFn - Function to send the request to the server
|
||||
* @param credentials - Optional WebSocket credentials for authentication
|
||||
*/
|
||||
export async function requestMissedUpdates(
|
||||
lastSeq: number,
|
||||
requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise<void>,
|
||||
credentials?: WebSocketCredentials
|
||||
): Promise<void> {
|
||||
if (lastSeq > 0) {
|
||||
await requestFn({
|
||||
type: "getUpdates",
|
||||
data: { lastSeq },
|
||||
credentials
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { CallSignalingHandler } from "./calls/signaling";
|
||||
import { onlineStatusManager } from "./onlineStatusManager";
|
||||
import { typingManager } from "./typingManager";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager";
|
||||
import { processBatchedUpdates } from "./updateManager";
|
||||
import { getAuthToken } from "@/core/api/user/auth";
|
||||
|
||||
interface HttpError extends Error {
|
||||
@@ -161,21 +161,10 @@ function setupEventHandlers(): void {
|
||||
|
||||
// Handle batched updates
|
||||
if (response.type === "updates" && "seq" in response && "updates" in response) {
|
||||
// Create function to request missed updates with credentials
|
||||
const token = getAuthToken();
|
||||
const requestMissedFn = token ? async (lastSeq: number) => {
|
||||
await requestMissedUpdates(lastSeq, async (req) => {
|
||||
await request(req);
|
||||
}, {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
});
|
||||
} : undefined;
|
||||
|
||||
await processBatchedUpdates(response as any, (update) => {
|
||||
// Route individual updates to appropriate handlers
|
||||
handleUpdate(update);
|
||||
}, requestMissedFn);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,20 +239,9 @@ function setupEventHandlers(): void {
|
||||
console.error("Failed to send ping on reconnect:", error);
|
||||
}
|
||||
|
||||
// Send last sequence number and request missed updates on reconnect
|
||||
// Wait a bit for ping to complete authentication
|
||||
await delay(100);
|
||||
|
||||
try {
|
||||
const lastSeq = await getLastSequence();
|
||||
if (lastSeq > 0) {
|
||||
await requestMissedUpdates(lastSeq, async (req) => {
|
||||
await request(req);
|
||||
}, credentials);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to request missed updates:", error);
|
||||
}
|
||||
// Note: We don't request missed updates on reconnect because getUpdates
|
||||
// doesn't properly return updates (they're sent directly via WebSocket
|
||||
// but the client can't handle them). Gaps will be logged but not recovered.
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to authenticate on reconnect:", error);
|
||||
@@ -359,6 +337,22 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a WebSocket message without waiting for a response (fire-and-forget)
|
||||
* Useful for typing indicators and other non-critical messages
|
||||
*/
|
||||
export function send<T = unknown>(payload: WebSocketMessage<T>): void {
|
||||
if (websocket.readyState !== WebSocket.OPEN) {
|
||||
console.warn("WebSocket is not open, cannot send message");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
websocket.send(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.error("Failed to send WebSocket message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Initialization
|
||||
// --------------
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { AuthContainer } from "./Auth";
|
||||
import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useState, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
|
||||
import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
import { LoginForm } from "./LoginForm";
|
||||
import { RegisterForm } from "./RegisterForm";
|
||||
import type { Variants, Transition } from "motion/react";
|
||||
import styles from "./auth.module.scss";
|
||||
import { useUserStore } from "@/state/user";
|
||||
|
||||
const slideVariants: Variants = {
|
||||
enter: (direction: number) => ({
|
||||
@@ -37,9 +38,9 @@ const slideTransition: Transition = {
|
||||
export default function AuthPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user } = useUserStore();
|
||||
|
||||
const [direction, setDirection] = useState(0);
|
||||
const prevMode = useRef(searchParams.get("mode") || "login");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -49,12 +50,33 @@ export default function AuthPage() {
|
||||
const currentMode = searchParams.get("mode") || "login";
|
||||
const enteringElementRef = useRef<"login" | "register" | null>(null);
|
||||
const [effectActivated, setEffectActivated] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (prevMode.current !== currentMode) {
|
||||
setDirection(currentMode === "register" ? 1 : -1);
|
||||
const previousMode = prevMode.current;
|
||||
|
||||
// Measure the exiting form's height BEFORE changing anything
|
||||
// This works whether it's relative or absolute
|
||||
const exitingComponent = previousMode === "login" ? loginFormRef.current : registerFormRef.current;
|
||||
let measuredHeight: number | null = null;
|
||||
if (exitingComponent) {
|
||||
const height = exitingComponent.scrollHeight;
|
||||
if (height > 0) {
|
||||
measuredHeight = height;
|
||||
}
|
||||
}
|
||||
|
||||
// Update mode and direction first
|
||||
prevMode.current = currentMode;
|
||||
setDirection(currentMode === "register" ? 1 : -1);
|
||||
enteringElementRef.current = currentMode as "login" | "register";
|
||||
|
||||
// Set height and transition state together
|
||||
if (measuredHeight !== null) {
|
||||
setContainerHeight(measuredHeight);
|
||||
}
|
||||
setIsTransitioning(true);
|
||||
}
|
||||
}, [currentMode]);
|
||||
|
||||
@@ -90,6 +112,12 @@ export default function AuthPage() {
|
||||
}
|
||||
};
|
||||
}, [currentMode]);
|
||||
|
||||
// Now we can do conditional returns after all hooks are called
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
if (user.authToken && user.currentUser) {
|
||||
return <Navigate to="/chat" replace />;
|
||||
}
|
||||
|
||||
function switchMode(newMode: "login" | "register") {
|
||||
navigate(`/auth?mode=${newMode}`, { replace: true });
|
||||
@@ -105,6 +133,7 @@ export default function AuthPage() {
|
||||
return () => {
|
||||
if (currentMode === mode && enteringElementRef.current === mode) {
|
||||
enteringElementRef.current = null;
|
||||
setIsTransitioning(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -129,9 +158,6 @@ export default function AuthPage() {
|
||||
width: "100%",
|
||||
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
|
||||
transition: "height 0.3s ease"
|
||||
}}
|
||||
onAnimationStart={() => {
|
||||
|
||||
}}
|
||||
onAnimationEnd={() => {
|
||||
setContainerHeight("auto");
|
||||
@@ -151,7 +177,7 @@ export default function AuthPage() {
|
||||
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
|
||||
className={styles.formWrapper}
|
||||
style={{
|
||||
position: containerHeight === "auto" ? "relative" : "absolute"
|
||||
position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
|
||||
}}
|
||||
>
|
||||
<LoginForm onSwitchMode={() => switchMode("register")} />
|
||||
@@ -168,6 +194,9 @@ export default function AuthPage() {
|
||||
transition={slideTransition}
|
||||
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
|
||||
className={styles.formWrapper}
|
||||
style={{
|
||||
position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
|
||||
}}
|
||||
>
|
||||
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
||||
</motion.div>
|
||||
|
||||
@@ -79,7 +79,14 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
console.log("========================================");
|
||||
console.log("[LoginForm] 🚀 LOGIN FORM SUBMITTED");
|
||||
console.log("[LoginForm] Username:", username);
|
||||
console.log("[LoginForm] Has password:", !!password);
|
||||
console.log("========================================");
|
||||
|
||||
try {
|
||||
console.log("[LoginForm] Deriving auth secret...");
|
||||
const derived = await api.user.auth.deriveAuthSecret(username, password);
|
||||
const request: LoginRequest = {
|
||||
username: username,
|
||||
@@ -87,13 +94,52 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[LoginForm] Calling login API...");
|
||||
const data = await api.user.auth.login(request);
|
||||
console.log("[LoginForm] Login successful, user ID:", data.user?.id);
|
||||
|
||||
setUser(data.token, data.user);
|
||||
|
||||
try {
|
||||
console.log("[LoginForm] Ensuring keys on login...");
|
||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||
console.log("[LoginForm] Keys ensured");
|
||||
|
||||
// Initialize Signal Protocol after keys are set up (non-blocking)
|
||||
if (data.user?.id) {
|
||||
console.log("[LoginForm] ✅ User ID exists, scheduling Signal Protocol initialization");
|
||||
// Run Signal Protocol initialization in background to avoid blocking navigation
|
||||
// Use setTimeout to ensure it runs even if navigation happens
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log("[LoginForm] 🚀 Starting Signal Protocol initialization...");
|
||||
const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
|
||||
await initializeSignalProtocol({
|
||||
userId: data.user!.id.toString(),
|
||||
password,
|
||||
token: data.token,
|
||||
restoreSessions: true,
|
||||
uploadSessions: true
|
||||
});
|
||||
console.log("[LoginForm] ✅ Signal Protocol initialization completed");
|
||||
} catch (e) {
|
||||
console.error("[LoginForm] ❌ Signal Protocol initialization failed:", e);
|
||||
console.error("[LoginForm] Error details:", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
stack: e instanceof Error ? e.stack : undefined
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
console.log("[LoginForm] ✅ Signal Protocol initialization scheduled");
|
||||
} else {
|
||||
console.warn("[LoginForm] ⚠️ No user ID, skipping Signal Protocol initialization");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
console.error("[LoginForm] ❌ Key setup failed:", e);
|
||||
console.error("[LoginForm] Error details:", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
stack: e instanceof Error ? e.stack : undefined
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure WebSocket is connected and authenticated
|
||||
|
||||
@@ -114,12 +114,31 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
|
||||
confirm_password: derived
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.user.auth.register(request);
|
||||
setUser(data.token, data.user);
|
||||
try {
|
||||
const data = await api.user.auth.register(request);
|
||||
|
||||
setUser(data.token, data.user);
|
||||
|
||||
try {
|
||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||
|
||||
// Initialize Signal Protocol after keys are set up (non-blocking)
|
||||
if (data.user?.id) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
|
||||
await initializeSignalProtocol({
|
||||
userId: data.user!.id.toString(),
|
||||
password,
|
||||
token: data.token,
|
||||
restoreSessions: false,
|
||||
uploadSessions: true
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[RegisterForm] Signal Protocol initialization failed:", e);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { useChatStore } from "@/state/chat";
|
||||
import api from "@/core/api";
|
||||
import { decryptDm, sendDMViaWebSocket } from "@/core/api/dm";
|
||||
import type { ConversationResponse } from "@/core/api/chats/dm";
|
||||
import type { User, Message, DmEncryptedJSON } from "@/core/types";
|
||||
import { websocket } from "@/core/websocket";
|
||||
@@ -51,6 +52,10 @@ export function useDM() {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
// Wait for session restoration to complete (if in progress)
|
||||
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
|
||||
await waitForSessionRestore();
|
||||
|
||||
// Get public key
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
|
||||
if (!publicKey) return;
|
||||
@@ -62,11 +67,22 @@ export function useDM() {
|
||||
// Find last message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let lastPlaintext: string | null = null;
|
||||
|
||||
const isAuthor = lastMessage.senderId === user.currentUser?.id;
|
||||
try {
|
||||
lastPlaintext = (JSON.parse(await api.chats.dm.decrypt(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
|
||||
if (isAuthor) {
|
||||
// For our own messages, fetch plaintexts from server (encrypted at rest)
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(dmUser.id);
|
||||
const cached = plaintexts.get(lastMessage.id);
|
||||
if (cached) {
|
||||
lastPlaintext = (JSON.parse(cached) as DmEncryptedJSON).data.content;
|
||||
}
|
||||
} else {
|
||||
// Incoming message - decrypt via Signal
|
||||
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, lastMessage.senderId)) as DmEncryptedJSON).data.content;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message:", error);
|
||||
console.error("Failed to get last message preview:", error);
|
||||
}
|
||||
|
||||
// Calculate unread count
|
||||
@@ -101,6 +117,10 @@ export function useDM() {
|
||||
usersLoadedRef.current = true;
|
||||
setIsLoadingUsers(true);
|
||||
try {
|
||||
// Wait for session restoration to complete (if in progress)
|
||||
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
|
||||
await waitForSessionRestore();
|
||||
|
||||
const conversations = await api.chats.dm.conversations(user.authToken);
|
||||
|
||||
// Process conversations and decrypt last messages
|
||||
@@ -110,20 +130,30 @@ export function useDM() {
|
||||
|
||||
if (conv.lastMessage) {
|
||||
try {
|
||||
// Get the public key for the other user
|
||||
const otherUserId = conv.lastMessage.senderId === user.currentUser?.id
|
||||
? conv.lastMessage.recipientId
|
||||
: conv.lastMessage.senderId;
|
||||
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
// Decrypt the last message
|
||||
const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!);
|
||||
const isAuthor = conv.lastMessage.senderId === user.currentUser?.id;
|
||||
const otherUserId = conv.user.id; // the other party in the conversation
|
||||
if (isAuthor) {
|
||||
// Fetch plaintext of our own last message from server
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
|
||||
const cached = plaintexts.get(conv.lastMessage.id);
|
||||
if (cached) {
|
||||
const data = JSON.parse(cached) as DmEncryptedJSON;
|
||||
lastMessageContent = formatDMMessageContent(data.data.content, conv.lastMessage.senderId, user.currentUser!.id);
|
||||
}
|
||||
} else {
|
||||
// Incoming message - decrypt
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const decryptedJson = await decryptDm(conv.lastMessage, conv.lastMessage.senderId);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser!.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message for user", conv.user.id, error);
|
||||
// Silently fail for last message decryption - it's not critical
|
||||
// The message will just show "No messages" instead
|
||||
console.debug("Failed to decrypt last message for user", conv.user.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,19 +182,43 @@ export function useDM() {
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load DM history for active conversation
|
||||
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
|
||||
const loadDMHistory = useCallback(async (userId: number) => {
|
||||
if (!user.authToken || isLoadingHistory) return;
|
||||
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
// Wait for session restoration to complete (if in progress)
|
||||
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
|
||||
await waitForSessionRestore();
|
||||
console.log(`[useDM] Session restoration complete, proceeding with message load for user ${userId}`);
|
||||
|
||||
const { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50);
|
||||
const decryptedMessages: Message[] = [];
|
||||
let maxIncomingId = 0;
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const text = await api.chats.dm.decrypt(env, publicKey);
|
||||
const isAuthor = env.senderId !== userId;
|
||||
// Check if this is a message sent by the current user
|
||||
const isAuthor = env.senderId === user.currentUser?.id;
|
||||
let text: string;
|
||||
|
||||
if (isAuthor) {
|
||||
// For sent messages, we can't decrypt them in Signal Protocol
|
||||
// Try to get the plaintext from the server (encrypted)
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(userId);
|
||||
const cached = plaintexts.get(env.id);
|
||||
if (cached) {
|
||||
text = cached;
|
||||
} else {
|
||||
// Not on server - skip this message
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Decrypt incoming messages
|
||||
text = await decryptDm(env, env.senderId);
|
||||
}
|
||||
|
||||
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
||||
|
||||
decryptedMessages.push({
|
||||
@@ -204,11 +258,11 @@ export function useDM() {
|
||||
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
|
||||
|
||||
// Send DM message
|
||||
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
|
||||
const sendDMMessage = useCallback(async (recipientId: number, content: string) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
await api.chats.dm.send(recipientId, publicKey, content, user.authToken);
|
||||
await sendDMViaWebSocket(recipientId, content, user.authToken);
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
@@ -234,7 +288,7 @@ export function useDM() {
|
||||
});
|
||||
|
||||
// Load conversation history
|
||||
await loadDMHistory(dmUser.id, publicKey);
|
||||
await loadDMHistory(dmUser.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to start DM conversation:", error);
|
||||
}
|
||||
@@ -254,23 +308,29 @@ export function useDM() {
|
||||
const conversations = await api.chats.dm.conversations(user.authToken);
|
||||
const userConversation = conversations.find(conv => conv.user.id === userId);
|
||||
|
||||
if (userConversation) {
|
||||
if (userConversation) {
|
||||
let lastMessageContent: string | undefined = undefined;
|
||||
|
||||
if (userConversation.lastMessage) {
|
||||
try {
|
||||
// Get the public key for the other user
|
||||
const otherUserId = userConversation.lastMessage.senderId === user.currentUser?.id
|
||||
? userConversation.lastMessage.recipientId
|
||||
: userConversation.lastMessage.senderId;
|
||||
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
// Decrypt the last message
|
||||
const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!);
|
||||
}
|
||||
const isAuthor = userConversation.lastMessage.senderId === user.currentUser?.id;
|
||||
const otherUserId = userId;
|
||||
if (isAuthor) {
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
|
||||
const cached = plaintexts.get(userConversation.lastMessage.id);
|
||||
if (cached) {
|
||||
const data = JSON.parse(cached) as DmEncryptedJSON;
|
||||
lastMessageContent = formatDMMessageContent(data.data.content, userConversation.lastMessage.senderId, user.currentUser!.id);
|
||||
}
|
||||
} else {
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const decryptedJson = await decryptDm(userConversation.lastMessage, userConversation.lastMessage.senderId);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser!.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message for user", userId, error);
|
||||
}
|
||||
@@ -313,23 +373,33 @@ export function useDM() {
|
||||
return;
|
||||
}
|
||||
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
|
||||
|
||||
|
||||
// Update unread count and last message preview
|
||||
try {
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
const messageContent = decryptedData.data.content;
|
||||
let messageContent: string | null = null;
|
||||
if (senderId === user.currentUser.id) {
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
|
||||
const cached = plaintexts.get(envelope.id);
|
||||
if (cached) {
|
||||
messageContent = (JSON.parse(cached) as DmEncryptedJSON).data.content;
|
||||
}
|
||||
} else {
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const decryptedJson = await decryptDm(envelope, senderId);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
messageContent = decryptedData.data.content;
|
||||
}
|
||||
}
|
||||
if (messageContent !== null) {
|
||||
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
||||
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === otherUserId
|
||||
? {
|
||||
...u,
|
||||
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
|
||||
lastMessage: formattedMessage,
|
||||
publicKey
|
||||
lastMessage: formattedMessage
|
||||
}
|
||||
: u
|
||||
));
|
||||
@@ -346,18 +416,29 @@ export function useDM() {
|
||||
}
|
||||
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
|
||||
try {
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
const messageContent = decryptedData.data.content;
|
||||
let messageContent: string | null = null;
|
||||
if (senderId === user.currentUser.id) {
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
|
||||
const cached = plaintexts.get(id);
|
||||
if (cached) {
|
||||
messageContent = (JSON.parse(cached) as DmEncryptedJSON).data.content;
|
||||
}
|
||||
} else {
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const decryptedJson = await decryptDm(envelope, senderId);
|
||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||
messageContent = decryptedData.data.content;
|
||||
}
|
||||
}
|
||||
if (messageContent !== null) {
|
||||
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === otherUserId
|
||||
? {
|
||||
...u,
|
||||
lastMessage: formattedMessage,
|
||||
publicKey
|
||||
lastMessage: formattedMessage
|
||||
}
|
||||
: u
|
||||
));
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { Message, User } from "@/core/types";
|
||||
import { MessagePanel } from "../ui/right/panels/MessagePanel";
|
||||
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
|
||||
import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel";
|
||||
import type { ChatState, ChatTabs, ActiveDM } from "./types";
|
||||
import { useUserStore } from "@/state/user";
|
||||
|
||||
export interface ChatStateSlice {
|
||||
chat: ChatState;
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
|
||||
removeMessage: (messageId: number) => void;
|
||||
setCurrentChat: (chat: string) => void;
|
||||
setActiveTab: (tab: ChatTabs) => void;
|
||||
setDmUsers: (users: User[]) => void;
|
||||
setActiveDm: (dm: ActiveDM | null) => void;
|
||||
clearMessages: () => void;
|
||||
setActivePanel: (panel: MessagePanel | null) => void;
|
||||
setPendingPanel: (panel: MessagePanel | null) => void;
|
||||
applyPendingPanel: () => void;
|
||||
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const createChatState: StateCreator<
|
||||
ChatStateSlice & { user: { authToken: string | null } },
|
||||
[],
|
||||
[],
|
||||
ChatStateSlice
|
||||
> = (set, get) => ({
|
||||
chat: {
|
||||
messages: [],
|
||||
currentChat: "Общий чат",
|
||||
activeTab: "chats",
|
||||
dmUsers: [],
|
||||
activeDm: null,
|
||||
isSwitching: false,
|
||||
setIsSwitching: (value: boolean) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
isSwitching: value
|
||||
}
|
||||
})),
|
||||
activePanel: null,
|
||||
publicChatPanel: null,
|
||||
dmPanel: null,
|
||||
pendingPanel: null,
|
||||
profileDialog: null,
|
||||
call: {
|
||||
isActive: false,
|
||||
status: "ended",
|
||||
startTime: null,
|
||||
isMuted: false,
|
||||
remoteUserId: null,
|
||||
remoteUsername: null,
|
||||
isInitiator: false,
|
||||
isMinimized: false,
|
||||
sessionKeyHash: null,
|
||||
encryptionEmojis: [],
|
||||
isVideoEnabled: false,
|
||||
isRemoteVideoEnabled: false,
|
||||
isSharingScreen: false,
|
||||
isRemoteScreenSharing: false
|
||||
},
|
||||
onlineStatuses: new Map(),
|
||||
typingUsers: new Map(),
|
||||
dmTypingUsers: new Map()
|
||||
},
|
||||
addMessage: (message: Message) => set((state) => {
|
||||
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
|
||||
if (messageExists) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: [...state.chat.messages, message]
|
||||
}
|
||||
};
|
||||
}),
|
||||
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: state.chat.messages.map(msg =>
|
||||
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
|
||||
)
|
||||
}
|
||||
})),
|
||||
removeMessage: (messageId: number) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: state.chat.messages.filter(msg => msg.id !== messageId)
|
||||
}
|
||||
})),
|
||||
clearMessages: () => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: []
|
||||
}
|
||||
})),
|
||||
setCurrentChat: (chat: string) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
currentChat: chat
|
||||
}
|
||||
})),
|
||||
setActiveTab: (tab: ChatTabs) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activeTab: tab
|
||||
}
|
||||
})),
|
||||
setDmUsers: (users: User[]) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
dmUsers: users
|
||||
}
|
||||
})),
|
||||
setActiveDm: (dm: ActiveDM | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activeDm: dm
|
||||
}
|
||||
})),
|
||||
setActivePanel: (panel: MessagePanel | null) => {
|
||||
const state = get();
|
||||
if (state.chat.activePanel && state.chat.activePanel !== panel) {
|
||||
state.chat.activePanel.deactivate();
|
||||
}
|
||||
return set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: panel
|
||||
}
|
||||
}));
|
||||
},
|
||||
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: panel
|
||||
}
|
||||
})),
|
||||
applyPendingPanel: () => {
|
||||
const state = get();
|
||||
if (state.chat.activePanel) {
|
||||
state.chat.activePanel.deactivate();
|
||||
}
|
||||
return set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: state.chat.pendingPanel || state.chat.activePanel,
|
||||
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
|
||||
? (state.chat.pendingPanel as PublicChatPanel)
|
||||
: state.chat.publicChatPanel,
|
||||
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
|
||||
? (state.chat.pendingPanel as DMPanel)
|
||||
: state.chat.dmPanel,
|
||||
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
|
||||
pendingPanel: null
|
||||
}
|
||||
}));
|
||||
},
|
||||
switchToPublicChat: async (chatName: string) => {
|
||||
const { chat } = get();
|
||||
const { user } = useUserStore();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
let publicChatPanel = chat.publicChatPanel;
|
||||
if (!publicChatPanel) {
|
||||
publicChatPanel = new PublicChatPanel(chatName, user);
|
||||
} else {
|
||||
publicChatPanel.setChatName(chatName);
|
||||
publicChatPanel.setAuthToken(user.authToken);
|
||||
publicChatPanel.clearMessages();
|
||||
}
|
||||
|
||||
await publicChatPanel.activate();
|
||||
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: publicChatPanel,
|
||||
activeTab: "chats"
|
||||
}
|
||||
}));
|
||||
},
|
||||
switchToDM: async (dmData: DMPanelData) => {
|
||||
const { chat } = get();
|
||||
const { user } = useUserStore();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
let dmPanel = chat.dmPanel;
|
||||
if (!dmPanel) {
|
||||
dmPanel = new DMPanel(user);
|
||||
} else {
|
||||
dmPanel.setAuthToken(user.authToken);
|
||||
dmPanel.clearMessages();
|
||||
}
|
||||
|
||||
dmPanel.setDMData(dmData);
|
||||
|
||||
await dmPanel.activate();
|
||||
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: dmPanel,
|
||||
activeDm: {
|
||||
userId: dmData.userId,
|
||||
username: dmData.username,
|
||||
publicKey: dmData.publicKey
|
||||
},
|
||||
activeTab: "chats"
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { Message, User } from "@/core/types";
|
||||
import { MessagePanel } from "../ui/right/panels/MessagePanel";
|
||||
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
|
||||
import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel";
|
||||
import type { ChatState, ChatTabs, ActiveDM } from "./types";
|
||||
import { useUserStore } from "@/state/user";
|
||||
|
||||
export interface ChatStateSlice {
|
||||
chat: ChatState;
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
|
||||
removeMessage: (messageId: number) => void;
|
||||
setCurrentChat: (chat: string) => void;
|
||||
setActiveTab: (tab: ChatTabs) => void;
|
||||
setDmUsers: (users: User[]) => void;
|
||||
setActiveDm: (dm: ActiveDM | null) => void;
|
||||
clearMessages: () => void;
|
||||
setActivePanel: (panel: MessagePanel | null) => void;
|
||||
setPendingPanel: (panel: MessagePanel | null) => void;
|
||||
applyPendingPanel: () => void;
|
||||
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const createChatState: StateCreator<
|
||||
ChatStateSlice & { user: { authToken: string | null } },
|
||||
[],
|
||||
[],
|
||||
ChatStateSlice
|
||||
> = (set, get) => ({
|
||||
chat: {
|
||||
messages: [],
|
||||
currentChat: "Общий чат",
|
||||
activeTab: "chats",
|
||||
dmUsers: [],
|
||||
activeDm: null,
|
||||
isSwitching: false,
|
||||
setIsSwitching: (value: boolean) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
isSwitching: value
|
||||
}
|
||||
})),
|
||||
activePanel: null,
|
||||
publicChatPanel: null,
|
||||
dmPanel: null,
|
||||
pendingPanel: null,
|
||||
profileDialog: null,
|
||||
call: {
|
||||
isActive: false,
|
||||
status: "ended",
|
||||
startTime: null,
|
||||
isMuted: false,
|
||||
remoteUserId: null,
|
||||
remoteUsername: null,
|
||||
isInitiator: false,
|
||||
isMinimized: false,
|
||||
sessionKeyHash: null,
|
||||
encryptionEmojis: [],
|
||||
isVideoEnabled: false,
|
||||
isRemoteVideoEnabled: false,
|
||||
isSharingScreen: false,
|
||||
isRemoteScreenSharing: false
|
||||
},
|
||||
onlineStatuses: new Map(),
|
||||
typingUsers: new Map(),
|
||||
dmTypingUsers: new Map()
|
||||
},
|
||||
addMessage: (message: Message) => set((state) => {
|
||||
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
|
||||
if (messageExists) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: [...state.chat.messages, message]
|
||||
}
|
||||
};
|
||||
}),
|
||||
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: state.chat.messages.map(msg =>
|
||||
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
|
||||
)
|
||||
}
|
||||
})),
|
||||
removeMessage: (messageId: number) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: state.chat.messages.filter(msg => msg.id !== messageId)
|
||||
}
|
||||
})),
|
||||
clearMessages: () => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: []
|
||||
}
|
||||
})),
|
||||
setCurrentChat: (chat: string) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
currentChat: chat
|
||||
}
|
||||
})),
|
||||
setActiveTab: (tab: ChatTabs) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activeTab: tab
|
||||
}
|
||||
})),
|
||||
setDmUsers: (users: User[]) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
dmUsers: users
|
||||
}
|
||||
})),
|
||||
setActiveDm: (dm: ActiveDM | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activeDm: dm
|
||||
}
|
||||
})),
|
||||
setActivePanel: (panel: MessagePanel | null) => {
|
||||
const state = get();
|
||||
if (state.chat.activePanel && state.chat.activePanel !== panel) {
|
||||
state.chat.activePanel.deactivate();
|
||||
}
|
||||
return set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: panel
|
||||
}
|
||||
}));
|
||||
},
|
||||
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: panel
|
||||
}
|
||||
})),
|
||||
applyPendingPanel: () => {
|
||||
const state = get();
|
||||
if (state.chat.activePanel) {
|
||||
state.chat.activePanel.deactivate();
|
||||
}
|
||||
return set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: state.chat.pendingPanel || state.chat.activePanel,
|
||||
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
|
||||
? (state.chat.pendingPanel as PublicChatPanel)
|
||||
: state.chat.publicChatPanel,
|
||||
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
|
||||
? (state.chat.pendingPanel as DMPanel)
|
||||
: state.chat.dmPanel,
|
||||
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
|
||||
pendingPanel: null
|
||||
}
|
||||
}));
|
||||
},
|
||||
switchToPublicChat: async (chatName: string) => {
|
||||
const { chat } = get();
|
||||
const { user } = useUserStore();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
let publicChatPanel = chat.publicChatPanel;
|
||||
if (!publicChatPanel) {
|
||||
publicChatPanel = new PublicChatPanel(chatName, user);
|
||||
} else {
|
||||
publicChatPanel.setChatName(chatName);
|
||||
publicChatPanel.setAuthToken(user.authToken);
|
||||
publicChatPanel.clearMessages();
|
||||
}
|
||||
|
||||
await publicChatPanel.activate();
|
||||
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: publicChatPanel,
|
||||
activeTab: "chats"
|
||||
}
|
||||
}));
|
||||
},
|
||||
switchToDM: async (dmData: DMPanelData) => {
|
||||
const { chat } = get();
|
||||
const { user } = useUserStore();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
let dmPanel = chat.dmPanel;
|
||||
if (!dmPanel) {
|
||||
dmPanel = new DMPanel(user);
|
||||
} else {
|
||||
dmPanel.setAuthToken(user.authToken);
|
||||
dmPanel.clearMessages();
|
||||
}
|
||||
|
||||
dmPanel.setDMData(dmData);
|
||||
|
||||
await dmPanel.activate();
|
||||
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: dmPanel,
|
||||
activeDm: {
|
||||
userId: dmData.userId,
|
||||
username: dmData.username,
|
||||
publicKey: dmData.publicKey
|
||||
},
|
||||
activeTab: "chats"
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Message, User } from "@/core/types";
|
||||
import { MessagePanel } from "../ui/right/panels/MessagePanel";
|
||||
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
|
||||
import { DMPanel } from "../ui/right/panels/DMPanel";
|
||||
|
||||
export type ChatTabs = "chats" | "channels" | "contacts";
|
||||
|
||||
export type CallStatus = "calling" | "connecting" | "active" | "ended";
|
||||
|
||||
export interface ProfileDialogData {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
profilePicture?: string;
|
||||
bio?: string;
|
||||
memberSince?: string;
|
||||
online?: boolean;
|
||||
isOwnProfile: boolean;
|
||||
verified?: boolean;
|
||||
suspended?: boolean;
|
||||
suspension_reason?: string | null;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
export interface ActiveDM {
|
||||
userId: number;
|
||||
username: string;
|
||||
publicKey: string | null;
|
||||
}
|
||||
|
||||
export interface CallState {
|
||||
isActive: boolean;
|
||||
status: CallStatus;
|
||||
startTime: number | null;
|
||||
isMuted: boolean;
|
||||
remoteUserId: number | null;
|
||||
remoteUsername: string | null;
|
||||
isInitiator: boolean;
|
||||
isMinimized: boolean;
|
||||
sessionKeyHash: string | null;
|
||||
encryptionEmojis: string[];
|
||||
isVideoEnabled: boolean;
|
||||
isRemoteVideoEnabled: boolean;
|
||||
isSharingScreen: boolean;
|
||||
isRemoteScreenSharing: boolean;
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
messages: Message[];
|
||||
currentChat: string;
|
||||
activeTab: ChatTabs;
|
||||
dmUsers: User[];
|
||||
activeDm: ActiveDM | null;
|
||||
isSwitching: boolean;
|
||||
setIsSwitching: (value: boolean) => void;
|
||||
activePanel: MessagePanel | null;
|
||||
publicChatPanel: PublicChatPanel | null;
|
||||
dmPanel: DMPanel | null;
|
||||
pendingPanel?: MessagePanel | null;
|
||||
call: CallState;
|
||||
profileDialog: ProfileDialogData | null;
|
||||
onlineStatuses: Map<number, {online: boolean, lastSeen: string}>;
|
||||
typingUsers: Map<number, string>; // userId -> username
|
||||
dmTypingUsers: Map<number, boolean>;
|
||||
}
|
||||
|
||||
export interface UserState {
|
||||
currentUser: User | null;
|
||||
authToken: string | null;
|
||||
isSuspended: boolean;
|
||||
suspensionReason: string | null;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import { parse } from "marked";
|
||||
import { escape as escapeHtml } from "he";
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import api from "@/core/api";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { useProfileStore } from "@/state/profile";
|
||||
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||
import { removePadding } from "@/utils/crypto/obfuscation";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -211,7 +213,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
}, [message.files, isDm, decryptedFiles]);
|
||||
|
||||
async function decryptFile(file: Attachment): Promise<string | null> {
|
||||
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null;
|
||||
if (!file.encrypted || !isDm || !user.authToken || !dmEnvelope || !user.currentUser?.id) return null;
|
||||
|
||||
// Check if already decrypted
|
||||
if (decryptedFiles.has(file.path)) {
|
||||
@@ -219,7 +221,6 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
}
|
||||
|
||||
try {
|
||||
// no-op decrypt indicator removed from UI
|
||||
// Fetch encrypted file
|
||||
const response = await fetch(file.path, {
|
||||
headers: api.user.auth.getAuthHeaders(user.authToken!)
|
||||
@@ -228,19 +229,36 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
|
||||
const encryptedData = await response.arrayBuffer();
|
||||
|
||||
// Get current user's keys
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Derive shared secret with the recipient's public key
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
|
||||
|
||||
// Derive wrapping key using the salt from the DM envelope
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Unwrap the message key
|
||||
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
|
||||
// Decrypt the master key using Signal Protocol
|
||||
const signalService = new SignalProtocolService(user.currentUser.id.toString());
|
||||
const senderId = dmEnvelope.senderId;
|
||||
|
||||
// Remove padding from wrappedMk (backward compatible)
|
||||
let wrappedMkStr: string;
|
||||
try {
|
||||
wrappedMkStr = removePadding(dmEnvelope.wrappedMk);
|
||||
} catch {
|
||||
// If padding removal fails, assume it's an old message without padding
|
||||
wrappedMkStr = dmEnvelope.wrappedMk;
|
||||
}
|
||||
|
||||
// Parse wrappedMk - it's a JSON string containing Signal Protocol encrypted data
|
||||
let mk: Uint8Array;
|
||||
try {
|
||||
const encryptedMk = JSON.parse(wrappedMkStr);
|
||||
if (encryptedMk.type && encryptedMk.body) {
|
||||
// Signal Protocol encrypted
|
||||
const mkBase64 = await signalService.decryptMessage(senderId, encryptedMk);
|
||||
mk = new Uint8Array(
|
||||
atob(mkBase64).split("").map(c => c.charCodeAt(0))
|
||||
);
|
||||
} else {
|
||||
throw new Error("Invalid Signal Protocol message format");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt master key with Signal Protocol:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Decrypt the file using the message key
|
||||
const iv = new Uint8Array(encryptedData, 0, 12);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import api from "@/core/api";
|
||||
import { decryptDm, sendDMViaWebSocket, sendDmWithFiles } from "@/core/api/dm";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||
import type { UserState, ProfileDialogData } from "@/state/types";
|
||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { typingManager } from "@/core/typingManager";
|
||||
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
@@ -17,11 +19,16 @@ export interface DMPanelData {
|
||||
export class DMPanel extends MessagePanel {
|
||||
public dmData: DMPanelData | null = null;
|
||||
private messagesLoaded: boolean = false;
|
||||
private signalService: SignalProtocolService | null = null;
|
||||
|
||||
constructor(
|
||||
user: UserState
|
||||
) {
|
||||
super("dm", user);
|
||||
// Initialize Signal Protocol service if user is available
|
||||
if (user.currentUser?.id) {
|
||||
this.signalService = new SignalProtocolService(user.currentUser.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
@@ -52,10 +59,41 @@ export class DMPanel extends MessagePanel {
|
||||
clearMessages(): void {
|
||||
super.clearMessages();
|
||||
this.messagesLoaded = false;
|
||||
this.processedMessageIds.clear();
|
||||
this.failedDecryptionIds.clear();
|
||||
}
|
||||
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||
const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey);
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[], plaintextOverride?: string) {
|
||||
// Check if this is a message sent by the current user
|
||||
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
|
||||
|
||||
let plaintext: string;
|
||||
if (isSentByUs) {
|
||||
// Can't decrypt our own sent messages in Signal Protocol
|
||||
// The plaintext should be passed in from loadMessages (fetched from server)
|
||||
if (plaintextOverride) {
|
||||
plaintext = plaintextOverride;
|
||||
} else {
|
||||
// Try to fetch from server as fallback
|
||||
try {
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData!.userId);
|
||||
const cached = plaintexts.get(env.id);
|
||||
if (cached) {
|
||||
plaintext = cached;
|
||||
} else {
|
||||
// Not on server - skip this message
|
||||
throw new Error("Cannot decrypt own sent message - plaintext not available on server");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Cannot decrypt own sent message - plaintext must be fetched from server first");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Decrypt incoming messages
|
||||
plaintext = await decryptDm(env, env.senderId);
|
||||
}
|
||||
|
||||
const username = formatDMUsername(
|
||||
env.senderId,
|
||||
env.recipientId,
|
||||
@@ -103,6 +141,35 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
this.setLoading(true);
|
||||
try {
|
||||
// Wait for session restoration to complete (if in progress)
|
||||
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
|
||||
await waitForSessionRestore();
|
||||
console.log(`[DMPanel] Session restoration complete, proceeding with message load for user ${this.dmData.userId}`);
|
||||
|
||||
// Ensure Signal Protocol session is established before fetching messages
|
||||
if (!this.signalService && this.currentUser.currentUser?.id) {
|
||||
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
|
||||
}
|
||||
if (this.signalService) {
|
||||
const hasSession = await this.signalService.hasSession(this.dmData.userId);
|
||||
if (!hasSession) {
|
||||
try {
|
||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
|
||||
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
|
||||
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during history load.`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during history load:`, error);
|
||||
// Continue loading history, but decryption will likely fail for new messages
|
||||
}
|
||||
} else {
|
||||
console.log(`[DMPanel] Signal Protocol session exists for user ${this.dmData.userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch encrypted plaintexts from server for sent messages
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
|
||||
|
||||
const limit = this.calculateMessageLimit();
|
||||
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
|
||||
const decryptedMessages: Message[] = [];
|
||||
@@ -110,19 +177,82 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||
// Mark as processed to prevent duplicates
|
||||
if (env.id) {
|
||||
this.processedMessageIds.add(env.id);
|
||||
}
|
||||
|
||||
// For sent messages, use plaintext from server
|
||||
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
|
||||
let dmMsg: Message;
|
||||
if (isSentByUs) {
|
||||
const cachedPlaintext = plaintexts.get(env.id);
|
||||
if (!cachedPlaintext) {
|
||||
// Not on server - skip this message
|
||||
continue;
|
||||
}
|
||||
// Parse the plaintext as if it came from parseTextPayload
|
||||
const username = formatDMUsername(
|
||||
env.senderId,
|
||||
env.recipientId,
|
||||
this.currentUser.currentUser?.id!,
|
||||
this.dmData!.username
|
||||
);
|
||||
let content = cachedPlaintext;
|
||||
let reply_to_id: number | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
|
||||
if (obj && obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
reply_to_id = Number(obj.data.reply_to_id) || undefined;
|
||||
}
|
||||
} catch {}
|
||||
dmMsg = {
|
||||
id: env.id,
|
||||
user_id: env.senderId,
|
||||
content: content,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false,
|
||||
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
|
||||
reactions: env.reactions || [],
|
||||
runtimeData: {
|
||||
dmEnvelope: env
|
||||
}
|
||||
};
|
||||
if (reply_to_id) {
|
||||
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
|
||||
if (referenced) dmMsg.reply_to = referenced;
|
||||
}
|
||||
} else {
|
||||
dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||
}
|
||||
decryptedMessages.push(dmMsg);
|
||||
|
||||
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
|
||||
maxIncomingId = env.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decrypting message:", error);
|
||||
// Log warning with deduplication to avoid console spam
|
||||
if (env.id && !this.failedDecryptionIds.has(env.id)) {
|
||||
this.failedDecryptionIds.add(env.id);
|
||||
console.warn(`Failed to decrypt DM ${env.id}:`, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
// Remove from processed set if decryption failed
|
||||
if (env.id) {
|
||||
this.processedMessageIds.delete(env.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.clearMessages();
|
||||
decryptedMessages.forEach(msg => this.addMessage(msg));
|
||||
// Only clear and replace if we actually decrypted something
|
||||
if (decryptedMessages.length > 0) {
|
||||
this.clearMessages();
|
||||
decryptedMessages.forEach(msg => this.addMessage(msg));
|
||||
} else {
|
||||
console.warn("[DMPanel] No messages decrypted; keeping existing messages to avoid empty state after reload.");
|
||||
}
|
||||
this.setHasMoreMessages(has_more);
|
||||
|
||||
// Update last read ID
|
||||
@@ -149,6 +279,28 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
this.setLoadingMore(true);
|
||||
try {
|
||||
// Ensure Signal Protocol session is established before fetching messages
|
||||
if (!this.signalService && this.currentUser.currentUser?.id) {
|
||||
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
|
||||
}
|
||||
if (this.signalService) {
|
||||
const hasSession = await this.signalService.hasSession(this.dmData.userId);
|
||||
if (!hasSession) {
|
||||
try {
|
||||
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
|
||||
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
|
||||
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during more history load.`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during more history load:`, error);
|
||||
// Continue loading history, but decryption will likely fail for new messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch encrypted plaintexts from server for sent messages
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
|
||||
|
||||
const limit = this.calculateMessageLimit();
|
||||
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
|
||||
this.dmData.userId,
|
||||
@@ -161,10 +313,60 @@ export class DMPanel extends MessagePanel {
|
||||
const decryptedMessages: Message[] = [];
|
||||
for (const env of newEnvelopes) {
|
||||
try {
|
||||
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||
// Mark as processed to prevent duplicates
|
||||
if (env.id) {
|
||||
this.processedMessageIds.add(env.id);
|
||||
}
|
||||
|
||||
// For sent messages, use plaintext from server
|
||||
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
|
||||
let dmMsg: Message;
|
||||
if (isSentByUs) {
|
||||
const cachedPlaintext = plaintexts.get(env.id);
|
||||
if (!cachedPlaintext) {
|
||||
// Not on server - skip this message
|
||||
continue;
|
||||
}
|
||||
// Parse the plaintext as if it came from parseTextPayload
|
||||
const username = formatDMUsername(
|
||||
env.senderId,
|
||||
env.recipientId,
|
||||
this.currentUser.currentUser?.id!,
|
||||
this.dmData!.username
|
||||
);
|
||||
let content = cachedPlaintext;
|
||||
let reply_to_id: number | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
|
||||
if (obj && obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
reply_to_id = Number(obj.data.reply_to_id) || undefined;
|
||||
}
|
||||
} catch {}
|
||||
dmMsg = {
|
||||
id: env.id,
|
||||
user_id: env.senderId,
|
||||
content: content,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false,
|
||||
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
|
||||
reactions: env.reactions || [],
|
||||
runtimeData: {
|
||||
dmEnvelope: env
|
||||
}
|
||||
};
|
||||
if (reply_to_id) {
|
||||
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
|
||||
if (referenced) dmMsg.reply_to = referenced;
|
||||
}
|
||||
} else {
|
||||
dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||
}
|
||||
decryptedMessages.push(dmMsg);
|
||||
} catch (error) {
|
||||
console.error("Error decrypting message:", error);
|
||||
// Silently skip messages that can't be decrypted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,21 +395,30 @@ export class DMPanel extends MessagePanel {
|
||||
}
|
||||
const json = JSON.stringify(payload);
|
||||
|
||||
if (files.length === 0) {
|
||||
await api.chats.dm.send(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
} else {
|
||||
await api.chats.dm.sendWithFiles(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
files,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
try {
|
||||
if (files.length === 0) {
|
||||
await sendDMViaWebSocket(
|
||||
this.dmData.userId,
|
||||
json,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
} else {
|
||||
await sendDmWithFiles(
|
||||
this.dmData.userId,
|
||||
json,
|
||||
files,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
|
||||
// Check if it's a prekey exhaustion error
|
||||
const { PrekeyExhaustedError } = await import("@/core/api/crypto/prekeys");
|
||||
if (error instanceof PrekeyExhaustedError) {
|
||||
const { alert } = await import("@/core/components/AlertDialog");
|
||||
await alert("Cannot Send Message: The recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys. This ensures maximum privacy and security.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,28 +435,102 @@ export class DMPanel extends MessagePanel {
|
||||
}
|
||||
|
||||
|
||||
// Track processed message IDs to prevent duplicates
|
||||
private processedMessageIds: Set<number> = new Set();
|
||||
private failedDecryptionIds: Set<number> = new Set(); // Track messages that failed decryption to avoid spam
|
||||
|
||||
// Handle incoming WebSocket DM messages
|
||||
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
|
||||
// Only process actual DM messages, not typing indicators or other events
|
||||
if (response.type === "dmNew" && this.dmData) {
|
||||
const envelope = response.data;
|
||||
|
||||
// Validate envelope has required fields
|
||||
if (!envelope || !envelope.ciphertext || !envelope.senderId || !envelope.id) {
|
||||
console.warn("Invalid DM envelope received, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if we've already processed this message
|
||||
if (this.processedMessageIds.has(envelope.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is for the active DM conversation
|
||||
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
|
||||
try {
|
||||
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
|
||||
|
||||
// Mark as processed before attempting decryption
|
||||
this.processedMessageIds.add(envelope.id);
|
||||
|
||||
// Check if this is a confirmation of a message we sent
|
||||
const isOurMessage = envelope.senderId !== this.dmData.userId;
|
||||
const isOurMessage = envelope.senderId === this.currentUser.currentUser?.id;
|
||||
|
||||
let dmMsg: Message;
|
||||
if (isOurMessage) {
|
||||
// This is our message being confirmed, find the temp message and replace it
|
||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||
for (const tempMsg of tempMessages) {
|
||||
if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) {
|
||||
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg);
|
||||
// For sent messages, fetch plaintext from server
|
||||
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
|
||||
const cachedPlaintext = plaintexts.get(envelope.id);
|
||||
if (cachedPlaintext) {
|
||||
// Parse the plaintext and create message
|
||||
dmMsg = await this.parseTextPayload(envelope, this.getMessages(), cachedPlaintext);
|
||||
} else {
|
||||
// Plaintext not available yet - this might be a new message confirmation
|
||||
// Try to get it from temp message content
|
||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||
let tempMsgContent: string | null = null;
|
||||
for (const tempMsg of tempMessages) {
|
||||
if (tempMsg.runtimeData?.sendingState?.retryData?.content) {
|
||||
tempMsgContent = tempMsg.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tempMsgContent) {
|
||||
dmMsg = await this.parseTextPayload(envelope, this.getMessages(), tempMsgContent);
|
||||
} else {
|
||||
// Can't display without plaintext - skip
|
||||
console.warn(`Cannot display sent message ${envelope.id} - plaintext not available`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Incoming message - decrypt normally
|
||||
dmMsg = await this.parseTextPayload(envelope, this.getMessages());
|
||||
}
|
||||
|
||||
if (isOurMessage) {
|
||||
// This is our message being confirmed, find the temp message and replace it
|
||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||
let tempMsgContent: string | null = null;
|
||||
for (const tempMsg of tempMessages) {
|
||||
if ((tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content ||
|
||||
tempMsg.content === dmMsg.content) && tempMsg.runtimeData?.sendingState?.tempId) {
|
||||
tempMsgContent = tempMsg.content; // Get plaintext from temp message
|
||||
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId, dmMsg);
|
||||
|
||||
// Upload the plaintext to server (encrypted) so we can display it in history
|
||||
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
if (tempMsgContent) {
|
||||
await uploadMessagePlaintext(this.dmData.userId, envelope.id, tempMsgContent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find a temp message, try to upload from dmMsg content
|
||||
// (this might happen if the page was reloaded)
|
||||
if (!tempMsgContent && dmMsg.content) {
|
||||
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
|
||||
await uploadMessagePlaintext(this.dmData.userId, envelope.id, dmMsg.content);
|
||||
}
|
||||
|
||||
// Add the message to the chat
|
||||
this.addMessage(dmMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Incoming message - add to chat
|
||||
this.addMessage(dmMsg);
|
||||
|
||||
this.addMessage(dmMsg);
|
||||
|
||||
@@ -254,19 +539,27 @@ export class DMPanel extends MessagePanel {
|
||||
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt incoming DM:", error);
|
||||
// Only log each failed message once to avoid console spam
|
||||
if (envelope.id && !this.failedDecryptionIds.has(envelope.id)) {
|
||||
this.failedDecryptionIds.add(envelope.id);
|
||||
console.warn(`Failed to decrypt DM ${envelope.id}:`, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
// Remove from processed set so we can retry if needed
|
||||
if (envelope.id) {
|
||||
this.processedMessageIds.delete(envelope.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.type === "dmEdited" && this.dmData) {
|
||||
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
||||
const { id, senderId, recipientId, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
||||
try {
|
||||
// Decrypt new content in-place
|
||||
const plaintext = await api.chats.dm.decrypt(
|
||||
const plaintext = await decryptDm(
|
||||
{
|
||||
id,
|
||||
senderId: 0,
|
||||
recipientId: 0,
|
||||
senderId,
|
||||
recipientId,
|
||||
iv,
|
||||
ciphertext,
|
||||
salt,
|
||||
@@ -274,7 +567,7 @@ export class DMPanel extends MessagePanel {
|
||||
wrappedMk,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
this.dmData.publicKey
|
||||
senderId
|
||||
);
|
||||
let content = plaintext;
|
||||
let files: Message["files"] | undefined = undefined;
|
||||
@@ -311,6 +604,7 @@ export class DMPanel extends MessagePanel {
|
||||
this.dmData = null;
|
||||
this.messagesLoaded = false;
|
||||
this.clearMessages();
|
||||
this.failedDecryptionIds.clear(); // Clear failed decryption tracking
|
||||
this.updateState({
|
||||
id: "dm",
|
||||
title: "Select a user",
|
||||
@@ -379,7 +673,7 @@ export class DMPanel extends MessagePanel {
|
||||
reply_to_id: msg?.reply_to?.id ?? undefined
|
||||
}
|
||||
};
|
||||
api.chats.dm.edit(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
|
||||
api.chats.dm.edit(messageId, this.dmData.userId, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
|
||||
console.error("Failed to edit DM:", e);
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user