6 Commits

123 changed files with 5291 additions and 4525 deletions
-12
View File
@@ -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
View File
@@ -576,4 +576,3 @@ backend/alembic/**
!backend/alembic/script.py.mako !backend/alembic/script.py.mako
!frontend/src/css/lib !frontend/src/css/lib
**/*.module.scss.d.ts **/*.module.scss.d.ts
.cursor/plans
+1 -2
View File
@@ -6,6 +6,5 @@
"**/.husky/_": true, "**/.husky/_": true,
"**/.venv": true, "**/.venv": true,
"**/node_modules": true "**/node_modules": true
}, }
"python.terminal.activateEnvironment": false
} }
+2 -2
View File
@@ -5,7 +5,7 @@
# this is typically a path given in POSIX (e.g. forward slashes) # this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this # format, relative to the token %(here)s which refers to the location of this
# ini file # ini file
script_location = alembic script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time # Uncomment the line below if you want the files to be prepended with date and time
@@ -84,7 +84,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only. # database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py # other means of configuring database URLs may be customized within the env.py
# file. # file.
# sqlalchemy.url is set dynamically from DATABASE_URL environment variable sqlalchemy.url = sqlite:///./data/database.db
[post_write_hooks] [post_write_hooks]
+1 -1
View File
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
# add your model's MetaData object here # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
from backend.shared.models import Base from models import Base
target_metadata = Base.metadata target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
+91 -118
View File
@@ -1,43 +1,102 @@
import asyncio import asyncio
import time import time
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import subprocess import subprocess
import sys import sys
import os import os
import httpx from routes import account, messaging, profile, push, webrtc, devices, moderation
import logging import logging
# Gateway doesn't need direct model access - it's a stateless proxy from models import User
# Gateway doesn't need constants - it's a stateless proxy from constants import OWNER_USERNAME
from backend.shared.utils import get_client_ip from utils import get_client_ip
# Gateway doesn't need database access - it's a stateless proxy from db import POOL_CONFIG, SessionLocal
from backend.logging_config import access_logger # noqa: F401 - ensure loggers configured from logging_config import access_logger # noqa: F401 - ensure loggers configured
from backend.security.audit import log_access from security.audit import log_access
from backend.security.rate_limit import limiter from security.rate_limit import limiter
from slowapi.middleware import SlowAPIMiddleware 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") logger = logging.getLogger("uvicorn.error")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Gateway is a stateless proxy - no database operations or background tasks needed # Startup - run migration in separate process to avoid logging interference
logger.info("Gateway proxy service initialized - routing to microservices") 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 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 # Инициализация FastAPI
app = FastAPI(title="FromChat", lifespan=lifespan) app = FastAPI(title="FromChat", lifespan=lifespan)
@@ -49,16 +108,6 @@ app.add_middleware(SlowAPIMiddleware)
@app.middleware("http") @app.middleware("http")
async def access_logging_middleware(request: Request, call_next): 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() start = time.perf_counter()
try: try:
response = await call_next(request) response = await call_next(request)
@@ -108,87 +157,11 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Common API endpoints - route to appropriate services (defined first for priority) # Routes
@app.api_route("/login", methods=["POST"]) app.include_router(account.router)
async def login(request: Request): app.include_router(messaging.router)
"""Login endpoint - routes to account service.""" app.include_router(profile.router)
return await _proxy_to_service("account", "login", request) app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc")
@app.api_route("/register", methods=["POST"]) app.include_router(devices.router, prefix="/devices")
async def register(request: Request): app.include_router(moderation.router)
"""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
-16
View File
@@ -5,10 +5,8 @@ from sqlalchemy.orm import Session
from utils import verify_token from utils import verify_token
from models import User, DeviceSession from models import User, DeviceSession
from db import SessionLocal from db import SessionLocal
import logging
security = HTTPBearer() security = HTTPBearer()
logger = logging.getLogger("uvicorn.error")
# Зависимость для получения сессии БД # Зависимость для получения сессии БД
def get_db(): def get_db():
@@ -25,17 +23,8 @@ def get_current_user(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> User: ) -> User:
token = credentials.credentials token = credentials.credentials
try:
payload = verify_token(token) 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: if not payload:
logger.info("get_current_user: verify_token returned empty payload")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token", detail="Invalid or expired token",
@@ -43,7 +32,6 @@ def get_current_user(
) )
user = db.query(User).filter(User.id == payload["user_id"]).first() user = db.query(User).filter(User.id == payload["user_id"]).first()
if not user: if not user:
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found", detail="User not found",
@@ -72,7 +60,6 @@ def get_current_user(
) )
if not device_session or device_session.revoked: 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session revoked or not found", detail="Session revoked or not found",
@@ -86,7 +73,6 @@ def get_current_user(
# Session expired due to inactivity - revoke it # Session expired due to inactivity - revoke it
device_session.revoked = True device_session.revoked = True
db.commit() 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to inactivity", detail="Session expired due to inactivity",
@@ -99,7 +85,6 @@ def get_current_user(
# Check if user is suspended # Check if user is suspended
if user.suspended: if user.suspended:
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Account suspended", detail="Account suspended",
@@ -108,7 +93,6 @@ def get_current_user(
# Check if user is deleted # Check if user is deleted
if user.deleted: if user.deleted:
logger.info("get_current_user: account deleted for user_id=%s", user.id)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Account deleted", detail="Account deleted",
+6 -6
View File
@@ -1,7 +1,7 @@
from backend.shared.constants import * from constants import *
from backend.shared.db import * from db import *
from backend.shared.models import * from models import *
from backend.shared.validation import * from validation import *
from backend.shared.utils import * from utils import *
from backend.shared.dependencies import * from dependencies import *
from app import * from app import *
+8 -42
View File
@@ -8,7 +8,7 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine from sqlalchemy import create_engine
from backend.shared.constants import DATABASE_URL from constants import DATABASE_URL
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,27 +19,10 @@ def run_migrations():
This function will upgrade the database to the latest migration. This function will upgrade the database to the latest migration.
Fully automated - handles all scenarios automatically. Fully automated - handles all scenarios automatically.
""" """
try:
# Get the directory where this script is located # Get the directory where this script is located
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
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
# Create Alembic configuration # Create Alembic configuration
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini")) alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
@@ -57,13 +40,6 @@ def run_migrations():
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
# If no migration files exist after cleanup, create initial migration
if not migration_files:
logger.info("No migration files found after cleanup. Creating initial migration...")
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
logger.info(f"Created {len(migration_files)} initial migration(s)")
if not migration_files: if not migration_files:
logger.info("No migration files found. Creating initial migration...") logger.info("No migration files found. Creating initial migration...")
# Check if database exists and has tables # Check if database exists and has tables
@@ -147,11 +123,7 @@ def run_migrations():
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully.") logger.info("Database migrations completed successfully.")
except Exception as upgrade_error: except Exception as upgrade_error:
error_msg = str(upgrade_error) if "Can't locate revision identified by 'direct_creation'" in 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:
logger.info("Found 'direct_creation' revision - resetting migration state...") logger.info("Found 'direct_creation' revision - resetting migration state...")
# Clear the alembic_version table and start fresh # Clear the alembic_version table and start fresh
engine = create_engine(DATABASE_URL) engine = create_engine(DATABASE_URL)
@@ -196,7 +168,6 @@ def run_migrations():
# Try upgrade again # Try upgrade again
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.") logger.info("Database migrations completed successfully after reset.")
# Note: Index-related errors are now prevented by Docker build cleanup
else: else:
raise upgrade_error raise upgrade_error
@@ -303,7 +274,7 @@ def _populate_migration_file(migration_path):
def _generate_migration_from_models(): def _generate_migration_from_models():
"""Generate migration content dynamically from SQLAlchemy models.""" """Generate migration content dynamically from SQLAlchemy models."""
from backend.shared.models import Base from models import Base
import sqlalchemy as sa import sqlalchemy as sa
from datetime import datetime from datetime import datetime
@@ -488,12 +459,10 @@ def _get_column_type(column):
def _create_database_directly(): def _create_database_directly():
"""Fallback method: create database directly using SQLAlchemy.""" """Fallback method: create database directly using SQLAlchemy."""
from backend.shared.models import Base from models import Base
from backend.shared.db import get_engine from db import engine
from sqlalchemy import text, inspect from sqlalchemy import text, inspect
engine = get_engine()
# Check existing tables and update schema # Check existing tables and update schema
with engine.connect() as connection: with engine.connect() as connection:
inspector = inspect(connection) inspector = inspect(connection)
@@ -549,9 +518,6 @@ def _create_database_directly():
# Table doesn't exist, create it # Table doesn't exist, create it
logger.info(f"Creating table {table_name}") logger.info(f"Creating table {table_name}")
# Create alembic_version table manually
engine = get_engine(DATABASE_URL)
with engine.connect() as connection:
# Create alembic_version table manually # Create alembic_version table manually
connection.execute(text(""" connection.execute(text("""
CREATE TABLE IF NOT EXISTS alembic_version ( CREATE TABLE IF NOT EXISTS alembic_version (
@@ -575,9 +541,9 @@ def _create_database_directly():
revision_match = re.search(r"revision: str = '([^']+)'", content) revision_match = re.search(r"revision: str = '([^']+)'", content)
if revision_match: if revision_match:
revision_id = revision_match.group(1) revision_id = revision_match.group(1)
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING")) connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
else: else:
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING")) connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
else: else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
+49 -10
View File
@@ -71,6 +71,55 @@ class CryptoBackup(Base):
blob_json = Column(Text, nullable=False) 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): class DMEnvelope(Base):
__tablename__ = "dm_envelope" __tablename__ = "dm_envelope"
@@ -113,16 +162,6 @@ class PushSubscription(Base):
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) 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): class Reaction(Base):
__tablename__ = "reaction" __tablename__ = "reaction"
+152
View File
@@ -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()
-3
View File
@@ -2,7 +2,6 @@ PyJWT>=2.8.0
fastapi[standard]>=0.116.1 fastapi[standard]>=0.116.1
pydantic>=2.11.7 pydantic>=2.11.7
sqlalchemy>=2.0.43 sqlalchemy>=2.0.43
psycopg2-binary>=2.9.9
bcrypt>=4.3.0 bcrypt>=4.3.0
websockets>=15.0.1 websockets>=15.0.1
Pillow>=10.0.0 Pillow>=10.0.0
@@ -15,5 +14,3 @@ user-agents>=2.2.0
httpx>=0.27.2 httpx>=0.27.2
rich>=13.9.4 rich>=13.9.4
slowapi>=0.1.9 slowapi>=0.1.9
firebase_admin>=7.1.0
PyNaCl>=1.5.0
+505 -61
View File
@@ -8,16 +8,16 @@ import uuid
from user_agents import parse as parse_ua from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from backend.shared.constants import OWNER_USERNAME from constants import OWNER_USERNAME
from backend.shared.dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession, SentMessagePlaintext
from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip from 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 validation import is_valid_password, is_valid_username, is_valid_display_name
import os import os
from backend.security.audit import log_security from security.audit import log_security
from backend.security.profanity import contains_profanity from security.profanity import contains_profanity
from backend.security.rate_limit import rate_limit_per_ip from security.rate_limit import rate_limit_per_ip
router = APIRouter() router = APIRouter()
_FAILED_ATTEMPT_WINDOW_SECONDS = 300 _FAILED_ATTEMPT_WINDOW_SECONDS = 300
@@ -39,24 +39,21 @@ def _record_failed_login(identifier: str) -> bool:
def _reset_failed_logins(identifier: str) -> None: def _reset_failed_logins(identifier: str) -> None:
_failed_login_attempts.pop(identifier, None) _failed_login_attempts.pop(identifier, None)
def _is_admin(user: User) -> bool:
return user.id == 1
def convert_user(user: User) -> dict: def convert_user(user: User) -> dict:
return { return {
"id": user.id, "id": user.id,
"created_at": user.created_at.isoformat(), "created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(), "last_seen": user.last_seen.isoformat(),
"online": user.is_online, "online": user.online,
"username": user.username, "username": user.username,
"display_name": user.display_name, "display_name": user.display_name,
"profile_picture": user.avatar_url, "profile_picture": user.profile_picture,
"bio": user.bio, "bio": user.bio,
"admin": _is_admin(user), "admin": user.username == OWNER_USERNAME,
"verified": user.verified, "verified": user.verified,
"suspended": user.suspended, "suspended": user.suspended or False,
"suspension_reason": user.suspension_reason, "suspension_reason": user.suspension_reason,
"deleted": user.deleted "deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
} }
@router.get("/check_auth") @router.get("/check_auth")
@@ -64,7 +61,7 @@ def check_auth(current_user: User = Depends(get_current_user)):
return { return {
"authenticated": True, "authenticated": True,
"username": current_user.username, "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() 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( log_security(
"login_failed", "login_failed",
severity="warning", severity="warning",
@@ -112,9 +109,30 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
detail="Неверное имя пользователя или пароль" 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 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.online = True
user.last_seen = datetime.now() user.last_seen = datetime.now()
db.commit() db.commit()
@@ -127,19 +145,15 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
for identifier in identifiers: for identifier in identifiers:
_reset_failed_logins(identifier) _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( log_security(
"login_success", "login_success",
username=user.username, username=user.username,
user_id=user.id, user_id=user.id,
ip=client_ip, ip=client_ip,
session_id=session_id, session_id=session_id,
device=device_type, device=device.device_type,
os=ua.os.family, os=device.os_name,
browser=ua.browser.family, browser=device.browser_name,
) )
return { return {
@@ -163,6 +177,13 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
# Determine if owner already exists # Determine if owner already exists
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None 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 # Validate input
if not is_valid_username(username): if not is_valid_username(username):
raise HTTPException( raise HTTPException(
@@ -198,6 +219,13 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
detail="Пароли не совпадают" 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() existing_user = db.query(User).filter(User.username == username).first()
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
@@ -213,9 +241,8 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
new_user = User( new_user = User(
username=username, username=username,
display_name=display_name, display_name=display_name,
hashed_password=hashed_password, password_hash=hashed_password,
salt="", # Not used since bcrypt includes salt in hash online=True,
is_online=True,
last_seen=datetime.now(), last_seen=datetime.now(),
verified=is_owner verified=is_owner
) )
@@ -224,13 +251,32 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
db.commit() db.commit()
db.refresh(new_user) 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 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) 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" os_name = ua.os.family or "Unknown OS"
if ua.os.version_string: if ua.os.version_string:
os_name = f"{os_name} {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) db: Session = Depends(get_db)
): ):
# Only owner can delete users # 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") raise HTTPException(status_code=403, detail="Only owner can perform this action")
user = db.query(User).filter(User.id == user_id).first() 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") raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting the owner account via API # 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") raise HTTPException(status_code=400, detail="Cannot delete owner account")
# Manually delete user's messages to satisfy FK constraints # Manually delete user's messages to satisfy FK constraints
from 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.query(Message).filter(Message.user_id == user.id).delete()
db.delete(user) db.delete(user)
@@ -345,14 +391,14 @@ def logout(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Revoke current session - TODO: Move to device service # Revoke current session
# from backend.shared.utils import verify_token as _verify_token from utils import verify_token as _verify_token
# payload = _verify_token(credentials.credentials) payload = _verify_token(credentials.credentials)
# if payload and payload.get("session_id"): if payload and payload.get("session_id"):
# db.query(DeviceSession).filter( db.query(DeviceSession).filter(
# DeviceSession.user_id == current_user.id, DeviceSession.user_id == current_user.id,
# DeviceSession.session_id == payload["session_id"], DeviceSession.session_id == payload["session_id"],
# ).update({DeviceSession.revoked: True}) ).update({DeviceSession.revoked: True})
current_user.online = False current_user.online = False
current_user.last_seen = datetime.now() current_user.last_seen = datetime.now()
@@ -364,7 +410,7 @@ def logout(
username=current_user.username, username=current_user.username,
user_id=current_user.id, user_id=current_user.id,
ip=client_ip, ip=client_ip,
session_id=None, # TODO: Get session_id from device service session_id=payload.get("session_id") if payload else None,
) )
return { return {
@@ -383,25 +429,25 @@ def change_password(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Verify current derived password against stored hash # 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="Текущий пароль неверный") raise HTTPException(status_code=401, detail="Текущий пароль неверный")
# Update password hash to hash of new derived password # 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() db.commit()
# Optionally revoke all other sessions, keeping the current one - TODO: Move to device service # Optionally revoke all other sessions, keeping the current one
# if password_request.logoutAllExceptCurrent: if password_request.logoutAllExceptCurrent:
# from backend.shared.utils import verify_token as _verify_token from utils import verify_token as _verify_token
# payload = _verify_token(credentials.credentials) payload = _verify_token(credentials.credentials)
# if not payload: if not payload:
# raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
# current_session_id = payload.get("session_id") current_session_id = payload.get("session_id")
# db.query(DeviceSession).filter( db.query(DeviceSession).filter(
# DeviceSession.user_id == current_user.id, DeviceSession.user_id == current_user.id,
# DeviceSession.session_id != current_session_id, DeviceSession.session_id != current_session_id,
# ).update({DeviceSession.revoked: True}) ).update({DeviceSession.revoked: True})
# db.commit() db.commit()
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
log_security( 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} 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") @router.get("/users/search")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @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)): 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.deleted = True
user.display_name = f"Deleted User #{user_id}" user.display_name = f"Deleted User #{user_id}"
user.bio = None user.bio = None
user.hashed_password = "" user.password_hash = ""
user.username = f"deleted_{user_id}" user.username = f"deleted_{user_id}"
user.profile_picture = None user.profile_picture = None
user.last_seen = None # Clear last seen timestamp 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 Delete the current user's own account - preserves messages/DMs/reactions/files
""" """
# Prevent admin/owner account self-deletion # 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") raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
await _delete_user_data(current_user, db) await _delete_user_data(current_user, db)
+3 -3
View File
@@ -2,9 +2,9 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.shared.dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from backend.shared.models import User, DeviceSession from models import User, DeviceSession
from backend.shared.utils import verify_token from utils import verify_token
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
router = APIRouter() router = APIRouter()
+33 -236
View File
@@ -16,30 +16,26 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.shared.dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from backend.shared.utils import convert_user from .account import convert_user
from backend.shared.constants import OWNER_USERNAME from constants import OWNER_USERNAME
from backend.shared.models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
import os from push_service import push_service
import httpx
from PIL import Image from PIL import Image
import io import io
import json import json
from pydantic import BaseModel
from better_profanity import profanity as _bp from better_profanity import profanity as _bp
from backend.security.audit import log_access, log_dm, log_public_chat, log_security from security.audit import log_access, log_dm, log_public_chat, log_security
from backend.security.profanity import contains_profanity from security.profanity import contains_profanity
from backend.security.rate_limit import rate_limit_per_ip from security.rate_limit import rate_limit_per_ip
from backend.services.messaging.files.websocket.utils import authenticate_user from websocket.utils import authenticate_user
from backend.shared.models import FcmToken
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
FILES_BASE_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "files" FILES_BASE_DIR = Path("data/uploads/files")
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal" FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
@@ -54,8 +50,8 @@ _BURST_COUNT_THRESHOLD = 20
_SHORT_MESSAGE_LENGTH = 8 _SHORT_MESSAGE_LENGTH = 8
_SHORT_MESSAGE_REPEAT_LIMIT = 4 _SHORT_MESSAGE_REPEAT_LIMIT = 4
_recent_message_cache: dict[int, deque[tuple[str, str, float, int]]] = defaultdict(deque) # (normalized, content, timestamp, message_id) _recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque)
_message_rate_cache: dict[int, deque[tuple[float, int]]] = defaultdict(deque) # (timestamp, message_id) _message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
_burst_last_logged: dict[int, float] = {} _burst_last_logged: dict[int, float] = {}
@@ -66,23 +62,12 @@ def _normalize_for_spam(text: str) -> str:
return cleaned 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() 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: if user.suspended or user.id == 1:
return 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.suspended = True
user.suspension_reason = reason user.suspension_reason = reason
db.commit() db.commit()
@@ -92,7 +77,6 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
user_id=user.id, user_id=user.id,
username=user.username, username=user.username,
reason=reason, reason=reason,
deleted_messages=len(message_ids_to_delete) if message_ids_to_delete else 0,
**extra, **extra,
) )
try: try:
@@ -102,8 +86,8 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
# Rate tracking for burst detection # Rate tracking for burst detection
rate_bucket = _message_rate_cache[user.id] rate_bucket = _message_rate_cache[user.id]
rate_bucket.append((now, message_id)) rate_bucket.append(now)
while rate_bucket and now - rate_bucket[0][0] > _BURST_WINDOW_SECONDS: while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
rate_bucket.popleft() rate_bucket.popleft()
burst_count = len(rate_bucket) 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, window_seconds=_BURST_WINDOW_SECONDS,
) )
_burst_last_logged[user.id] = now _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( suspend(
"Automatic suspension: excessive message rate", "Automatic suspension: excessive message rate",
"auto_suspension_public_burst", "auto_suspension_public_burst",
message_ids_to_delete=burst_message_ids,
count=burst_count, count=burst_count,
window_seconds=_BURST_WINDOW_SECONDS, window_seconds=_BURST_WINDOW_SECONDS,
) )
return
# Similarity-based spam detection # Similarity-based spam detection
normalized = _normalize_for_spam(content) 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: while history and now - history[0][2] > _SPAM_WINDOW_SECONDS:
history.popleft() 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( prior_similar = sum(
1 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 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 total_matches = prior_same + prior_similar + 1
if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT: 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( suspend(
"Automatic suspension: repeated short messages", "Automatic suspension: repeated short messages",
"auto_suspension_public_spam", "auto_suspension_public_spam",
message_ids_to_delete=spam_message_ids,
occurrences=prior_same + 1, occurrences=prior_same + 1,
window_seconds=_SPAM_WINDOW_SECONDS, window_seconds=_SPAM_WINDOW_SECONDS,
match_type="short", match_type="short",
@@ -163,20 +138,9 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
return return
if total_matches >= _SPAM_MESSAGE_LIMIT: 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( suspend(
"Automatic suspension: repeated similar public messages", "Automatic suspension: repeated similar public messages",
"auto_suspension_public_spam", "auto_suspension_public_spam",
message_ids_to_delete=spam_message_ids,
similar_messages=total_matches, similar_messages=total_matches,
window_seconds=_SPAM_WINDOW_SECONDS, window_seconds=_SPAM_WINDOW_SECONDS,
match_type="similar", match_type="similar",
@@ -394,16 +358,7 @@ async def _send_message_internal(
# Send push notifications for public messages # Send push notifications for public messages
try: try:
push_service_url = os.getenv("PUSH_SERVICE_URL", "http://push_service:8306") await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{push_service_url}/push/send-public-notification",
json={
"message_id": new_message.id,
"exclude_user_id": current_user.id
}
)
response.raise_for_status()
except Exception as e: except Exception as e:
logger.error(f"Failed to send push notification for message {new_message.id}: {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: except Exception:
pass 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) 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) 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") @router.get("/get_messages")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @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)): 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") @router.post("/dm/send")
@rate_limit_per_ip("20/minute") @rate_limit_per_ip("20/minute")
async def dm_send( 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, message_id: int,
edit_request: EditMessageRequest, edit_request: EditMessageRequest,
current_user: User, current_user: User = Depends(get_current_user),
db: Session db: Session = Depends(get_db)
) -> dict: ):
"""Internal function to edit a message without requiring a Request object.
This can be called from both HTTP endpoints and WebSocket handlers.
"""
message = db.query(Message).filter(Message.id == message_id).first() message = db.query(Message).filter(Message.id == message_id).first()
if not message: if not message:
@@ -909,18 +735,6 @@ async def _edit_message_internal(
return {"status": "success", "message": payload} 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}") @router.delete("/delete_message/{message_id}")
async def delete_message( async def delete_message(
message_id: int, message_id: int,
@@ -1190,7 +1004,6 @@ class MessaggingSocketManager:
# Skip if this exact update was recently added # Skip if this exact update was recently added
if signature in self.recent_updates[websocket]: if signature in self.recent_updates[websocket]:
logger.warning(f"Update was skipped due to duplicate signature {signature}")
return return
# Add to pending updates and track signature # Add to pending updates and track signature
@@ -1198,8 +1011,10 @@ class MessaggingSocketManager:
self.recent_updates[websocket].add(signature) self.recent_updates[websocket].add(signature)
# Limit recent updates cache size (keep last 100 signatures per websocket) # Limit recent updates cache size (keep last 100 signatures per websocket)
if len(self.recent_updates[websocket]) > 1: if len(self.recent_updates[websocket]) > 100:
self.recent_updates[websocket] = set(list(self.recent_updates[websocket])[-1]) # 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): async def _flush_updates(self, websocket: WebSocket, db: Session | None = None):
"""Flush pending updates for a WebSocket connection""" """Flush pending updates for a WebSocket connection"""
@@ -1292,7 +1107,7 @@ class MessaggingSocketManager:
self.ws_subscriptions[websocket] = set() self.ws_subscriptions[websocket] = set()
# Import here to avoid circular import # Import here to avoid circular import
from backend.services.messaging.files.websocket.handlers import handler_registry from websocket.handlers import handler_registry
while True: while True:
try: try:
@@ -1571,21 +1386,3 @@ async def get_file_encrypted(filename: str, current_user: User = Depends(get_cur
raise HTTPException(500) raise HTTPException(500)
return FileResponse(str(path)) 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))
+6 -6
View File
@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List from typing import List
from backend.shared.constants import OWNER_USERNAME from constants import OWNER_USERNAME
from backend.shared.dependencies import get_current_user from dependencies import get_current_user
from backend.shared.models import User from models import User
from backend.security.audit import log_security from security.audit import log_security
from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist from 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 security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
class BlocklistUpdateRequest(BaseModel): class BlocklistUpdateRequest(BaseModel):
+10 -20
View File
@@ -9,16 +9,15 @@ import uuid
import io import io
from fastapi import Request from fastapi import Request
from backend.shared.dependencies import get_db, get_current_user from dependencies import get_db, get_current_user
from backend.shared.models import User, UpdateBioRequest, UserProfileResponse from models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel from pydantic import BaseModel
from backend.shared.validation import is_valid_username, is_valid_display_name from validation import is_valid_username, is_valid_display_name
from backend.shared.similarity import is_user_similar_to_verified from similarity import is_user_similar_to_verified
import os from .messaging import messagingManager
import httpx from security.audit import log_security
from backend.security.audit import log_security from security.profanity import contains_profanity
from backend.security.profanity import contains_profanity from security.rate_limit import rate_limit_per_ip
from backend.security.rate_limit import rate_limit_per_ip
router = APIRouter() router = APIRouter()
@@ -37,7 +36,7 @@ class UpdateProfileRequest(BaseModel):
description: str | None = None description: str | None = None
# Create uploads directory if it doesn't exist # Create uploads directory if it doesn't exist
PROFILE_PICTURES_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "pfp" PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
@@ -480,16 +479,7 @@ async def suspend_user(
# Send WebSocket suspension message # Send WebSocket suspension message
try: try:
messaging_service_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305") await messagingManager.send_suspension_to_user(user_id, request.reason)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{messaging_service_url}/messaging/send-suspension",
json={
"user_id": user_id,
"reason": request.reason
}
)
response.raise_for_status()
except Exception as e: except Exception as e:
# Log error but don't fail the request # Log error but don't fail the request
pass pass
+3 -26
View File
@@ -1,16 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from dependencies import get_current_user, get_db
from backend.shared.dependencies import get_current_user, get_db from models import User, PushSubscriptionRequest
from backend.shared.models import User, PushSubscriptionRequest from push_service import push_service
from backend.services.push.files import push_service
router = APIRouter() router = APIRouter()
class SendPublicNotificationRequest(BaseModel):
message_id: int
exclude_user_id: int
@router.post("/subscribe") @router.post("/subscribe")
async def subscribe_to_push_notifications( async def subscribe_to_push_notifications(
request: PushSubscriptionRequest, request: PushSubscriptionRequest,
@@ -49,21 +44,3 @@ async def unsubscribe_from_push_notifications(
raise HTTPException(status_code=500, detail="Failed to disable push notifications") raise HTTPException(status_code=500, detail="Failed to disable push notifications")
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(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))
+1 -1
View File
@@ -4,7 +4,7 @@ import hmac
import hashlib import hashlib
import time import time
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from backend.shared.dependencies import get_current_user from dependencies import get_current_user
import traceback import traceback
router = APIRouter() router = APIRouter()
-80
View File
@@ -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"]
)
+1 -1
View File
@@ -4,7 +4,7 @@ import logging
from html import unescape from html import unescape
from typing import Any, Callable, Dict, List from typing import Any, Callable, Dict, List
from 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: def _clean_username(username: Any) -> str:
+12 -51
View File
@@ -9,7 +9,7 @@ from typing import Iterable, List, Set, Tuple
from better_profanity import Profanity from better_profanity import Profanity
BLOCKLIST_PATH = Path(__file__).resolve().parent.parent / "data" / "profanity" / "blocklist.json" BLOCKLIST_PATH = Path("data/profanity/blocklist.json")
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
_CUSTOM_RU_TERMS: Set[str] = { _CUSTOM_RU_TERMS: Set[str] = {
@@ -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), 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) # Map for normalizing homoglyphs (similar-looking characters)
# Maps English/Latin characters to their Cyrillic equivalents and vice versa # Maps English/Latin characters to their Cyrillic equivalents and vice versa
# Also includes Greek, full-width, and other Unicode variants # Also includes Greek, full-width, and other Unicode variants
@@ -194,8 +182,6 @@ _LEET_MAP = {
"н": "н", # Already mapped, but explicit "н": "н", # Already mapped, but explicit
# Special characters # 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, ...]], ...] = ( _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, ...], ...]] = {} _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: def _normalize_char(ch: str) -> str:
"""Normalize a single character, mapping homoglyphs to canonical form.""" """Normalize a single character, mapping homoglyphs to canonical form."""
# First try direct mapping (preserves case for non-mapped chars) # 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 (normalized_text, position_map) where position_map[i] is the original
position of the i-th character in normalized_text position of the i-th character in normalized_text
""" """
# First preprocess visual bypasses (like "}{" -> "х") # First normalize Unicode (composed vs decomposed)
text = _preprocess_visual_bypasses(text)
# Then normalize Unicode (composed vs decomposed)
normalized_unicode = unicodedata.normalize('NFKC', text) normalized_unicode = unicodedata.normalize('NFKC', text)
# For phrase matching, convert zero-width chars to spaces instead of stripping # For phrase matching, convert zero-width chars to spaces instead of stripping
@@ -345,21 +316,17 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -
start = pos + 1 start = pos + 1
# Also check if profane word appears as a subsequence (allowing extra chars) # Also check if profane word appears as a subsequence (allowing extra chars)
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй" # This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй"
# Now applies to ALL words, not just length >= 4, to prevent bypasses # 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) word_chars = list(word_lower)
text_chars = list(normalized_lower) text_chars = list(normalized_lower)
# Stricter ratio for shorter words, more lenient for longer words
# Stricter span limits based on word length to prevent false positives if len(word_lower) <= 5:
# Shorter words get much stricter limits max_span_ratio = 1.5 # Very strict for short words
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: else:
max_span_ratio = 1.8 # Slightly more lenient for longer words max_span_ratio = 2.0 # Slightly more lenient for longer words
# Try to find the word as a subsequence # Try to find the word as a subsequence
i = 0 # position in text i = 0 # position in text
@@ -615,13 +582,7 @@ def contains_profanity(text: str) -> bool:
_rebuild_dictionary() _rebuild_dictionary()
# Check original text patterns first (before normalization) to catch visual bypasses # Check phrase patterns first
# like "}{" used to form "х"
for pattern in _ORIGINAL_TEXT_PATTERNS:
if pattern.search(text):
return True
# Check phrase patterns
if _check_phrase_patterns(text): if _check_phrase_patterns(text):
return True return True
+1 -1
View File
@@ -8,7 +8,7 @@ from fastapi import Request
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from backend.shared.utils import get_client_ip from utils import get_client_ip
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
-11
View File
@@ -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
-9
View File
@@ -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)))
-11
View File
@@ -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
-9
View File
@@ -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)))
-14
View File
@@ -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
-6
View File
@@ -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)))
-12
View File
@@ -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
-9
View File
@@ -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"]
-51
View File
@@ -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()
-13
View File
@@ -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
-9
View File
@@ -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)))
-11
View File
@@ -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
-9
View File
@@ -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)))
-12
View File
@@ -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
-243
View File
@@ -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()
-9
View File
@@ -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)))
-11
View File
@@ -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
-9
View File
@@ -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
View File
@@ -1 +0,0 @@
# Shared modules package
-50
View File
@@ -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"
-62
View File
@@ -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()
-122
View File
@@ -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
-423
View File
@@ -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"),
)
-234
View File
@@ -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
}
-132
View File
@@ -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,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 # Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
# Import them directly from websocket.handlers when needed # Import them directly from websocket.handlers when needed
@@ -3,21 +3,20 @@ import json
import logging import logging
import time import time
from typing import Any from typing import Any
from fastapi import HTTPException, WebSocket, Request from fastapi import HTTPException, WebSocket
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.services.messaging.files.websocket.registry import WebSocketHandlerRegistry from websocket.registry import WebSocketHandlerRegistry
from backend.routes.messaging import ( from routes.messaging import (
MessaggingSocketManager, MessaggingSocketManager,
_send_message_internal, _send_message_internal,
_edit_message_internal,
get_messages, get_messages,
edit_message, edit_message,
delete_message, delete_message,
add_reaction, add_reaction,
add_dm_reaction, add_dm_reaction,
) )
from backend.shared.models import ( from models import (
User, User,
SendMessageRequest, SendMessageRequest,
EditMessageRequest, EditMessageRequest,
@@ -26,7 +25,7 @@ from backend.shared.models import (
DMReactionRequest, DMReactionRequest,
UpdateLog, UpdateLog,
) )
from backend.security.audit import log_access, log_dm from security.audit import log_access, log_dm
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -212,11 +211,14 @@ async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Ses
@websocket_handler("editMessage", authRequired=True) @websocket_handler("editMessage", authRequired=True)
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Edit a public chat message.""" """Edit a public chat message."""
from types import SimpleNamespace
message_id = data["message_id"] 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({ await manager.broadcast({
"type": "messageEdited", "type": "messageEdited",
"data": response["message"] "data": response["message"]
@@ -2,8 +2,8 @@ from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from types import SimpleNamespace from types import SimpleNamespace
from backend.shared.dependencies import get_current_user from dependencies import get_current_user
from backend.shared.models import User from models import User
def extract_token_from_data(data: dict) -> str | None: def extract_token_from_data(data: dict) -> str | None:
-88
View File
@@ -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
}
-13
View File
@@ -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
-70
View File
@@ -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;
-158
View File
@@ -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;
+29 -349
View File
@@ -1,368 +1,48 @@
services: services:
# Database service backend:
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: build:
dockerfile: deployment/Dockerfile.backend
context: .. context: ..
dockerfile: docker/Dockerfile.multi
target: migration_runner
environment: 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 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} 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_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_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: volumes:
- certs:/root/site/certs - data:/app/data
networks: - logs:/app/logs
- fromchat_external
extra_hosts: develop:
- "host.docker.internal:host-gateway" watch:
environment: - action: sync+restart
- XDG_DATA_HOME=/root/site/certs path: ../backend
- XDG_CONFIG_HOME=/root/site/certs target: /app
restart: unless-stopped - action: rebuild
depends_on: path: ../backend/requirements.txt
- gateway
profiles: ["prod"]
# Frontend service - serves the React app
frontend: frontend:
build: build:
context: ..
dockerfile: deployment/frontend/Dockerfile dockerfile: deployment/frontend/Dockerfile
context: ..
environment:
PORT: 8301
BACKEND_HOST: http://backend:8300
ports: ports:
- "8301:8301" - "8301:8301"
environment: depends_on:
- PORT=8301 - backend
- BACKEND_HOST=http://gateway:8300 develop:
restart: unless-stopped watch:
networks: - action: rebuild
- fromchat_external path: ../frontend
- fromchat_internal - action: sync+restart
path: server.js
target: /server/server.js
- action: rebuild
path: package.json
volumes: volumes:
database:
name: fromchat-database
certs:
name: fromchat-certs
logs:
name: fromchat-logs
data: data:
name: fromchat-data name: fromchat-data
logs:
networks: name: fromchat-logs
fromchat_internal:
driver: bridge
internal: true
fromchat_external:
driver: bridge
+1 -3
View File
@@ -3,13 +3,11 @@ FROM node:24 AS frontend
# 1.1. Install npm dependencies # 1.1. Install npm dependencies
WORKDIR /app WORKDIR /app
# Copy package.json and workspace package directory first (needed for workspace resolution)
COPY package.json . COPY package.json .
COPY frontend/packages/ frontend/packages/
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm install --ignore-scripts npm install --ignore-scripts
# 1.2. Copy remaining frontend code and build # 1.2. Build
COPY frontend frontend COPY frontend frontend
RUN npm run frontend:build RUN npm run frontend:build
+7 -23
View File
@@ -1,44 +1,28 @@
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware'; import { createProxyMiddleware } from 'http-proxy-middleware';
import { resolve } from 'path'; import { resolve } from 'path';
const app = express(); 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 backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
const filePath = process.env.STATIC_FILE_PATH || "."; const filePath = process.env.STATIC_FILE_PATH || ".";
// Direct WebSocket proxy for chat - bypass gateway (must come before general API proxy) // API proxy middleware
app.use('/api/chat/ws', createProxyMiddleware({ app.use('/api', createProxyMiddleware({
target: 'http://messaging_service:8305',
changeOrigin: true,
pathRewrite: { '^/api/chat/ws': '/messaging/chat/ws' },
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, target: backendHost,
changeOrigin: true, changeOrigin: true,
pathRewrite: { '^/api': '' }, pathRewrite: { '^/api': '' },
ws: true ws: true
})(req, res, next); }));
});
// Serve static files // Serve static files
app.use(express.static(resolve(filePath))); app.use(express.static(resolve(filePath)));
// SPA routing - catch all handler for client-side routing // 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')); res.sendFile(resolve(filePath, 'index.html'));
}); });
app.listen(port, '0.0.0.0', () => { app.listen(port, () => {
console.log(`Backend host: ${backendHost}`); console.log(`Server launched on http://localhost:${port}`);
console.log(`Server launched on http://0.0.0.0:${port}`);
}); });
-123
View File
@@ -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
-5
View File
@@ -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"]
}
+4 -1
View File
@@ -1,7 +1,9 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types"; 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 { b64, ub64 } from "@/utils/utils";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto"; import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
import type { Headers } from "@/core/types"; import type { Headers } from "@/core/types";
@@ -125,6 +127,7 @@ export async function deriveAuthSecret(username: string, password: string): Prom
return b64(derived); return b64(derived);
} }
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> { export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
// Try to restore from backup // Try to restore from backup
const blobJson = await fetchBackupBlob(token); const blobJson = await fetchBackupBlob(token);
+324 -42
View File
@@ -1,19 +1,148 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth"; import api from "@/core/api";
import { getCurrentKeys } from "../user/auth"; import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils"; import { b64 } from "@/utils/utils";
import { fetchUserPublicKey } from "../crypto/identity"; import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { fetchUsers, searchUsers } from "../user/search"; import { useUserStore } from "@/state/user";
import { getOrInitProtocol } from "@/utils/crypto/fromchatInit"; import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol";
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> { export async function decrypt(envelope: DmEnvelope, senderId: number): Promise<string> {
const protocol = getOrInitProtocol(); const user = useUserStore.getState().user.currentUser;
const senderPublicKey = ub64(senderPublicKeyB64); if (!user?.id) {
throw new Error("User not authenticated");
}
return await protocol.decryptMessage(senderPublicKey, envelope); if (!envelope.ciphertext) {
throw new Error("DM envelope missing ciphertext");
}
const signalService = new SignalProtocolService(user.id.toString());
// 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 }> { 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}`; url += `&before_id=${beforeId}`;
} }
const response = await globalThis.fetch(url, { 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 }; if (!response.ok) return { messages: [], has_more: false };
const data = await response.json(); const data = await response.json();
return { messages: data.messages || [], has_more: data.has_more ?? false }; 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> { export async function send(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const protocol = getOrInitProtocol(); const user = useUserStore.getState().user.currentUser;
const recipientPublicKey = ub64(recipientPublicKeyB64); if (!user?.id) {
throw new Error("User not authenticated");
}
const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext); const signalService = new SignalProtocolService(user.id.toString());
// 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 = { const payload: SendDMRequest = {
recipientId: recipientId, 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; if (replyToId) payload.replyToId = replyToId;
@@ -49,20 +262,44 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p
}, },
data: payload 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> { export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
// For files, we need to use the same message key for both the message and files const user = useUserStore.getState().user.currentUser;
// So we'll do the encryption manually here to reuse the mk if (!user?.id) {
const keys = getCurrentKeys(); throw new Error("User not authenticated");
if (!keys) throw new Error("Keys not initialized"); }
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 mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); // Encrypt the master key using Signal Protocol
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); const mkBase64 = b64(mk);
const wk = await importAesGcmKey(wkRaw); const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
const wrap = await aesGcmEncrypt(wk, mk);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
const form = new FormData(); const form = new FormData();
const names: string[] = []; const names: string[] = [];
@@ -75,42 +312,84 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
const data = new Uint8Array(await f.arrayBuffer()); const data = new Uint8Array(await f.arrayBuffer());
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); 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); names.push(serverName);
form.append("files", new File([blob], serverName)); form.append("files", new File([blob], serverName));
} }
form.append("fileNames", JSON.stringify(names)); form.append("fileNames", JSON.stringify(names));
// Encrypt the plaintext JSON with the same mk // Merge files metadata into plaintext JSON and encrypt
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson)); 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({ form.append("dm_payload", JSON.stringify({
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext), ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt), salt: "", // Not used for Signal Protocol
iv2: b64(wrap.iv), iv2: "", // Not used for Signal Protocol
wrappedMk: b64(wrap.ciphertext) wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope)); } satisfies BaseDmEnvelope));
await globalThis.fetch(`${API_BASE_URL}/dm/send`, { await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
method: "POST", method: "POST",
headers: getAuthHeaders(token, false), headers: api.user.auth.getAuthHeaders(token, false),
body: form body: form
}); });
} }
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> { export async function edit(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const protocol = getOrInitProtocol(); const user = useUserStore.getState().user.currentUser;
const recipientPublicKey = ub64(recipientPublicKeyB64); if (!user?.id) {
throw new Error("User not authenticated");
}
const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson); 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, 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({ await request({
type: "dmEdit", type: "dmEdit",
credentials: { scheme: "Bearer", credentials: authToken }, credentials: { scheme: "Bearer", credentials: authToken },
data: { data: {
id, 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); } as DMEditRequest);
} }
@@ -131,7 +410,7 @@ export interface ConversationResponse {
export async function conversations(token: string): Promise<ConversationResponse[]> { export async function conversations(token: string): Promise<ConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, { const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true) headers: api.user.auth.getAuthHeaders(token, true)
}); });
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); 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 // Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey }; export { fetchUsers, searchUsers } from "@/core/api/users";
export { fetchUserPublicKey } from "@/core/api/crypto/identity";
+37
View File
@@ -2,6 +2,7 @@ import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account"; import { getAuthHeaders } from "./account";
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types"; import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
/** /**
* Fetches the current user's public key * 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"); 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;
}
+3 -3
View File
@@ -1,12 +1,12 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { BackupBlob } from "@/core/types"; import type { BackupBlob } from "@/core/types";
import api from "@/core/api";
/** /**
* Fetches the current user's backup blob * Fetches the current user's backup blob
*/ */
export async function fetchBackupBlob(token: string): Promise<string | null> { 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`, { const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "GET", method: "GET",
headers headers
@@ -25,7 +25,7 @@ export async function fetchBackupBlob(token: string): Promise<string | null> {
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> { export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
const payload: BackupBlob = { blob: blobJson } 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`, { const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "POST", method: "POST",
headers, 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 || [];
}
+83 -8
View File
@@ -1,14 +1,89 @@
// Placeholder for Signal Protocol pre-key management import { API_BASE_URL } from "@/core/config";
// Will be implemented when Signal Protocol is added 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 * Uploads Signal Protocol prekey bundle for the current user
throw new Error("Not implemented yet"); * 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 * Uploads all available prekeys to the server for rotation in a single request
throw new Error("Not implemented yet"); */
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;
} }
+69
View File
@@ -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
View File
@@ -1,176 +1,16 @@
import { API_BASE_URL } from "@/core/config"; // Re-export from dmApi.ts which has Signal Protocol support
import { getAuthHeaders } from "./account"; export {
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol"; decryptDm,
import { getCurrentKeys } from "./account"; fetchDMHistory,
import { request } from "@/core/websocket"; sendDMViaWebSocket,
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; sendDmWithFiles,
import { b64, ub64 } from "@/utils/utils"; editDmEnvelope,
import { fetchUserPublicKey } from "./crypto"; deleteDmEnvelope,
import { fetchUsers, searchUsers } from "./users"; fetchDMConversations,
fetchUsers,
searchUsers,
fetchUserPublicKey
} from "./dmApi";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> { export type { DMConversationResponse } from "./dmApi";
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 || [];
}
+201 -62
View File
@@ -1,62 +1,163 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account"; import api from "@/core/api";
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol"; import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { getCurrentKeys } from "./account"; import { randomBytes } from "@/utils/crypto/kdf";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils"; import { b64 } from "@/utils/utils";
import { fetchUserPublicKey } from "./crypto"; import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { fetchUsers, searchUsers } from "./users"; import { useUserStore } from "@/state/user";
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> { export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); if (!user?.id) {
throw new Error("User not authenticated");
}
// Obtain the key if (!envelope.ciphertext) {
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); throw new Error("DM envelope missing ciphertext");
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 signalService = new SignalProtocolService(user.id.toString());
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg); // 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[]> { 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}`, { 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 []; if (!response.ok) return [];
const data = await response.json(); const data = await response.json();
return data.messages || []; 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> { export async function sendDMViaWebSocket(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); if (!user?.id) {
throw new Error("User not authenticated");
}
// Encryption key const signalService = new SignalProtocolService(user.id.toString());
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 // Check if we have a session, if not, fetch prekey bundle and establish one
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); const hasSession = await signalService.hasSession(recipientId);
const wrap = await aesGcmEncrypt(wk, mk); 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 = { const payload: SendDMRequest = {
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), iv: "", // Not used for Signal Protocol
ciphertext: b64(encMsg.ciphertext), ciphertext: paddedCiphertext, // Padded Signal Protocol message
salt: b64(wkSalt), salt: "", // Not used for Signal Protocol
iv2: b64(wrap.iv), iv2: "", // Not used for Signal Protocol
wrappedMk: b64(wrap.ciphertext) wrappedMk: "" // Not used for Signal Protocol
}; };
if (replyToId) payload.replyToId = replyToId; 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> { export async function sendDmWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); 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 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 form = new FormData();
const names: string[] = []; const names: string[] = [];
@@ -112,30 +229,48 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext), ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt), salt: "", // Not used for Signal Protocol
iv2: b64(wrap.iv), iv2: "", // Not used for Signal Protocol
wrappedMk: b64(wrap.ciphertext) wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope)); } satisfies BaseDmEnvelope));
await fetch(`${API_BASE_URL}/dm/send`, { await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST", method: "POST",
headers: getAuthHeaders(token, false), headers: api.user.auth.getAuthHeaders(token, false),
body: form body: form
}); });
} }
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> { export async function editDmEnvelope(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); 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 mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); // Encrypt the master key using Signal Protocol
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); const mkBase64 = b64(mk);
const wk = await importAesGcmKey(wkRaw); 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 encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({ await request({
type: "dmEdit", type: "dmEdit",
@@ -144,9 +279,9 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string,
id, id,
iv: b64(encMsg.iv), iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext), ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv), iv2: "", // Not used for Signal Protocol
wrappedMk: b64(wrap.ciphertext), wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
salt: b64(wkSalt) salt: "" // Not used for Signal Protocol
} }
} as DMEditRequest); } as DMEditRequest);
} }
@@ -165,9 +300,13 @@ export interface DMConversationResponse {
unreadCount: number; unreadCount: number;
} }
// Re-export for convenience
export { fetchUsers, searchUsers } from "./users";
export { fetchUserPublicKey } from "./crypto/identity";
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> { export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, { const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true) headers: api.user.auth.getAuthHeaders(token, true)
}); });
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
+5 -1
View File
@@ -7,6 +7,8 @@ import * as userSearch from "./user/search";
import * as cryptoPrekeys from "./crypto/prekeys"; import * as cryptoPrekeys from "./crypto/prekeys";
import * as cryptoIdentity from "./crypto/identity"; import * as cryptoIdentity from "./crypto/identity";
import * as cryptoBackup from "./crypto/backup"; 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 moderationBlocklist from "./moderation/blocklist";
import * as moderationUsers from "./moderation/users"; import * as moderationUsers from "./moderation/users";
import * as callsModule from "./calls"; import * as callsModule from "./calls";
@@ -27,7 +29,9 @@ const api = {
crypto: { crypto: {
prekeys: cryptoPrekeys, prekeys: cryptoPrekeys,
identity: cryptoIdentity, identity: cryptoIdentity,
backup: cryptoBackup backup: cryptoBackup,
sessions: cryptoSessions,
messagePlaintexts: cryptoMessagePlaintexts
}, },
moderation: { moderation: {
blocklist: moderationBlocklist, blocklist: moderationBlocklist,
+3 -1
View File
@@ -1,7 +1,9 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types"; 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 { b64, ub64 } from "@/utils/utils";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity"; import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup"; import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
+57 -158
View File
@@ -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 { b64, ub64 } from "@/utils/utils";
import api from "@/core/api"; import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import type { WrappedSessionKeyPayload } from "@/core/types"; import { useUserStore } from "@/state/user";
import { fetchPreKeyBundle } from "@/core/api/crypto";
import { getAuthToken } from "@/core/api/account";
export interface CallSessionKey { export interface CallSessionKey {
key: Uint8Array; key: Uint8Array;
hash: string; // For emoji display 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 * Generates a new call session key for end-to-end encryption
* @returns Promise that resolves to a session key with its hash for display * @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 * Generate 4 emojis representing the call session key
*/ */
@@ -174,63 +71,65 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
return emojis; 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 * Encrypts a call session key using Signal Protocol
* @param recipientPublicKeyB64 - The recipient's public key in base64 format * @param recipientId - The recipient's user ID
* @param sessionKey - The session key to wrap * @param sessionKey - The session key to encrypt
* @returns Promise that resolves to the wrapped session key payload * @returns Promise that resolves to encrypted session key data
*/ */
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> { export async function encryptCallSessionKey(recipientId: number, sessionKey: Uint8Array): Promise<{ type: number; body: string }> {
const keys = api.user.auth.getCurrentKeys(); const user = useUserStore.getState().user.currentUser;
if (!keys) throw new Error("Keys not initialized"); if (!user?.id) {
throw new Error("User not authenticated");
}
const salt = randomBytes(16); const signalService = new SignalProtocolService(user.id.toString());
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO); // Ensure we have a session with the recipient
const wk = await importAesGcmKey(wkRaw); const hasSession = await signalService.hasSession(recipientId);
const wrap = await aesGcmEncrypt(wk, sessionKey); if (!hasSession) {
return { // Fetch prekey bundle and establish session
salt: b64(salt), const token = getAuthToken();
iv2: b64(wrap.iv), if (!token) {
wrapped: b64(wrap.ciphertext) 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( export async function decryptCallSessionKey(senderId: number, encryptedKey: { type: number; body: string }): Promise<Uint8Array> {
senderPublicKeyB64: string, const user = useUserStore.getState().user.currentUser;
sessionKeyHash: string, if (!user?.id) {
isInitiator: boolean throw new Error("User not authenticated");
): Promise<CallSessionKey> { }
const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Create shared secret using ECDH const signalService = new SignalProtocolService(user.id.toString());
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
// Derive the session key from the shared secret // Decrypt using Signal Protocol
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator); const decryptedString = await signalService.decryptMessage(senderId, encryptedKey);
}
// Convert back to Uint8Array
/** const sessionKey = new Uint8Array(
* Unwraps a call session key received from a sender using ECDH key exchange atob(decryptedString).split("").map(c => c.charCodeAt(0))
* @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 return sessionKey;
*/
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);
} }
+8 -4
View File
@@ -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"; import * as WebRTC from "./webrtc";
export interface CallState { export interface CallState {
@@ -133,12 +133,16 @@ export class CallSignalingHandler {
private handleCallSessionKey(message: CallSignalingMessage) { private handleCallSessionKey(message: CallSignalingMessage) {
const state = this.getState(); const state = this.getState();
const { sessionKeyHash, data } = message; const { sessionKeyHash } = message;
const data = message.data as CallSessionKeyData;
if (sessionKeyHash) { if (sessionKeyHash) {
state.setCallSessionKeyHash(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);
} }
} }
+16 -29
View File
@@ -1,8 +1,8 @@
import api from "@/core/api"; import api from "@/core/api";
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; import type { CallSignalingMessage } from "@/core/types";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; import { encryptCallSessionKey, decryptCallSessionKey, rotateCallSessionKey } from "./encryption";
import { importAesGcmKey } from "@fromchat/protocol"; import { importAesGcmKey } from "@/utils/crypto/symmetric";
import E2EEWorker from "./e2eeWorker?worker"; import E2EEWorker from "./e2eeWorker?worker";
import { delay } from "@/utils/utils"; 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> { export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
try { try {
const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!); // Encrypt session key using Signal Protocol
if (!recipientPublicKey) { const encrypted = await encryptCallSessionKey(userId, sessionKey);
console.warn("No recipient public key for", userId);
return;
}
const wrapped = await wrapCallSessionKeyForRecipient(recipientPublicKey, sessionKey);
await sendSignalingMessage({ await sendSignalingMessage({
type: "call_session_key", type: "call_session_key",
fromUserId: 0, fromUserId: 0,
toUserId: userId, toUserId: userId,
sessionKeyHash, sessionKeyHash,
data: { wrappedSessionKey: wrapped } data: { encryptedSessionKey: encrypted }
}); });
} catch (e) { } 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( export async function receiveWrappedSessionKey(
fromUserId: number, fromUserId: number,
wrappedPayload: WrappedSessionKeyPayload, encryptedKey: { type: number; body: string }
sessionKeyHash?: string
): Promise<void> { ): Promise<void> {
try { try {
const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!); if (!encryptedKey) {
if (!senderPublicKey) { console.error("Missing encrypted session key");
console.error("Failed to get sender public key");
return;
}
if (!wrappedPayload || !sessionKeyHash) {
console.error("Missing wrapped payload or session key hash");
return; return;
} }
// Unwrap the session key from the encrypted payload // Decrypt the session key using Signal Protocol
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, { const sessionKey = await decryptCallSessionKey(fromUserId, encryptedKey);
salt: wrappedPayload.salt,
iv2: wrappedPayload.iv2,
wrapped: wrappedPayload.wrapped
});
// Use the unwrapped session key directly (both sides should have the same key) // Use the decrypted session key for media encryption
await setSessionKey(fromUserId, unwrappedSessionKey); await setSessionKey(fromUserId, sessionKey);
} catch (e) { } catch (e) {
console.error("Failed to unwrap session key:", e); console.error("Failed to decrypt session key:", e);
} }
} }
+1 -7
View File
@@ -505,7 +505,7 @@ export interface CallEndData {
} }
export interface CallSessionKeyData { export interface CallSessionKeyData {
wrappedSessionKey?: WrappedSessionKeyPayload; encryptedSessionKey: { type: number; body: string };
} }
export interface CallVideoToggleData { export interface CallVideoToggleData {
@@ -526,12 +526,6 @@ export interface CallScreenShareToggleMessageData {
data: CallScreenShareToggleData; data: CallScreenShareToggleData;
} }
export interface WrappedSessionKeyPayload {
salt: string;
iv2: string;
wrapped: string;
}
export interface CallVideoToggleMessage extends CallSignalingMessage { export interface CallVideoToggleMessage extends CallSignalingMessage {
type: "call_video_toggle"; type: "call_video_toggle";
data: CallVideoToggleData; data: CallVideoToggleData;
+9 -21
View File
@@ -5,7 +5,7 @@
* @version 1.0.0 * @version 1.0.0
*/ */
import { request } from "./websocket"; import { send } from "./websocket";
import type { import type {
TypingWebSocketMessage, TypingWebSocketMessage,
StopTypingWebSocketMessage, StopTypingWebSocketMessage,
@@ -39,7 +39,6 @@ export class TypingManager {
async sendTyping(): Promise<void> { async sendTyping(): Promise<void> {
if (!this.authToken) return; if (!this.authToken) return;
try {
const message: TypingRequest = { const message: TypingRequest = {
type: "typing", type: "typing",
credentials: { credentials: {
@@ -49,11 +48,9 @@ export class TypingManager {
data: {} data: {}
}; };
await request(message); // Fire-and-forget - don't wait for response
send(message);
this.scheduleStopTyping("public"); this.scheduleStopTyping("public");
} catch (error) {
console.error("Failed to send typing indicator:", error);
}
} }
/** /**
@@ -62,7 +59,6 @@ export class TypingManager {
async sendStopTyping(): Promise<void> { async sendStopTyping(): Promise<void> {
if (!this.authToken) return; if (!this.authToken) return;
try {
const message: StopTypingRequest = { const message: StopTypingRequest = {
type: "stopTyping", type: "stopTyping",
credentials: { credentials: {
@@ -72,11 +68,9 @@ export class TypingManager {
data: {} data: {}
}; };
await request(message); // Fire-and-forget - don't wait for response
send(message);
this.clearStopTypingTimeout("public"); this.clearStopTypingTimeout("public");
} catch (error) {
console.error("Failed to send stop typing indicator:", error);
}
} }
/** /**
@@ -85,7 +79,6 @@ export class TypingManager {
async sendDmTyping(recipientId: number): Promise<void> { async sendDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return; if (!this.authToken) return;
try {
const message: DmTypingRequest = { const message: DmTypingRequest = {
type: "dmTyping", type: "dmTyping",
credentials: { credentials: {
@@ -97,11 +90,9 @@ export class TypingManager {
} }
}; };
await request(message); // Fire-and-forget - don't wait for response
send(message);
this.scheduleStopDmTyping(recipientId); this.scheduleStopDmTyping(recipientId);
} catch (error) {
console.error("Failed to send DM typing indicator:", error);
}
} }
/** /**
@@ -110,7 +101,6 @@ export class TypingManager {
async sendStopDmTyping(recipientId: number): Promise<void> { async sendStopDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return; if (!this.authToken) return;
try {
const message: StopDmTypingRequest = { const message: StopDmTypingRequest = {
type: "stopDmTyping", type: "stopDmTyping",
credentials: { credentials: {
@@ -122,11 +112,9 @@ export class TypingManager {
} }
}; };
await request(message); // Fire-and-forget - don't wait for response
send(message);
this.clearStopTypingTimeout(`dm_${recipientId}`); this.clearStopTypingTimeout(`dm_${recipientId}`);
} catch (error) {
console.error("Failed to send stop DM typing indicator:", error);
}
} }
/** /**
+4 -35
View File
@@ -6,7 +6,6 @@
*/ */
import { openDB, type IDBPDatabase } from "idb"; import { openDB, type IDBPDatabase } from "idb";
import type { WebSocketCredentials, WebSocketMessage } from "./types";
interface UpdateMessage<T = any> { interface UpdateMessage<T = any> {
type: string; type: string;
@@ -72,28 +71,18 @@ export async function setLastSequence(seq: number): Promise<void> {
* Process a batched updates message * Process a batched updates message
* @param message - The batched updates message from the server * @param message - The batched updates message from the server
* @param handler - Function to handle individual updates * @param handler - Function to handle individual updates
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
*/ */
export async function processBatchedUpdates( export async function processBatchedUpdates(
message: BatchedUpdatesMessage, message: BatchedUpdatesMessage,
handler: (update: UpdateMessage) => void, handler: (update: UpdateMessage) => void
requestMissedFn?: (lastSeq: number) => Promise<void>
): Promise<void> { ): Promise<void> {
const { seq, updates } = message; const { seq, updates } = message;
const lastSeq = await getLastSequence(); 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) { if (seq !== lastSeq + 1 && lastSeq > 0) {
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`); const gapSize = seq - (lastSeq + 1);
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq} (gap size: ${gapSize}). Skipping ${gapSize} updates.`);
// Request missing updates if function provided
if (requestMissedFn) {
try {
await requestMissedFn(lastSeq);
} catch (error) {
console.error("Failed to request missed updates for gap:", error);
}
}
} }
// Process all updates in the batch // Process all updates in the batch
@@ -104,23 +93,3 @@ export async function processBatchedUpdates(
// Update last sequence number // Update last sequence number
await setLastSequence(seq); 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
});
}
}
+21 -27
View File
@@ -12,7 +12,7 @@ import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager"; import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager"; import { typingManager } from "./typingManager";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager"; import { processBatchedUpdates } from "./updateManager";
import { getAuthToken } from "@/core/api/user/auth"; import { getAuthToken } from "@/core/api/user/auth";
interface HttpError extends Error { interface HttpError extends Error {
@@ -161,21 +161,10 @@ function setupEventHandlers(): void {
// Handle batched updates // Handle batched updates
if (response.type === "updates" && "seq" in response && "updates" in response) { 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) => { await processBatchedUpdates(response as any, (update) => {
// Route individual updates to appropriate handlers // Route individual updates to appropriate handlers
handleUpdate(update); handleUpdate(update);
}, requestMissedFn); });
return; return;
} }
@@ -250,20 +239,9 @@ function setupEventHandlers(): void {
console.error("Failed to send ping on reconnect:", error); console.error("Failed to send ping on reconnect:", error);
} }
// Send last sequence number and request missed updates on reconnect // Note: We don't request missed updates on reconnect because getUpdates
// Wait a bit for ping to complete authentication // doesn't properly return updates (they're sent directly via WebSocket
await delay(100); // but the client can't handle them). Gaps will be logged but not recovered.
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);
}
} }
} catch (error) { } catch (error) {
console.error("Failed to authenticate on reconnect:", 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 // Initialization
// -------------- // --------------
+38 -9
View File
@@ -1,12 +1,13 @@
import { AuthContainer } from "./Auth"; import { AuthContainer } from "./Auth";
import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react"; import { useState, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { LoginForm } from "./LoginForm"; import { LoginForm } from "./LoginForm";
import { RegisterForm } from "./RegisterForm"; import { RegisterForm } from "./RegisterForm";
import type { Variants, Transition } from "motion/react"; import type { Variants, Transition } from "motion/react";
import styles from "./auth.module.scss"; import styles from "./auth.module.scss";
import { useUserStore } from "@/state/user";
const slideVariants: Variants = { const slideVariants: Variants = {
enter: (direction: number) => ({ enter: (direction: number) => ({
@@ -37,8 +38,8 @@ const slideTransition: Transition = {
export default function AuthPage() { export default function AuthPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { navigate: navigateDownloadApp } = useDownloadAppScreen(); const { navigate: navigateDownloadApp } = useDownloadAppScreen();
if (navigateDownloadApp) return navigateDownloadApp;
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useUserStore();
const [direction, setDirection] = useState(0); const [direction, setDirection] = useState(0);
const prevMode = useRef(searchParams.get("mode") || "login"); const prevMode = useRef(searchParams.get("mode") || "login");
@@ -49,12 +50,33 @@ export default function AuthPage() {
const currentMode = searchParams.get("mode") || "login"; const currentMode = searchParams.get("mode") || "login";
const enteringElementRef = useRef<"login" | "register" | null>(null); const enteringElementRef = useRef<"login" | "register" | null>(null);
const [effectActivated, setEffectActivated] = useState(false); const [effectActivated, setEffectActivated] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
useEffect(() => { useLayoutEffect(() => {
if (prevMode.current !== currentMode) { 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; prevMode.current = currentMode;
setDirection(currentMode === "register" ? 1 : -1);
enteringElementRef.current = currentMode as "login" | "register"; enteringElementRef.current = currentMode as "login" | "register";
// Set height and transition state together
if (measuredHeight !== null) {
setContainerHeight(measuredHeight);
}
setIsTransitioning(true);
} }
}, [currentMode]); }, [currentMode]);
@@ -91,6 +113,12 @@ export default function AuthPage() {
}; };
}, [currentMode]); }, [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") { function switchMode(newMode: "login" | "register") {
navigate(`/auth?mode=${newMode}`, { replace: true }); navigate(`/auth?mode=${newMode}`, { replace: true });
} }
@@ -105,6 +133,7 @@ export default function AuthPage() {
return () => { return () => {
if (currentMode === mode && enteringElementRef.current === mode) { if (currentMode === mode && enteringElementRef.current === mode) {
enteringElementRef.current = null; enteringElementRef.current = null;
setIsTransitioning(false);
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -129,9 +158,6 @@ export default function AuthPage() {
width: "100%", width: "100%",
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`, height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
transition: "height 0.3s ease" transition: "height 0.3s ease"
}}
onAnimationStart={() => {
}} }}
onAnimationEnd={() => { onAnimationEnd={() => {
setContainerHeight("auto"); setContainerHeight("auto");
@@ -151,7 +177,7 @@ export default function AuthPage() {
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)} onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
className={styles.formWrapper} className={styles.formWrapper}
style={{ style={{
position: containerHeight === "auto" ? "relative" : "absolute" position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
}} }}
> >
<LoginForm onSwitchMode={() => switchMode("register")} /> <LoginForm onSwitchMode={() => switchMode("register")} />
@@ -168,6 +194,9 @@ export default function AuthPage() {
transition={slideTransition} transition={slideTransition}
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)} onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
className={styles.formWrapper} className={styles.formWrapper}
style={{
position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
}}
> >
<RegisterForm onSwitchMode={() => switchMode("login")} /> <RegisterForm onSwitchMode={() => switchMode("login")} />
</motion.div> </motion.div>
+47 -1
View File
@@ -79,7 +79,14 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
setIsLoading(true); setIsLoading(true);
console.log("========================================");
console.log("[LoginForm] 🚀 LOGIN FORM SUBMITTED");
console.log("[LoginForm] Username:", username);
console.log("[LoginForm] Has password:", !!password);
console.log("========================================");
try { try {
console.log("[LoginForm] Deriving auth secret...");
const derived = await api.user.auth.deriveAuthSecret(username, password); const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: LoginRequest = { const request: LoginRequest = {
username: username, username: username,
@@ -87,13 +94,52 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
} }
try { try {
console.log("[LoginForm] Calling login API...");
const data = await api.user.auth.login(request); const data = await api.user.auth.login(request);
console.log("[LoginForm] Login successful, user ID:", data.user?.id);
setUser(data.token, data.user); setUser(data.token, data.user);
try { try {
console.log("[LoginForm] Ensuring keys on login...");
await api.user.auth.ensureKeysOnLogin(password, data.token); 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) { } catch (e) {
console.error("Key setup failed:", 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("[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 // Ensure WebSocket is connected and authenticated
+19
View File
@@ -116,10 +116,29 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
try { try {
const data = await api.user.auth.register(request); const data = await api.user.auth.register(request);
setUser(data.token, data.user); setUser(data.token, data.user);
try { try {
await api.user.auth.ensureKeysOnLogin(password, data.token); 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) { } catch (e) {
console.error("Key setup failed:", e); console.error("Key setup failed:", e);
} }
+116 -35
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat"; import { useChatStore } from "@/state/chat";
import api from "@/core/api"; import api from "@/core/api";
import { decryptDm, sendDMViaWebSocket } from "@/core/api/dm";
import type { ConversationResponse } from "@/core/api/chats/dm"; import type { ConversationResponse } from "@/core/api/chats/dm";
import type { User, Message, DmEncryptedJSON } from "@/core/types"; import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "@/core/websocket"; import { websocket } from "@/core/websocket";
@@ -51,6 +52,10 @@ export function useDM() {
if (!user.authToken) return; if (!user.authToken) return;
try { try {
// Wait for session restoration to complete (if in progress)
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
await waitForSessionRestore();
// Get public key // Get public key
const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken); const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return; if (!publicKey) return;
@@ -62,11 +67,22 @@ export function useDM() {
// Find last message // Find last message
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null; let lastPlaintext: string | null = null;
const isAuthor = lastMessage.senderId === user.currentUser?.id;
try { 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) { } catch (error) {
console.error("Failed to decrypt last message:", error); console.error("Failed to get last message preview:", error);
} }
// Calculate unread count // Calculate unread count
@@ -101,6 +117,10 @@ export function useDM() {
usersLoadedRef.current = true; usersLoadedRef.current = true;
setIsLoadingUsers(true); setIsLoadingUsers(true);
try { 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); const conversations = await api.chats.dm.conversations(user.authToken);
// Process conversations and decrypt last messages // Process conversations and decrypt last messages
@@ -110,20 +130,30 @@ export function useDM() {
if (conv.lastMessage) { if (conv.lastMessage) {
try { try {
// Get the public key for the other user const isAuthor = conv.lastMessage.senderId === user.currentUser?.id;
const otherUserId = conv.lastMessage.senderId === user.currentUser?.id const otherUserId = conv.user.id; // the other party in the conversation
? conv.lastMessage.recipientId if (isAuthor) {
: conv.lastMessage.senderId; // 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!); const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) { if (publicKey) {
// Decrypt the last message const decryptedJson = await decryptDm(conv.lastMessage, conv.lastMessage.senderId);
const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!); lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser!.id);
}
} }
} catch (error) { } 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]); }, [user.authToken]);
// Load DM history for active conversation // 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; if (!user.authToken || isLoadingHistory) return;
setIsLoadingHistory(true); setIsLoadingHistory(true);
try { 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 { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50);
const decryptedMessages: Message[] = []; const decryptedMessages: Message[] = [];
let maxIncomingId = 0; let maxIncomingId = 0;
for (const env of messages) { for (const env of messages) {
try { try {
const text = await api.chats.dm.decrypt(env, publicKey); // Check if this is a message sent by the current user
const isAuthor = env.senderId !== userId; 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"; const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
decryptedMessages.push({ decryptedMessages.push({
@@ -204,11 +258,11 @@ export function useDM() {
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]); }, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
// Send DM message // 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; if (!user.authToken) return;
try { try {
await api.chats.dm.send(recipientId, publicKey, content, user.authToken); await sendDMViaWebSocket(recipientId, content, user.authToken);
} catch (error) { } catch (error) {
console.error("Failed to send DM:", error); console.error("Failed to send DM:", error);
} }
@@ -234,7 +288,7 @@ export function useDM() {
}); });
// Load conversation history // Load conversation history
await loadDMHistory(dmUser.id, publicKey); await loadDMHistory(dmUser.id);
} catch (error) { } catch (error) {
console.error("Failed to start DM conversation:", error); console.error("Failed to start DM conversation:", error);
} }
@@ -259,17 +313,23 @@ export function useDM() {
if (userConversation.lastMessage) { if (userConversation.lastMessage) {
try { try {
// Get the public key for the other user const isAuthor = userConversation.lastMessage.senderId === user.currentUser?.id;
const otherUserId = userConversation.lastMessage.senderId === user.currentUser?.id const otherUserId = userId;
? userConversation.lastMessage.recipientId if (isAuthor) {
: userConversation.lastMessage.senderId; 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!); const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) { if (publicKey) {
// Decrypt the last message const decryptedJson = await decryptDm(userConversation.lastMessage, userConversation.lastMessage.senderId);
const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!); lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser!.id);
}
} }
} catch (error) { } catch (error) {
console.error("Failed to decrypt last message for user", userId, error); console.error("Failed to decrypt last message for user", userId, error);
@@ -316,20 +376,30 @@ export function useDM() {
// Update unread count and last message preview // Update unread count and last message preview
try { try {
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!); const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) { if (publicKey) {
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey); const decryptedJson = await decryptDm(envelope, senderId);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content; messageContent = decryptedData.data.content;
}
}
if (messageContent !== null) {
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
setDmUsersState(prev => prev.map(u => setDmUsersState(prev => prev.map(u =>
u.id === otherUserId u.id === otherUserId
? { ? {
...u, ...u,
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount, unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
lastMessage: formattedMessage, lastMessage: formattedMessage
publicKey
} }
: u : u
)); ));
@@ -346,18 +416,29 @@ export function useDM() {
} }
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId; const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
try { try {
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!); const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) { if (publicKey) {
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey); const decryptedJson = await decryptDm(envelope, senderId);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content; messageContent = decryptedData.data.content;
}
}
if (messageContent !== null) {
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
setDmUsersState(prev => prev.map(u => setDmUsersState(prev => prev.map(u =>
u.id === otherUserId u.id === otherUserId
? { ? {
...u, ...u,
lastMessage: formattedMessage, lastMessage: formattedMessage
publicKey
} }
: u : u
)); ));
+226
View File
@@ -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"
}
}));
}
});
+226
View File
@@ -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"
}
}));
}
});
+73
View File
@@ -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;
}
+32 -14
View File
@@ -6,10 +6,12 @@ import { parse } from "marked";
import { escape as escapeHtml } from "he"; import { escape as escapeHtml } from "he";
import { useEffect, useState, useRef, useMemo } from "react"; import { useEffect, useState, useRef, useMemo } from "react";
import api from "@/core/api"; 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 { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile"; import { useProfileStore } from "@/state/profile";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { removePadding } from "@/utils/crypto/obfuscation";
import { ub64 } from "@/utils/utils"; import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -211,7 +213,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}, [message.files, isDm, decryptedFiles]); }, [message.files, isDm, decryptedFiles]);
async function decryptFile(file: Attachment): Promise<string | null> { 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 // Check if already decrypted
if (decryptedFiles.has(file.path)) { if (decryptedFiles.has(file.path)) {
@@ -219,7 +221,6 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
} }
try { try {
// no-op decrypt indicator removed from UI
// Fetch encrypted file // Fetch encrypted file
const response = await fetch(file.path, { const response = await fetch(file.path, {
headers: api.user.auth.getAuthHeaders(user.authToken!) headers: api.user.auth.getAuthHeaders(user.authToken!)
@@ -228,19 +229,36 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
const encryptedData = await response.arrayBuffer(); const encryptedData = await response.arrayBuffer();
// Get current user's keys // Decrypt the master key using Signal Protocol
const keys = api.user.auth.getCurrentKeys(); const signalService = new SignalProtocolService(user.currentUser.id.toString());
if (!keys) throw new Error("Keys not initialized"); const senderId = dmEnvelope.senderId;
// Derive shared secret with the recipient's public key // Remove padding from wrappedMk (backward compatible)
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey)); let wrappedMkStr: string;
try {
wrappedMkStr = removePadding(dmEnvelope.wrappedMk);
} catch {
// If padding removal fails, assume it's an old message without padding
wrappedMkStr = dmEnvelope.wrappedMk;
}
// Derive wrapping key using the salt from the DM envelope // Parse wrappedMk - it's a JSON string containing Signal Protocol encrypted data
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1])); let mk: Uint8Array;
const wk = await importAesGcmKey(wkRaw); try {
const encryptedMk = JSON.parse(wrappedMkStr);
// Unwrap the message key if (encryptedMk.type && encryptedMk.body) {
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk)); // 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 // Decrypt the file using the message key
const iv = new Uint8Array(encryptedData, 0, 12); const iv = new Uint8Array(encryptedData, 0, 12);
@@ -1,10 +1,12 @@
import { MessagePanel } from "./MessagePanel"; import { MessagePanel } from "./MessagePanel";
import api from "@/core/api"; 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 { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types"; import type { UserState, ProfileDialogData } from "@/state/types";
import { formatDMUsername } from "@/pages/chat/hooks/useDM"; import { formatDMUsername } from "@/pages/chat/hooks/useDM";
import { onlineStatusManager } from "@/core/onlineStatusManager"; import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager"; import { typingManager } from "@/core/typingManager";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
export interface DMPanelData { export interface DMPanelData {
userId: number; userId: number;
@@ -17,11 +19,16 @@ export interface DMPanelData {
export class DMPanel extends MessagePanel { export class DMPanel extends MessagePanel {
public dmData: DMPanelData | null = null; public dmData: DMPanelData | null = null;
private messagesLoaded: boolean = false; private messagesLoaded: boolean = false;
private signalService: SignalProtocolService | null = null;
constructor( constructor(
user: UserState user: UserState
) { ) {
super("dm", user); 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 { isDm(): boolean {
@@ -52,10 +59,41 @@ export class DMPanel extends MessagePanel {
clearMessages(): void { clearMessages(): void {
super.clearMessages(); super.clearMessages();
this.messagesLoaded = false; this.messagesLoaded = false;
this.processedMessageIds.clear();
this.failedDecryptionIds.clear();
}
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);
} }
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey);
const username = formatDMUsername( const username = formatDMUsername(
env.senderId, env.senderId,
env.recipientId, env.recipientId,
@@ -103,6 +141,35 @@ export class DMPanel extends MessagePanel {
this.setLoading(true); this.setLoading(true);
try { 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 limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit); const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
const decryptedMessages: Message[] = []; const decryptedMessages: Message[] = [];
@@ -110,19 +177,82 @@ export class DMPanel extends MessagePanel {
for (const env of messages) { for (const env of messages) {
try { 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); decryptedMessages.push(dmMsg);
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) { if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
maxIncomingId = env.id; maxIncomingId = env.id;
} }
} catch (error) { } 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);
}
} }
} }
// Only clear and replace if we actually decrypted something
if (decryptedMessages.length > 0) {
this.clearMessages(); this.clearMessages();
decryptedMessages.forEach(msg => this.addMessage(msg)); 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); this.setHasMoreMessages(has_more);
// Update last read ID // Update last read ID
@@ -149,6 +279,28 @@ export class DMPanel extends MessagePanel {
this.setLoadingMore(true); this.setLoadingMore(true);
try { 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 limit = this.calculateMessageLimit();
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages( const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
this.dmData.userId, this.dmData.userId,
@@ -161,10 +313,60 @@ export class DMPanel extends MessagePanel {
const decryptedMessages: Message[] = []; const decryptedMessages: Message[] = [];
for (const env of newEnvelopes) { for (const env of newEnvelopes) {
try { 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); decryptedMessages.push(dmMsg);
} catch (error) { } catch (error) {
console.error("Error decrypting message:", error); // Silently skip messages that can't be decrypted
} }
} }
@@ -193,22 +395,31 @@ export class DMPanel extends MessagePanel {
} }
const json = JSON.stringify(payload); const json = JSON.stringify(payload);
try {
if (files.length === 0) { if (files.length === 0) {
await api.chats.dm.send( await sendDMViaWebSocket(
this.dmData.userId, this.dmData.userId,
this.dmData.publicKey,
json, json,
this.currentUser.authToken this.currentUser.authToken
); );
} else { } else {
await api.chats.dm.sendWithFiles( await sendDmWithFiles(
this.dmData.userId, this.dmData.userId,
this.dmData.publicKey,
json, json,
files, files,
this.currentUser.authToken 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.");
}
}
} }
// Set DM conversation data // Set DM conversation data
@@ -224,29 +435,103 @@ 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 // Handle incoming WebSocket DM messages
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> { async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
// Only process actual DM messages, not typing indicators or other events
if (response.type === "dmNew" && this.dmData) { if (response.type === "dmNew" && this.dmData) {
const envelope = response.data; 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 this is for the active DM conversation
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) { if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
try { 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 // 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) { if (isOurMessage) {
// This is our message being confirmed, find the temp message and replace it // 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); const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
let tempMsgContent: string | null = null;
for (const tempMsg of tempMessages) { for (const tempMsg of tempMessages) {
if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) { if (tempMsg.runtimeData?.sendingState?.retryData?.content) {
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg); 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; 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); this.addMessage(dmMsg);
// Update last read if it's from the other user // Update last read if it's from the other user
@@ -254,19 +539,27 @@ export class DMPanel extends MessagePanel {
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id)); this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
} }
} catch (error) { } 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) { 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 { try {
// Decrypt new content in-place // Decrypt new content in-place
const plaintext = await api.chats.dm.decrypt( const plaintext = await decryptDm(
{ {
id, id,
senderId: 0, senderId,
recipientId: 0, recipientId,
iv, iv,
ciphertext, ciphertext,
salt, salt,
@@ -274,7 +567,7 @@ export class DMPanel extends MessagePanel {
wrappedMk, wrappedMk,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}, },
this.dmData.publicKey senderId
); );
let content = plaintext; let content = plaintext;
let files: Message["files"] | undefined = undefined; let files: Message["files"] | undefined = undefined;
@@ -311,6 +604,7 @@ export class DMPanel extends MessagePanel {
this.dmData = null; this.dmData = null;
this.messagesLoaded = false; this.messagesLoaded = false;
this.clearMessages(); this.clearMessages();
this.failedDecryptionIds.clear(); // Clear failed decryption tracking
this.updateState({ this.updateState({
id: "dm", id: "dm",
title: "Select a user", title: "Select a user",
@@ -379,7 +673,7 @@ export class DMPanel extends MessagePanel {
reply_to_id: msg?.reply_to?.id ?? undefined 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); console.error("Failed to edit DM:", e);
}); });
} }

Some files were not shown because too many files have changed in this diff Show More