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 -2
View File
@@ -575,5 +575,4 @@ backend/alembic/**
!backend/alembic/env.py
!backend/alembic/script.py.mako
!frontend/src/css/lib
**/*.module.scss.d.ts
.cursor/plans
**/*.module.scss.d.ts
+1 -2
View File
@@ -6,6 +6,5 @@
"**/.husky/_": true,
"**/.venv": 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)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = alembic
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
@@ -84,7 +84,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# sqlalchemy.url is set dynamically from DATABASE_URL environment variable
sqlalchemy.url = sqlite:///./data/database.db
[post_write_hooks]
+1 -1
View File
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
# add your model's MetaData object here
# for 'autogenerate' support
from backend.shared.models import Base
from models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
+91 -118
View File
@@ -1,43 +1,102 @@
import asyncio
import time
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import subprocess
import sys
import os
import httpx
from routes import account, messaging, profile, push, webrtc, devices, moderation
import logging
# Gateway doesn't need direct model access - it's a stateless proxy
# Gateway doesn't need constants - it's a stateless proxy
from backend.shared.utils import get_client_ip
from models import User
from constants import OWNER_USERNAME
from utils import get_client_ip
# Gateway doesn't need database access - it's a stateless proxy
from backend.logging_config import access_logger # noqa: F401 - ensure loggers configured
from backend.security.audit import log_access
from backend.security.rate_limit import limiter
from db import POOL_CONFIG, SessionLocal
from logging_config import access_logger # noqa: F401 - ensure loggers configured
from security.audit import log_access
from security.rate_limit import limiter
from slowapi.middleware import SlowAPIMiddleware
# Service URL mapping for routing
SERVICE_URLS = {
"account": os.getenv("ACCOUNT_SERVICE_URL", "http://account_service:8302"),
"profile": os.getenv("PROFILE_SERVICE_URL", "http://profile_service:8303"),
"devices": os.getenv("DEVICE_SERVICE_URL", "http://device_service:8304"),
"messaging": os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305"),
"push": os.getenv("PUSH_SERVICE_URL", "http://push_service:8306"),
"webrtc": os.getenv("WEBRTC_SERVICE_URL", "http://webrtc_service:8307"),
"moderation": os.getenv("MODERATION_SERVICE_URL", "http://moderation_service:8308"),
}
logger = logging.getLogger("uvicorn.error")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Gateway is a stateless proxy - no database operations or background tasks needed
logger.info("Gateway proxy service initialized - routing to microservices")
# Startup - run migration in separate process to avoid logging interference
try:
logger.info("Starting database migration check...")
# Run migration in a separate process
subprocess.run(
[
sys.executable,
"-c",
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
],
cwd=os.path.dirname(os.path.abspath(__file__))
)
except Exception as e:
logger.error(f"Failed to run database migrations: {e}")
raise
try:
with SessionLocal() as db:
owner = db.query(User).filter(User.username == OWNER_USERNAME).first()
if owner and not owner.verified:
owner.verified = True
db.commit()
logger.info(f"Owner user '{OWNER_USERNAME}' has been verified")
elif owner and owner.verified:
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
else:
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
except Exception as e:
logger.error(f"Failed to ensure owner verification: {e}")
logger.info(
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
POOL_CONFIG["pool_size"],
POOL_CONFIG["max_overflow"],
POOL_CONFIG["pool_timeout"],
POOL_CONFIG["pool_recycle"],
POOL_CONFIG["pool_pre_ping"],
)
# Start the messaging cleanup task
try:
from routes.messaging import messagingManager
messagingManager.start_cleanup_task()
logger.info("Messaging cleanup task started")
except Exception as e:
logger.error(f"Failed to start messaging cleanup task: {e}")
# Reset all rate limits on startup to ensure clean state
# This prevents rate limits from persisting across restarts
try:
from security.rate_limit import reset_all_rate_limits
cleared = reset_all_rate_limits()
if cleared > 0:
logger.info(f"Cleared {cleared} rate limit entries on startup")
except Exception as e:
logger.warning(f"Failed to reset rate limits on startup: {e}")
# Start the rate limit cleanup task
try:
from security.rate_limit import start_rate_limit_cleanup_task
cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task())
logger.info("Rate limit cleanup task started")
except Exception as e:
logger.error(f"Failed to start rate limit cleanup task: {e}")
cleanup_task = None
yield
logger.info("Gateway proxy service shutting down.")
# Shutdown - cancel cleanup task if it exists
if cleanup_task:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
# Инициализация FastAPI
app = FastAPI(title="FromChat", lifespan=lifespan)
@@ -49,16 +108,6 @@ app.add_middleware(SlowAPIMiddleware)
@app.middleware("http")
async def access_logging_middleware(request: Request, call_next):
# Log incoming request and Authorization header presence for debugging auth issues
try:
auth_header = request.headers.get("authorization")
if auth_header:
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
else:
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
except Exception:
pass
start = time.perf_counter()
try:
response = await call_next(request)
@@ -108,87 +157,11 @@ app.add_middleware(
allow_headers=["*"],
)
# Common API endpoints - route to appropriate services (defined first for priority)
@app.api_route("/login", methods=["POST"])
async def login(request: Request):
"""Login endpoint - routes to account service."""
return await _proxy_to_service("account", "login", request)
@app.api_route("/register", methods=["POST"])
async def register(request: Request):
"""Register endpoint - routes to account service."""
return await _proxy_to_service("account", "register", request)
# API routes - route to appropriate microservices
@app.api_route("/account/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_account(path: str, request: Request):
"""Proxy account service requests."""
return await _proxy_to_service("account", path, request)
@app.api_route("/profile/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_profile(path: str, request: Request):
"""Proxy profile service requests."""
return await _proxy_to_service("profile", path, request)
@app.api_route("/devices/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_devices(path: str, request: Request):
"""Proxy device service requests."""
return await _proxy_to_service("devices", path, request)
@app.api_route("/messaging/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_messaging(path: str, request: Request):
"""Proxy messaging service requests."""
return await _proxy_to_service("messaging", path, request)
@app.api_route("/push/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_push(path: str, request: Request):
"""Proxy push service requests."""
return await _proxy_to_service("push", path, request)
@app.api_route("/webrtc/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_webrtc(path: str, request: Request):
"""Proxy WebRTC service requests."""
return await _proxy_to_service("webrtc", path, request)
@app.api_route("/moderation/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_moderation(path: str, request: Request):
"""Proxy moderation service requests."""
return await _proxy_to_service("moderation", path, request)
async def _proxy_to_service(service: str, path: str, request: Request):
"""Helper function to proxy requests to microservices."""
from fastapi.responses import Response
service_url = SERVICE_URLS[service]
target_url = f"{service_url}/{service}/{path}"
# Get request body
body = await request.body()
# Prepare headers (remove host header)
headers = dict(request.headers)
headers.pop("host", None)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
params=request.query_params,
)
# Return response with the same status code and content
content = response.content
return Response(
content=content,
status_code=response.status_code,
headers={"content-type": response.headers.get("content-type", "application/json")}
)
except httpx.RequestError as exc:
logging.error(f"Error communicating with {service} service: {exc}")
raise HTTPException(status_code=503, detail=f"Service {service} unavailable")
# Routes are handled by the catch-all proxy above
# Routes
app.include_router(account.router)
app.include_router(messaging.router)
app.include_router(profile.router)
app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(devices.router, prefix="/devices")
app.include_router(moderation.router)
+1 -17
View File
@@ -5,10 +5,8 @@ from sqlalchemy.orm import Session
from utils import verify_token
from models import User, DeviceSession
from db import SessionLocal
import logging
security = HTTPBearer()
logger = logging.getLogger("uvicorn.error")
# Зависимость для получения сессии БД
def get_db():
@@ -25,17 +23,8 @@ def get_current_user(
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
try:
payload = verify_token(token)
except Exception as e:
logger.warning("get_current_user: token verification error: %s", str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
payload = verify_token(token)
if not payload:
logger.info("get_current_user: verify_token returned empty payload")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
@@ -43,7 +32,6 @@ def get_current_user(
)
user = db.query(User).filter(User.id == payload["user_id"]).first()
if not user:
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
@@ -72,7 +60,6 @@ def get_current_user(
)
if not device_session or device_session.revoked:
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session revoked or not found",
@@ -86,7 +73,6 @@ def get_current_user(
# Session expired due to inactivity - revoke it
device_session.revoked = True
db.commit()
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to inactivity",
@@ -99,7 +85,6 @@ def get_current_user(
# Check if user is suspended
if user.suspended:
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account suspended",
@@ -108,7 +93,6 @@ def get_current_user(
# Check if user is deleted
if user.deleted:
logger.info("get_current_user: account deleted for user_id=%s", user.id)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account deleted",
+6 -6
View File
@@ -1,7 +1,7 @@
from backend.shared.constants import *
from backend.shared.db import *
from backend.shared.models import *
from backend.shared.validation import *
from backend.shared.utils import *
from backend.shared.dependencies import *
from constants import *
from db import *
from models import *
from validation import *
from utils import *
from dependencies import *
from app import *
+42 -76
View File
@@ -8,7 +8,7 @@ from alembic import command
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine
from backend.shared.constants import DATABASE_URL
from constants import DATABASE_URL
import logging
logger = logging.getLogger(__name__)
@@ -19,36 +19,19 @@ def run_migrations():
This function will upgrade the database to the latest migration.
Fully automated - handles all scenarios automatically.
"""
# Get the directory where this script is located
current_dir = os.path.dirname(os.path.abspath(__file__))
try:
# FIRST: Check if database has any application tables (excluding alembic_version)
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import inspect
inspector = inspect(connection)
existing_tables = [table for table in inspector.get_table_names()
if not table.startswith('sqlite_') and table != 'alembic_version']
# If no application tables exist, create them directly from models
if not existing_tables:
logger.info("No application tables found. Creating all tables directly from models...")
from backend.shared.models import Base
Base.metadata.create_all(bind=engine)
logger.info("All tables created successfully from models.")
# Note: Problematic migrations are now cleaned up during Docker build
# Get the directory where this script is located
current_dir = os.path.dirname(os.path.abspath(__file__))
# Create Alembic configuration
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
# Disable Alembic's logging configuration to avoid interfering with FastAPI
alembic_cfg.set_main_option("configure_logging", "false")
# Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Check if any migration files exist
versions_dir = os.path.join(current_dir, "alembic", "versions")
@@ -56,13 +39,6 @@ def run_migrations():
os.makedirs(versions_dir)
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
# If no migration files exist after cleanup, create initial migration
if not migration_files:
logger.info("No migration files found after cleanup. Creating initial migration...")
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
logger.info(f"Created {len(migration_files)} initial migration(s)")
if not migration_files:
logger.info("No migration files found. Creating initial migration...")
@@ -147,11 +123,7 @@ def run_migrations():
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully.")
except Exception as upgrade_error:
error_msg = str(upgrade_error)
# Handle PostgreSQL "already exists" errors gracefully
if "already exists" in error_msg.lower() or "relation" in error_msg.lower() and "exists" in error_msg.lower():
pass
elif "Can't locate revision identified by 'direct_creation'" in error_msg:
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
logger.info("Found 'direct_creation' revision - resetting migration state...")
# Clear the alembic_version table and start fresh
engine = create_engine(DATABASE_URL)
@@ -192,11 +164,10 @@ def run_migrations():
from sqlalchemy import text
connection.execute(text("DELETE FROM alembic_version"))
connection.commit()
# Try upgrade again
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.")
# Note: Index-related errors are now prevented by Docker build cleanup
else:
raise upgrade_error
@@ -303,7 +274,7 @@ def _populate_migration_file(migration_path):
def _generate_migration_from_models():
"""Generate migration content dynamically from SQLAlchemy models."""
from backend.shared.models import Base
from models import Base
import sqlalchemy as sa
from datetime import datetime
@@ -488,12 +459,10 @@ def _get_column_type(column):
def _create_database_directly():
"""Fallback method: create database directly using SQLAlchemy."""
from backend.shared.models import Base
from backend.shared.db import get_engine
from models import Base
from db import engine
from sqlalchemy import text, inspect
engine = get_engine()
# Check existing tables and update schema
with engine.connect() as connection:
inspector = inspect(connection)
@@ -550,38 +519,35 @@ def _create_database_directly():
logger.info(f"Creating table {table_name}")
# Create alembic_version table manually
engine = get_engine(DATABASE_URL)
with engine.connect() as connection:
# Create alembic_version table manually
connection.execute(text("""
CREATE TABLE IF NOT EXISTS alembic_version (
version_num VARCHAR(32) NOT NULL,
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)
"""))
# Get the correct revision ID from existing migration files
current_dir = os.path.dirname(os.path.abspath(__file__))
versions_dir = os.path.join(current_dir, "alembic", "versions")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
if migration_files:
latest_migration = max(migration_files)
migration_path = os.path.join(versions_dir, latest_migration)
with open(migration_path, 'r') as f:
content = f.read()
import re
revision_match = re.search(r"revision: str = '([^']+)'", content)
if revision_match:
revision_id = revision_match.group(1)
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING"))
else:
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
connection.commit()
connection.execute(text("""
CREATE TABLE IF NOT EXISTS alembic_version (
version_num VARCHAR(32) NOT NULL,
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)
"""))
# Get the correct revision ID from existing migration files
current_dir = os.path.dirname(os.path.abspath(__file__))
versions_dir = os.path.join(current_dir, "alembic", "versions")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
if migration_files:
latest_migration = max(migration_files)
migration_path = os.path.join(versions_dir, latest_migration)
with open(migration_path, 'r') as f:
content = f.read()
import re
revision_match = re.search(r"revision: str = '([^']+)'", content)
if revision_match:
revision_id = revision_match.group(1)
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
connection.commit()
def _get_sql_type(column):
+49 -10
View File
@@ -71,6 +71,55 @@ class CryptoBackup(Base):
blob_json = Column(Text, nullable=False)
class SignalPreKeyBundle(Base):
__tablename__ = "signal_prekey_bundle"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
bundle_json = Column(Text, nullable=False) # JSON string of PreKeyBundleData (identity, signed prekey, registration ID)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class SignalPreKey(Base):
__tablename__ = "signal_prekey"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
prekey_id = Column(Integer, nullable=False) # The prekey ID from the client
public_key = Column(Text, nullable=False) # Base64 encoded public key
used = Column(Boolean, default=False, nullable=False, index=True) # Whether this prekey has been used
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (UniqueConstraint('user_id', 'prekey_id', name='_user_prekey_uc'),)
class SignalSession(Base):
__tablename__ = "signal_session"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
recipient_id = Column(Integer, nullable=False, index=True) # The other user in the session
device_id = Column(Integer, default=1, nullable=False) # Device ID (always 1 for now)
encrypted_session_data = Column(Text, nullable=False) # Encrypted session record (JSON with salt, iv, ciphertext)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
__table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_uc'),)
class SentMessagePlaintext(Base):
"""Stores encrypted plaintexts of sent messages for history display"""
__tablename__ = "sent_message_plaintext"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
message_id = Column(Integer, nullable=False, index=True) # DM envelope ID
recipient_id = Column(Integer, nullable=False, index=True) # The recipient of the message
encrypted_data = Column(Text, nullable=False) # Encrypted plaintext (JSON with salt, iv, ciphertext)
created_at = Column(DateTime, default=datetime.now, index=True)
__table_args__ = (UniqueConstraint('user_id', 'message_id', name='_user_message_uc'),)
class DMEnvelope(Base):
__tablename__ = "dm_envelope"
@@ -113,16 +162,6 @@ class PushSubscription(Base):
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class FcmToken(Base):
__tablename__ = "fcm_token"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
token = Column(Text, nullable=False, unique=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Reaction(Base):
__tablename__ = "reaction"
+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
pydantic>=2.11.7
sqlalchemy>=2.0.43
psycopg2-binary>=2.9.9
bcrypt>=4.3.0
websockets>=15.0.1
Pillow>=10.0.0
@@ -15,5 +14,3 @@ user-agents>=2.2.0
httpx>=0.27.2
rich>=13.9.4
slowapi>=0.1.9
firebase_admin>=7.1.0
PyNaCl>=1.5.0
+505 -61
View File
@@ -8,16 +8,16 @@ import uuid
from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from backend.shared.constants import OWNER_USERNAME
from backend.shared.dependencies import get_current_user, get_db
from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup
from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip
from backend.shared.validation import is_valid_password, is_valid_username, is_valid_display_name
from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession, SentMessagePlaintext
from utils import create_token, get_password_hash, verify_password, get_client_ip
from validation import is_valid_password, is_valid_username, is_valid_display_name
import os
from backend.security.audit import log_security
from backend.security.profanity import contains_profanity
from backend.security.rate_limit import rate_limit_per_ip
from security.audit import log_security
from security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip
router = APIRouter()
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
@@ -39,24 +39,21 @@ def _record_failed_login(identifier: str) -> bool:
def _reset_failed_logins(identifier: str) -> None:
_failed_login_attempts.pop(identifier, None)
def _is_admin(user: User) -> bool:
return user.id == 1
def convert_user(user: User) -> dict:
return {
"id": user.id,
"created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(),
"online": user.is_online,
"online": user.online,
"username": user.username,
"display_name": user.display_name,
"profile_picture": user.avatar_url,
"profile_picture": user.profile_picture,
"bio": user.bio,
"admin": _is_admin(user),
"admin": user.username == OWNER_USERNAME,
"verified": user.verified,
"suspended": user.suspended,
"suspended": user.suspended or False,
"suspension_reason": user.suspension_reason,
"deleted": user.deleted
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
}
@router.get("/check_auth")
@@ -64,7 +61,7 @@ def check_auth(current_user: User = Depends(get_current_user)):
return {
"authenticated": True,
"username": current_user.username,
"admin": _is_admin(current_user)
"admin": current_user.username == OWNER_USERNAME
}
@@ -77,7 +74,7 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(login_request.password.strip(), user.hashed_password):
if not user or not verify_password(login_request.password.strip(), user.password_hash):
log_security(
"login_failed",
severity="warning",
@@ -112,9 +109,30 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
detail="Неверное имя пользователя или пароль"
)
# Generate session ID for JWT (device session will be created on first device service access)
# Create device session and embed into JWT
raw_ua = request.headers.get("user-agent")
device_name = request.headers.get("x-device-name")
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=user.id,
raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
browser_name=(ua.browser.family or None),
browser_version=(ua.browser.version_string or None),
brand=(ua.device.brand or None),
model=(ua.device.model or None),
session_id=session_id,
created_at=datetime.now(),
last_seen=datetime.now(),
revoked=False,
)
db.add(device)
user.online = True
user.last_seen = datetime.now()
db.commit()
@@ -127,19 +145,15 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
for identifier in identifiers:
_reset_failed_logins(identifier)
# Parse user agent for logging
ua = parse_ua(raw_ua or "")
device_type = "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"
log_security(
"login_success",
username=user.username,
user_id=user.id,
ip=client_ip,
session_id=session_id,
device=device_type,
os=ua.os.family,
browser=ua.browser.family,
device=device.device_type,
os=device.os_name,
browser=device.browser_name,
)
return {
@@ -163,6 +177,13 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
# Determine if owner already exists
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
# If owner not yet registered, only allow the owner to register
if not owner_exists and username != OWNER_USERNAME:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Регистрация временно закрыта до регистрации владельца"
)
# Validate input
if not is_valid_username(username):
raise HTTPException(
@@ -198,6 +219,13 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
detail="Пароли не совпадают"
)
# After owner exists, disallow registering the reserved owner username via public registration
if owner_exists and username == OWNER_USERNAME:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Это имя пользователя зарезервировано"
)
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
raise HTTPException(
@@ -213,9 +241,8 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
new_user = User(
username=username,
display_name=display_name,
hashed_password=hashed_password,
salt="", # Not used since bcrypt includes salt in hash
is_online=True,
password_hash=hashed_password,
online=True,
last_seen=datetime.now(),
verified=is_owner
)
@@ -224,13 +251,32 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
db.commit()
db.refresh(new_user)
# Generate a temporary session ID for the token (device session will be created on first device service access)
# Create initial device session
raw_ua = request.headers.get("user-agent")
device_name = request.headers.get("x-device-name")
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=new_user.id,
raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
browser_name=(ua.browser.family or None),
browser_version=(ua.browser.version_string or None),
brand=(ua.device.brand or None),
model=(ua.device.model or None),
session_id=session_id,
created_at=datetime.now(),
last_seen=datetime.now(),
revoked=False,
)
db.add(device)
db.commit()
token = create_token(new_user.id, new_user.username, session_id)
# Parse user agent for logging
raw_ua = request.headers.get("user-agent")
ua = parse_ua(raw_ua or "")
os_name = ua.os.family or "Unknown OS"
if ua.os.version_string:
os_name = f"{os_name} {ua.os.version_string}"
@@ -309,7 +355,7 @@ def delete_user_as_owner(
db: Session = Depends(get_db)
):
# Only owner can delete users
if _is_admin(current_user):
if current_user.username != OWNER_USERNAME:
raise HTTPException(status_code=403, detail="Only owner can perform this action")
user = db.query(User).filter(User.id == user_id).first()
@@ -317,11 +363,11 @@ def delete_user_as_owner(
raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting the owner account via API
if _is_admin(user):
if user.username == OWNER_USERNAME:
raise HTTPException(status_code=400, detail="Cannot delete owner account")
# Manually delete user's messages to satisfy FK constraints
from backend.shared.models import Message # local import to avoid circular
from models import Message # local import to avoid circular
db.query(Message).filter(Message.user_id == user.id).delete()
db.delete(user)
@@ -345,14 +391,14 @@ def logout(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Revoke current session - TODO: Move to device service
# from backend.shared.utils import verify_token as _verify_token
# payload = _verify_token(credentials.credentials)
# if payload and payload.get("session_id"):
# db.query(DeviceSession).filter(
# DeviceSession.user_id == current_user.id,
# DeviceSession.session_id == payload["session_id"],
# ).update({DeviceSession.revoked: True})
# Revoke current session
from utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials)
if payload and payload.get("session_id"):
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id == payload["session_id"],
).update({DeviceSession.revoked: True})
current_user.online = False
current_user.last_seen = datetime.now()
@@ -364,7 +410,7 @@ def logout(
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
session_id=None, # TODO: Get session_id from device service
session_id=payload.get("session_id") if payload else None,
)
return {
@@ -383,25 +429,25 @@ def change_password(
db: Session = Depends(get_db)
):
# Verify current derived password against stored hash
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.hashed_password):
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
# Update password hash to hash of new derived password
current_user.hashed_password = get_password_hash(password_request.newPasswordDerived.strip())
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip())
db.commit()
# Optionally revoke all other sessions, keeping the current one - TODO: Move to device service
# if password_request.logoutAllExceptCurrent:
# from backend.shared.utils import verify_token as _verify_token
# payload = _verify_token(credentials.credentials)
# if not payload:
# raise HTTPException(status_code=401, detail="Invalid token")
# current_session_id = payload.get("session_id")
# db.query(DeviceSession).filter(
# DeviceSession.user_id == current_user.id,
# DeviceSession.session_id != current_session_id,
# ).update({DeviceSession.revoked: True})
# db.commit()
# Optionally revoke all other sessions, keeping the current one
if password_request.logoutAllExceptCurrent:
from utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
current_session_id = payload.get("session_id")
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id != current_session_id,
).update({DeviceSession.revoked: True})
db.commit()
client_ip = get_client_ip(request)
log_security(
@@ -433,6 +479,404 @@ def get_public_key_of(request: Request, user_id: int, current_user: User = Depen
return {"publicKey": row.public_key_b64 if row else None}
@router.post("/crypto/signal/prekey-bundle")
@rate_limit_per_ip("10/minute")
def upload_prekey_bundle(
request: Request,
payload: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload Signal Protocol prekey bundle for the current user"""
from models import SignalPreKeyBundle, SignalPreKey
import json
bundle = payload.get("bundle")
if not bundle:
raise HTTPException(status_code=400, detail="bundle required")
# Validate bundle structure
if not isinstance(bundle, dict):
raise HTTPException(status_code=400, detail="bundle must be a JSON object")
# Validate required fields
required_fields = ["registrationId", "identityKey", "signedPreKey"]
for field in required_fields:
if field not in bundle:
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]:
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
# Store bundle (identity key, signed prekey, registration ID) - without the one-time prekey
bundle_without_prekey = {
"registrationId": bundle["registrationId"],
"identityKey": bundle["identityKey"],
"signedPreKey": bundle["signedPreKey"]
}
bundle_json = json.dumps(bundle_without_prekey)
if len(bundle_json) > 50000: # 50KB limit
raise HTTPException(status_code=400, detail="Bundle too large")
# Store or update the bundle
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
if row:
row.bundle_json = bundle_json
row.updated_at = datetime.now()
else:
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
db.add(row)
# Store the one-time prekey if provided
if "preKey" in bundle and bundle["preKey"]:
prekey = bundle["preKey"]
if isinstance(prekey, dict) and "keyId" in prekey and "publicKey" in prekey:
# Check if this prekey already exists
existing = db.query(SignalPreKey).filter(
SignalPreKey.user_id == current_user.id,
SignalPreKey.prekey_id == prekey["keyId"]
).first()
if existing:
# Update existing prekey (mark as unused if it was used)
existing.public_key = prekey["publicKey"]
existing.used = False
existing.created_at = datetime.now()
else:
# Add new prekey
new_prekey = SignalPreKey(
user_id=current_user.id,
prekey_id=prekey["keyId"],
public_key=prekey["publicKey"],
used=False
)
db.add(new_prekey)
db.commit()
return {"status": "ok"}
@router.post("/crypto/signal/prekeys/bulk")
@rate_limit_per_ip("10/minute")
def upload_prekeys_bulk(
request: Request,
payload: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload multiple Signal Protocol prekeys in one request"""
from models import SignalPreKeyBundle, SignalPreKey
import json
base_bundle = payload.get("baseBundle")
prekeys = payload.get("prekeys", [])
if not base_bundle:
raise HTTPException(status_code=400, detail="baseBundle required")
if not isinstance(prekeys, list):
raise HTTPException(status_code=400, detail="prekeys must be an array")
# Validate base bundle structure
if not isinstance(base_bundle, dict):
raise HTTPException(status_code=400, detail="baseBundle must be a JSON object")
# Validate required fields
required_fields = ["registrationId", "identityKey", "signedPreKey"]
for field in required_fields:
if field not in base_bundle:
raise HTTPException(status_code=400, detail=f"Missing required field in baseBundle: {field}")
if not isinstance(base_bundle["signedPreKey"], dict) or "keyId" not in base_bundle["signedPreKey"]:
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
# Store or update the base bundle (identity key, signed prekey, registration ID)
bundle_without_prekey = {
"registrationId": base_bundle["registrationId"],
"identityKey": base_bundle["identityKey"],
"signedPreKey": base_bundle["signedPreKey"]
}
bundle_json = json.dumps(bundle_without_prekey)
if len(bundle_json) > 50000: # 50KB limit
raise HTTPException(status_code=400, detail="Bundle too large")
# Store or update the bundle
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
if row:
row.bundle_json = bundle_json
row.updated_at = datetime.now()
else:
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
db.add(row)
# Store all prekeys
for prekey in prekeys:
if not isinstance(prekey, dict) or "keyId" not in prekey or "publicKey" not in prekey:
continue # Skip invalid prekeys
# Check if this prekey already exists
existing = db.query(SignalPreKey).filter(
SignalPreKey.user_id == current_user.id,
SignalPreKey.prekey_id == prekey["keyId"]
).first()
if existing:
# Update existing prekey (mark as unused if it was used)
existing.public_key = prekey["publicKey"]
existing.used = False
existing.created_at = datetime.now()
else:
# Add new prekey
new_prekey = SignalPreKey(
user_id=current_user.id,
prekey_id=prekey["keyId"],
public_key=prekey["publicKey"],
used=False
)
db.add(new_prekey)
db.commit()
return {"status": "ok", "uploaded": len(prekeys)}
@router.get("/crypto/signal/prekey-bundle")
def get_prekey_bundle(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get Signal Protocol prekey bundle for the current user"""
from models import SignalPreKeyBundle
import json
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
if not row:
raise HTTPException(status_code=404, detail="Prekey bundle not found")
try:
bundle = json.loads(row.bundle_json)
return {"bundle": bundle}
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Invalid bundle data")
@router.get("/crypto/signal/prekey-bundle/of/{user_id}")
@rate_limit_per_ip("100/minute")
def get_prekey_bundle_of(
request: Request,
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get Signal Protocol prekey bundle for another user with prekey rotation"""
from models import SignalPreKeyBundle, SignalPreKey
import json
# Get the base bundle (identity key, signed prekey, registration ID)
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first()
if not row:
raise HTTPException(status_code=404, detail="Prekey bundle not found")
try:
bundle = json.loads(row.bundle_json)
# Find an unused prekey for this user
unused_prekey = db.query(SignalPreKey).filter(
SignalPreKey.user_id == user_id,
SignalPreKey.used == False
).order_by(SignalPreKey.created_at.asc()).first()
if unused_prekey:
# Mark this prekey as used (atomic operation)
unused_prekey.used = True
db.commit()
# Add the prekey to the bundle
bundle["preKey"] = {
"keyId": unused_prekey.prekey_id,
"publicKey": unused_prekey.public_key
}
else:
# No unused prekeys available - return bundle without prekey
# The client will need to establish a session using the signed prekey only
pass
return {"bundle": bundle}
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Invalid bundle data")
@router.post("/crypto/signal/sessions")
@rate_limit_per_ip("100/minute")
def upload_signal_sessions(
request: Request,
payload: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload encrypted Signal Protocol sessions for the current user"""
import json
from datetime import datetime
sessions = payload.get("sessions")
if not isinstance(sessions, list):
raise HTTPException(status_code=400, detail="sessions must be a list")
uploaded_count = 0
for session_data in sessions:
if not isinstance(session_data, dict):
continue
recipient_id = session_data.get("recipientId")
device_id = session_data.get("deviceId", 1)
encrypted_data = session_data.get("encryptedData")
if not recipient_id or not encrypted_data:
continue
try:
# Validate encrypted_data is valid JSON
json.loads(encrypted_data)
except (json.JSONDecodeError, TypeError):
continue
# Store or update session
existing = db.query(SignalSession).filter(
SignalSession.user_id == current_user.id,
SignalSession.recipient_id == recipient_id,
SignalSession.device_id == device_id
).first()
if existing:
existing.encrypted_session_data = encrypted_data
existing.updated_at = datetime.now()
else:
new_session = SignalSession(
user_id=current_user.id,
recipient_id=recipient_id,
device_id=device_id,
encrypted_session_data=encrypted_data
)
db.add(new_session)
uploaded_count += 1
db.commit()
return {"status": "ok", "uploaded_count": uploaded_count}
@router.get("/crypto/signal/sessions")
@rate_limit_per_ip("60/minute")
def get_signal_sessions(
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all encrypted Signal Protocol sessions for the current user"""
sessions = db.query(SignalSession).filter(
SignalSession.user_id == current_user.id
).all()
return {
"sessions": [
{
"recipientId": s.recipient_id,
"deviceId": s.device_id,
"encryptedData": s.encrypted_session_data,
"updatedAt": s.updated_at.isoformat()
}
for s in sessions
]
}
@router.post("/crypto/signal/message-plaintexts")
@rate_limit_per_ip("100/minute")
def upload_message_plaintexts(
request: Request,
payload: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload encrypted plaintexts of sent messages"""
import json
from datetime import datetime
messages = payload.get("messages")
if not isinstance(messages, list):
raise HTTPException(status_code=400, detail="messages must be a list")
uploaded_count = 0
for msg_data in messages:
if not isinstance(msg_data, dict):
continue
message_id = msg_data.get("messageId")
recipient_id = msg_data.get("recipientId")
encrypted_data = msg_data.get("encryptedData")
if not message_id or not recipient_id or not encrypted_data:
continue
try:
# Validate encrypted_data is valid JSON
json.loads(encrypted_data)
except (json.JSONDecodeError, TypeError):
continue
# Store or update plaintext
existing = db.query(SentMessagePlaintext).filter(
SentMessagePlaintext.user_id == current_user.id,
SentMessagePlaintext.message_id == message_id
).first()
if existing:
existing.encrypted_data = encrypted_data
else:
new_plaintext = SentMessagePlaintext(
user_id=current_user.id,
message_id=message_id,
recipient_id=recipient_id,
encrypted_data=encrypted_data
)
db.add(new_plaintext)
uploaded_count += 1
db.commit()
return {"status": "ok", "uploaded_count": uploaded_count}
@router.get("/crypto/signal/message-plaintexts")
@rate_limit_per_ip("60/minute")
def get_message_plaintexts(
request: Request,
recipient_id: int | None = None, # Optional filter by recipient
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get encrypted plaintexts of sent messages for the current user"""
query = db.query(SentMessagePlaintext).filter(
SentMessagePlaintext.user_id == current_user.id
)
if recipient_id is not None:
query = query.filter(SentMessagePlaintext.recipient_id == recipient_id)
plaintexts = query.all()
return {
"messages": [
{
"messageId": p.message_id,
"recipientId": p.recipient_id,
"encryptedData": p.encrypted_data,
"createdAt": p.created_at.isoformat()
}
for p in plaintexts
]
}
@router.get("/users/search")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
@@ -461,7 +905,7 @@ async def _delete_user_data(user: User, db: Session):
user.deleted = True
user.display_name = f"Deleted User #{user_id}"
user.bio = None
user.hashed_password = ""
user.password_hash = ""
user.username = f"deleted_{user_id}"
user.profile_picture = None
user.last_seen = None # Clear last seen timestamp
@@ -521,7 +965,7 @@ async def delete_account(
Delete the current user's own account - preserves messages/DMs/reactions/files
"""
# Prevent admin/owner account self-deletion
if _is_admin(current_user):
if current_user.username == OWNER_USERNAME or current_user.id == 1:
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
await _delete_user_data(current_user, db)
+3 -3
View File
@@ -2,9 +2,9 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from backend.shared.dependencies import get_current_user, get_db
from backend.shared.models import User, DeviceSession
from backend.shared.utils import verify_token
from dependencies import get_current_user, get_db
from models import User, DeviceSession
from utils import verify_token
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
router = APIRouter()
+34 -237
View File
@@ -16,30 +16,26 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from backend.shared.dependencies import get_current_user, get_db
from backend.shared.utils import convert_user
from backend.shared.constants import OWNER_USERNAME
from backend.shared.models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
import os
import httpx
from dependencies import get_current_user, get_db
from .account import convert_user
from constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
from push_service import push_service
from PIL import Image
import io
import json
from pydantic import BaseModel
from better_profanity import profanity as _bp
from backend.security.audit import log_access, log_dm, log_public_chat, log_security
from backend.security.profanity import contains_profanity
from backend.security.rate_limit import rate_limit_per_ip
from backend.services.messaging.files.websocket.utils import authenticate_user
from backend.shared.models import FcmToken
from security.audit import log_access, log_dm, log_public_chat, log_security
from security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip
from websocket.utils import authenticate_user
router = APIRouter()
logger = logging.getLogger("uvicorn.error")
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
FILES_BASE_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "files"
FILES_BASE_DIR = Path("data/uploads/files")
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
@@ -54,8 +50,8 @@ _BURST_COUNT_THRESHOLD = 20
_SHORT_MESSAGE_LENGTH = 8
_SHORT_MESSAGE_REPEAT_LIMIT = 4
_recent_message_cache: dict[int, deque[tuple[str, str, float, int]]] = defaultdict(deque) # (normalized, content, timestamp, message_id)
_message_rate_cache: dict[int, deque[tuple[float, int]]] = defaultdict(deque) # (timestamp, message_id)
_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque)
_message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
_burst_last_logged: dict[int, float] = {}
@@ -66,23 +62,12 @@ def _normalize_for_spam(text: str) -> str:
return cleaned
def _monitor_public_message_activity(user: User, content: str, message_id: int, db: Session) -> None:
def _monitor_public_message_activity(user: User, content: str, db: Session) -> None:
now = time.time()
def suspend(reason: str, event: str, message_ids_to_delete: list[int] = None, **extra: Any) -> None:
def suspend(reason: str, event: str, **extra: Any) -> None:
if user.suspended or user.id == 1:
return
# Delete spam messages that triggered the ban
if message_ids_to_delete:
try:
deleted_count = db.query(Message).filter(Message.id.in_(message_ids_to_delete)).delete(synchronize_session=False)
db.commit()
logger.info(f"Deleted {deleted_count} spam messages for user {user.id}")
except Exception as e:
logger.error(f"Failed to delete spam messages: {e}")
db.rollback()
user.suspended = True
user.suspension_reason = reason
db.commit()
@@ -92,7 +77,6 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
user_id=user.id,
username=user.username,
reason=reason,
deleted_messages=len(message_ids_to_delete) if message_ids_to_delete else 0,
**extra,
)
try:
@@ -102,8 +86,8 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
# Rate tracking for burst detection
rate_bucket = _message_rate_cache[user.id]
rate_bucket.append((now, message_id))
while rate_bucket and now - rate_bucket[0][0] > _BURST_WINDOW_SECONDS:
rate_bucket.append(now)
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
rate_bucket.popleft()
burst_count = len(rate_bucket)
@@ -119,17 +103,12 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
window_seconds=_BURST_WINDOW_SECONDS,
)
_burst_last_logged[user.id] = now
# Get all message IDs from the burst window
burst_message_ids = [msg_id for _, msg_id in rate_bucket]
suspend(
"Automatic suspension: excessive message rate",
"auto_suspension_public_burst",
message_ids_to_delete=burst_message_ids,
count=burst_count,
window_seconds=_BURST_WINDOW_SECONDS,
)
return
# Similarity-based spam detection
normalized = _normalize_for_spam(content)
@@ -137,25 +116,21 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
while history and now - history[0][2] > _SPAM_WINDOW_SECONDS:
history.popleft()
prior_same = sum(1 for prev_norm, _, _, _ in history if prev_norm == normalized)
prior_same = sum(1 for prev_norm, _, _ in history if prev_norm == normalized)
prior_similar = sum(
1
for prev_norm, _, _, _ in history
for prev_norm, _, _ in history
if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD
)
history.append((normalized, content, now, message_id))
history.append((normalized, content, now))
total_matches = prior_same + prior_similar + 1
if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT:
# Get message IDs of all matching short messages
spam_message_ids = [msg_id for prev_norm, _, _, msg_id in history if prev_norm == normalized]
spam_message_ids.append(message_id) # Include current message
suspend(
"Automatic suspension: repeated short messages",
"auto_suspension_public_spam",
message_ids_to_delete=spam_message_ids,
occurrences=prior_same + 1,
window_seconds=_SPAM_WINDOW_SECONDS,
match_type="short",
@@ -163,20 +138,9 @@ def _monitor_public_message_activity(user: User, content: str, message_id: int,
return
if total_matches >= _SPAM_MESSAGE_LIMIT:
# Get message IDs of all matching similar messages
spam_message_ids = []
for prev_norm, _, _, msg_id in history:
if prev_norm == normalized:
spam_message_ids.append(msg_id)
elif prev_norm and normalized and prev_norm != normalized:
similarity = SequenceMatcher(None, normalized, prev_norm).ratio()
if similarity >= _SPAM_SIMILARITY_THRESHOLD:
spam_message_ids.append(msg_id)
spam_message_ids.append(message_id) # Include current message
suspend(
"Automatic suspension: repeated similar public messages",
"auto_suspension_public_spam",
message_ids_to_delete=spam_message_ids,
similar_messages=total_matches,
window_seconds=_SPAM_WINDOW_SECONDS,
match_type="similar",
@@ -394,16 +358,7 @@ async def _send_message_internal(
# Send push notifications for public messages
try:
push_service_url = os.getenv("PUSH_SERVICE_URL", "http://push_service:8306")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{push_service_url}/push/send-public-notification",
json={
"message_id": new_message.id,
"exclude_user_id": current_user.id
}
)
response.raise_for_status()
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
except Exception as e:
logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
@@ -416,7 +371,7 @@ async def _send_message_internal(
except Exception:
pass
_monitor_public_message_activity(current_user, raw_content, new_message.id, db)
_monitor_public_message_activity(current_user, raw_content, db)
message_payload = convert_message(new_message)
@@ -465,97 +420,6 @@ async def send_message(
return await _send_message_internal(message_request, current_user, db, files)
class RegisterFcmRequest(BaseModel):
token: str
@router.post("/push/register")
async def register_fcm_token(request: Request, body: RegisterFcmRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Register or update an FCM token for the authenticated user.
"""
token = body.token.strip() if body and body.token else None
if not token:
raise HTTPException(status_code=400, detail="Missing token")
try:
# If token already exists (from another device), reassign it to this user.
token_row = db.query(FcmToken).filter(FcmToken.token == token).first()
if token_row:
token_row.user_id = current_user.id
else:
# Create new token record (allow multiple tokens per user)
new = FcmToken(user_id=current_user.id, token=token)
db.add(new)
db.commit()
logger.info(f"Registered FCM token for user {current_user.id}: {token}")
except Exception as e:
try:
db.rollback()
except Exception:
pass
raise HTTPException(status_code=500, detail="Failed to save token")
return {"status": "success"}
@router.post("/push/unregister")
async def unregister_fcm_token(request: Request, body: RegisterFcmRequest | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Unregister an FCM token. If `body.token` provided, remove only that token for the user.
If no token provided, remove all tokens for the user.
"""
try:
if body and body.token:
db.query(FcmToken).filter(FcmToken.user_id == current_user.id, FcmToken.token == body.token.strip()).delete()
else:
db.query(FcmToken).filter(FcmToken.user_id == current_user.id).delete()
db.commit()
except Exception as e:
try:
db.rollback()
except Exception:
pass
raise HTTPException(status_code=500, detail="Failed to remove token")
return {"status": "success"}
@router.post("/push/test")
async def push_test(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Send a test push to the current user's registered FCM token (for manual testing).
"""
try:
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == current_user.id).all()
if not fcm_rows:
raise HTTPException(status_code=404, detail="No FCM token registered for user")
title = "FromChat test"
body = "This is a test push from the server"
data = {"type": "test", "timestamp": datetime.utcnow().isoformat()}
# Use push_service which uses Admin SDK internally; attempt to send to all tokens
failures = []
for fcm in fcm_rows:
try:
push_service._send_fcm_to_token(fcm.token, title, body, data)
except Exception as e:
logger.error(f"Failed to send test push to user {current_user.id} token {fcm.token}: {e}")
failures.append(str(e))
if failures and len(failures) == len(fcm_rows):
# All failed
raise HTTPException(status_code=500, detail=f"Failed to send push to any token: {failures}")
return {"status": "success", "sent": len(fcm_rows) - len(failures), "failed": len(failures)}
except HTTPException:
raise
except Exception as e:
logger.error(f"push_test error: {e}")
raise HTTPException(status_code=500, detail="Internal error")
@router.get("/get_messages")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
async def get_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
@@ -571,43 +435,6 @@ async def get_messages(request: Request, current_user: User = Depends(get_curren
}
class MarkReadRequest(BaseModel):
messageIds: list[int]
@router.get("/messages/new")
@rate_limit_per_ip("60/minute")
async def get_new_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Return unread public messages (Message.is_read == False).
"""
new_messages = db.query(Message).filter(Message.is_read == False).order_by(Message.timestamp.asc()).all()
messages_data = [convert_message(msg) for msg in new_messages]
return {"status": "success", "messages": messages_data}
@router.post("/messages/read")
@rate_limit_per_ip("60/minute")
async def mark_messages_read(request: Request, read_request: MarkReadRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Mark specified message IDs as read (set Message.is_read = True).
"""
if not read_request or not isinstance(read_request.messageIds, list) or len(read_request.messageIds) == 0:
return {"status": "success", "updated": 0}
try:
updated_count = db.query(Message).filter(Message.id.in_(read_request.messageIds)).update({Message.is_read: True}, synchronize_session=False)
db.commit()
except Exception as e:
try:
db.rollback()
except Exception:
pass
raise HTTPException(status_code=500, detail="Failed to mark messages as read")
return {"status": "success", "updated": int(updated_count)}
@router.post("/dm/send")
@rate_limit_per_ip("20/minute")
async def dm_send(
@@ -851,16 +678,15 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge
}
async def _edit_message_internal(
@router.put("/edit_message/{message_id}")
@rate_limit_per_ip("20/minute")
async def edit_message(
request: Request,
message_id: int,
edit_request: EditMessageRequest,
current_user: User,
db: Session
) -> dict:
"""Internal function to edit a message without requiring a Request object.
This can be called from both HTTP endpoints and WebSocket handlers.
"""
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
message = db.query(Message).filter(Message.id == message_id).first()
if not message:
@@ -909,18 +735,6 @@ async def _edit_message_internal(
return {"status": "success", "message": payload}
@router.put("/edit_message/{message_id}")
@rate_limit_per_ip("20/minute")
async def edit_message(
request: Request,
message_id: int,
edit_request: EditMessageRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
return await _edit_message_internal(message_id, edit_request, current_user, db)
@router.delete("/delete_message/{message_id}")
async def delete_message(
message_id: int,
@@ -1190,7 +1004,6 @@ class MessaggingSocketManager:
# Skip if this exact update was recently added
if signature in self.recent_updates[websocket]:
logger.warning(f"Update was skipped due to duplicate signature {signature}")
return
# Add to pending updates and track signature
@@ -1198,8 +1011,10 @@ class MessaggingSocketManager:
self.recent_updates[websocket].add(signature)
# Limit recent updates cache size (keep last 100 signatures per websocket)
if len(self.recent_updates[websocket]) > 1:
self.recent_updates[websocket] = set(list(self.recent_updates[websocket])[-1])
if len(self.recent_updates[websocket]) > 100:
# Remove oldest entries (simple FIFO by converting to list and keeping last 100)
# Actually, we'll just clear and rebuild on next flush - simpler approach
pass
async def _flush_updates(self, websocket: WebSocket, db: Session | None = None):
"""Flush pending updates for a WebSocket connection"""
@@ -1292,7 +1107,7 @@ class MessaggingSocketManager:
self.ws_subscriptions[websocket] = set()
# Import here to avoid circular import
from backend.services.messaging.files.websocket.handlers import handler_registry
from websocket.handlers import handler_registry
while True:
try:
@@ -1570,22 +1385,4 @@ async def get_file_encrypted(filename: str, current_user: User = Depends(get_cur
else:
raise HTTPException(500)
return FileResponse(str(path))
class SendSuspensionRequest(BaseModel):
user_id: int
reason: str
@router.post("/send-suspension")
async def send_suspension_to_user(
request: SendSuspensionRequest,
db: Session = Depends(get_db)
):
"""Send suspension message to user via WebSocket (called by profile service)"""
try:
await messagingManager.send_suspension_to_user(request.user_id, request.reason)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return FileResponse(str(path))
+6 -6
View File
@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import List
from backend.shared.constants import OWNER_USERNAME
from backend.shared.dependencies import get_current_user
from backend.shared.models import User
from backend.security.audit import log_security
from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
from backend.security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
from constants import OWNER_USERNAME
from dependencies import get_current_user
from models import User
from security.audit import log_security
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
from security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
class BlocklistUpdateRequest(BaseModel):
+10 -20
View File
@@ -9,16 +9,15 @@ import uuid
import io
from fastapi import Request
from backend.shared.dependencies import get_db, get_current_user
from backend.shared.models import User, UpdateBioRequest, UserProfileResponse
from dependencies import get_db, get_current_user
from models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel
from backend.shared.validation import is_valid_username, is_valid_display_name
from backend.shared.similarity import is_user_similar_to_verified
import os
import httpx
from backend.security.audit import log_security
from backend.security.profanity import contains_profanity
from backend.security.rate_limit import rate_limit_per_ip
from validation import is_valid_username, is_valid_display_name
from similarity import is_user_similar_to_verified
from .messaging import messagingManager
from security.audit import log_security
from security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip
router = APIRouter()
@@ -37,7 +36,7 @@ class UpdateProfileRequest(BaseModel):
description: str | None = None
# Create uploads directory if it doesn't exist
PROFILE_PICTURES_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "pfp"
PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
@@ -480,16 +479,7 @@ async def suspend_user(
# Send WebSocket suspension message
try:
messaging_service_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{messaging_service_url}/messaging/send-suspension",
json={
"user_id": user_id,
"reason": request.reason
}
)
response.raise_for_status()
await messagingManager.send_suspension_to_user(user_id, request.reason)
except Exception as e:
# Log error but don't fail the request
pass
+4 -27
View File
@@ -1,16 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from backend.shared.dependencies import get_current_user, get_db
from backend.shared.models import User, PushSubscriptionRequest
from backend.services.push.files import push_service
from dependencies import get_current_user, get_db
from models import User, PushSubscriptionRequest
from push_service import push_service
router = APIRouter()
class SendPublicNotificationRequest(BaseModel):
message_id: int
exclude_user_id: int
@router.post("/subscribe")
async def subscribe_to_push_notifications(
request: PushSubscriptionRequest,
@@ -42,28 +37,10 @@ async def unsubscribe_from_push_notifications(
"""Unsubscribe user from push notifications"""
try:
success = await push_service.unsubscribe_user(db=db, user_id=current_user.id)
if success:
return {"status": "success", "message": "Push notifications disabled"}
else:
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/send-public-notification")
async def send_public_message_notification(
request: SendPublicNotificationRequest,
db: Session = Depends(get_db)
):
"""Send push notification for public message (called by messaging service)"""
try:
# Get the message from database
from backend.shared.models import Message
message = db.query(Message).filter(Message.id == request.message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
await push_service.send_public_message_notification(db, message, exclude_user_id=request.exclude_user_id)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+1 -1
View File
@@ -4,7 +4,7 @@ import hmac
import hashlib
import time
from fastapi import APIRouter, Depends
from backend.shared.dependencies import get_current_user
from dependencies import get_current_user
import traceback
router = APIRouter()
-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 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:
+43 -82
View File
@@ -9,7 +9,7 @@ from typing import Iterable, List, Set, Tuple
from better_profanity import Profanity
BLOCKLIST_PATH = Path(__file__).resolve().parent.parent / "data" / "profanity" / "blocklist.json"
BLOCKLIST_PATH = Path("data/profanity/blocklist.json")
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
_CUSTOM_RU_TERMS: Set[str] = {
@@ -17,7 +17,7 @@ _CUSTOM_RU_TERMS: Set[str] = {
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор",
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
"пидоры", "пидорас", "пидорасы", "пидорасов",
}
@@ -46,18 +46,6 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
)
# Patterns to check in original text (before normalization) to catch visual bypasses
# These patterns check for special character combinations that visually form letters
_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = (
# Catch "}{" used to visually form "х" followed by "С0С" or similar patterns
# This catches "хуесос" written as "}{¥€С0С" or variations
# Matches: }{ + any characters (including special chars) + С/с + 0 + С/с
# The pattern allows any characters between to catch special chars like ¥€
re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
# Also catch "}{" followed by "уесос" with 0 instead of о
re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
)
# Map for normalizing homoglyphs (similar-looking characters)
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
# Also includes Greek, full-width, and other Unicode variants
@@ -194,8 +182,6 @@ _LEET_MAP = {
"н": "н", # Already mapped, but explicit
# Special characters
"@": "а",
# Multi-character visual bypasses (handled separately in preprocessing)
# "}{" visually forms "х" - handled in _preprocess_visual_bypasses
}
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
@@ -207,18 +193,6 @@ _SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
def _preprocess_visual_bypasses(text: str) -> str:
"""
Preprocess text to convert multi-character visual bypasses to their intended letters.
This handles cases like "}{" visually forming "х".
"""
result = text
# Convert "}{" to "х" (visual bypass for Cyrillic х)
# The curly braces visually form the letter х when placed together
result = result.replace("}{", "х")
return result
def _normalize_char(ch: str) -> str:
"""Normalize a single character, mapping homoglyphs to canonical form."""
# First try direct mapping (preserves case for non-mapped chars)
@@ -283,10 +257,7 @@ def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False)
(normalized_text, position_map) where position_map[i] is the original
position of the i-th character in normalized_text
"""
# First preprocess visual bypasses (like "}{" -> "х")
text = _preprocess_visual_bypasses(text)
# Then normalize Unicode (composed vs decomposed)
# First normalize Unicode (composed vs decomposed)
normalized_unicode = unicodedata.normalize('NFKC', text)
# For phrase matching, convert zero-width chars to spaces instead of stripping
@@ -345,49 +316,45 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -
start = pos + 1
# Also check if profane word appears as a subsequence (allowing extra chars)
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
# Now applies to ALL words, not just length >= 4, to prevent bypasses
word_chars = list(word_lower)
text_chars = list(normalized_lower)
# Stricter span limits based on word length to prevent false positives
# Shorter words get much stricter limits
if len(word_lower) <= 3:
max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй")
elif len(word_lower) == 4:
max_span_ratio = 1.4 # Strict for 4-char words
elif len(word_lower) <= 5:
max_span_ratio = 1.5 # Moderate for 5-char words
else:
max_span_ratio = 1.8 # Slightly more lenient for longer words
# Try to find the word as a subsequence
i = 0 # position in text
j = 0 # position in word
seq_start = None
while i < len(text_chars) and j < len(word_chars):
if text_chars[i] == word_chars[j]:
if seq_start is None:
seq_start = i
j += 1
if j == len(word_chars):
# Found the word as subsequence
seq_end = i + 1
# Check if the span is reasonable (not too long)
span_length = seq_end - seq_start
max_allowed_span = int(len(word_lower) * max_span_ratio)
if span_length <= max_allowed_span:
# Only add if it's not already covered by exact match
if (seq_start, seq_end) not in spans:
spans.append((seq_start, seq_end))
# Reset to find next occurrence - continue from after the end of this match
next_start = seq_start + 1
seq_start = None
j = 0
i = next_start
continue
i += 1
# This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй"
# Only do subsequence matching for words of length 4 or more to avoid false positives
# Use stricter span limits for shorter words to prevent false matches in long legitimate words
if len(word_lower) >= 4:
word_chars = list(word_lower)
text_chars = list(normalized_lower)
# Stricter ratio for shorter words, more lenient for longer words
if len(word_lower) <= 5:
max_span_ratio = 1.5 # Very strict for short words
else:
max_span_ratio = 2.0 # Slightly more lenient for longer words
# Try to find the word as a subsequence
i = 0 # position in text
j = 0 # position in word
seq_start = None
while i < len(text_chars) and j < len(word_chars):
if text_chars[i] == word_chars[j]:
if seq_start is None:
seq_start = i
j += 1
if j == len(word_chars):
# Found the word as subsequence
seq_end = i + 1
# Check if the span is reasonable (not too long)
span_length = seq_end - seq_start
max_allowed_span = int(len(word_lower) * max_span_ratio)
if span_length <= max_allowed_span:
# Only add if it's not already covered by exact match
if (seq_start, seq_end) not in spans:
spans.append((seq_start, seq_end))
# Reset to find next occurrence - continue from after the end of this match
next_start = seq_start + 1
seq_start = None
j = 0
i = next_start
continue
i += 1
return spans
@@ -615,13 +582,7 @@ def contains_profanity(text: str) -> bool:
_rebuild_dictionary()
# Check original text patterns first (before normalization) to catch visual bypasses
# like "}{" used to form "х"
for pattern in _ORIGINAL_TEXT_PATTERNS:
if pattern.search(text):
return True
# Check phrase patterns
# Check phrase patterns first
if _check_phrase_patterns(text):
return True
+1 -1
View File
@@ -8,7 +8,7 @@ from fastapi import Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from backend.shared.utils import get_client_ip
from utils import get_client_ip
logger = logging.getLogger("uvicorn.error")
-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
# Import them directly from websocket.handlers when needed
@@ -3,21 +3,20 @@ import json
import logging
import time
from typing import Any
from fastapi import HTTPException, WebSocket, Request
from fastapi import HTTPException, WebSocket
from sqlalchemy.orm import Session
from backend.services.messaging.files.websocket.registry import WebSocketHandlerRegistry
from backend.routes.messaging import (
from websocket.registry import WebSocketHandlerRegistry
from routes.messaging import (
MessaggingSocketManager,
_send_message_internal,
_edit_message_internal,
get_messages,
edit_message,
delete_message,
add_reaction,
add_dm_reaction,
)
from backend.shared.models import (
from models import (
User,
SendMessageRequest,
EditMessageRequest,
@@ -26,7 +25,7 @@ from backend.shared.models import (
DMReactionRequest,
UpdateLog,
)
from backend.security.audit import log_access, log_dm
from security.audit import log_access, log_dm
logger = logging.getLogger("uvicorn.error")
@@ -212,11 +211,14 @@ async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Ses
@websocket_handler("editMessage", authRequired=True)
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Edit a public chat message."""
from types import SimpleNamespace
message_id = data["message_id"]
edit_request: EditMessageRequest = EditMessageRequest.model_validate(data)
request: EditMessageRequest = EditMessageRequest.model_validate(data)
response = await _edit_message_internal(message_id, edit_request, user, db)
# Create a dummy request object for the HTTP endpoint function
dummy_request = SimpleNamespace()
response = await edit_message(dummy_request, message_id, request, user, db)
await manager.broadcast({
"type": "messageEdited",
"data": response["message"]
@@ -2,8 +2,8 @@ from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from types import SimpleNamespace
from backend.shared.dependencies import get_current_user
from backend.shared.models import User
from dependencies import get_current_user
from models import User
def extract_token_from_data(data: dict) -> str | None:
-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;
+31 -351
View File
@@ -1,368 +1,48 @@
services:
# Database service
database:
image: postgres:15
environment:
POSTGRES_DB: fromchat
POSTGRES_USER: fromchat_admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- database:/var/lib/postgresql/data
- ./db-init:/docker-entrypoint-initdb.d
networks:
- fromchat_internal
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fromchat_admin -d fromchat"]
interval: 1s
timeout: 5s
retries: 5
# Migration runner - runs once before other services
migration_runner:
build:
backend:
build:
dockerfile: deployment/Dockerfile.backend
context: ..
dockerfile: docker/Dockerfile.multi
target: migration_runner
environment:
DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD}@database:5432/fromchat
JWT_SECRET: ${JWT_SECRET}
depends_on:
database:
condition: service_healthy
networks:
- fromchat_internal
develop:
watch:
- action: sync
path: backend/alembic.ini
target: /app/backend/alembic.ini
- action: sync
path: backend/alembic
target: /app/backend/alembic
- action: sync
path: backend/migration.py
target: /app/backend/migration.py
- action: sync
path: backend/services/migration_runner
target: /app/backend/services/migration_runner
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Gateway service - handles complex operations
gateway:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: gateway
ports: ["8300:8300"]
environment:
DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8300
ACCOUNT_SERVICE_URL: http://account_service:8302
PROFILE_SERVICE_URL: http://profile_service:8303
DEVICE_SERVICE_URL: http://device_service:8304
MESSAGING_SERVICE_URL: http://messaging_service:8305
PUSH_SERVICE_URL: http://push_service:8306
WEBRTC_SERVICE_URL: http://webrtc_service:8307
MODERATION_SERVICE_URL: http://moderation_service:8308
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
- fromchat_external
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/app.py
target: /app/backend/app.py
- action: sync
path: backend/main.py
target: /app/backend/main.py
- action: sync
path: backend/dependencies.py
target: /app/backend/dependencies.py
- action: sync
path: backend/security
target: /app/backend/security
- action: sync
path: backend/services/gateway
target: /app/backend/services/gateway
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Account service
account_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: account_service
environment:
DATABASE_URL: postgresql://account_service_user:${DB_PASSWORD}@database:5432/fromchat
JWT_SECRET: ${JWT_SECRET}
PORT: 8302
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/account.py
target: /app/backend/routes/account.py
- action: sync
path: backend/services/account
target: /app/backend/services/account
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Profile service
profile_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: profile_service
environment:
DATABASE_URL: postgresql://profile_service_user:${DB_PASSWORD}@database:5432/fromchat
FIREBASE_CERT: ${FIREBASE_CERT}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
MESSAGING_SERVICE_URL: http://messaging_service:8305
PORT: 8303
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/profile.py
target: /app/backend/routes/profile.py
- action: sync
path: backend/services/profile
target: /app/backend/services/profile
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Device service
device_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: device_service
environment:
DATABASE_URL: postgresql://device_service_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8304
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/devices.py
target: /app/backend/routes/devices.py
- action: sync
path: backend/services/device
target: /app/backend/services/device
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Messaging service
messaging_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: messaging_service
ports:
- "8305:8305"
environment:
DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD}@database:5432/fromchat
FIREBASE_CERT: ${FIREBASE_CERT}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
PUSH_SERVICE_URL: http://push_service:8306
PORT: 8305
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/messaging.py
target: /app/backend/routes/messaging.py
- action: sync
path: backend/websocket
target: /app/backend/websocket
- action: sync
path: backend/services/messaging
target: /app/backend/services/messaging
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Push service
push_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: push_service
environment:
DATABASE_URL: postgresql://push_service_user:${DB_PASSWORD}@database:5432/fromchat
FIREBASE_CERT: ${FIREBASE_CERT}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
PORT: 8306
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/push.py
target: /app/backend/routes/push.py
- action: sync
path: backend/push_service.py
target: /app/backend/push_service.py
- action: sync
path: backend/services/push
target: /app/backend/services/push
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# WebRTC service
webrtc_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: webrtc_service
environment:
DATABASE_URL: postgresql://webrtc_service_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8307
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/webrtc.py
target: /app/backend/routes/webrtc.py
- action: sync
path: backend/services/webrtc
target: /app/backend/services/webrtc
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Moderation service
moderation_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: moderation_service
environment:
DATABASE_URL: postgresql://moderation_service_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8308
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/moderation.py
target: /app/backend/routes/moderation.py
- action: sync
path: backend/security
target: /app/backend/security
- action: sync
path: backend/similarity.py
target: /app/backend/similarity.py
- action: sync
path: backend/services/moderation
target: /app/backend/services/moderation
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Caddy reverse proxy
caddy:
build:
context: ./caddy
dockerfile: Dockerfile
ports:
- "80:80"
- "443:443"
volumes:
- certs:/root/site/certs
networks:
- fromchat_external
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- XDG_DATA_HOME=/root/site/certs
- XDG_CONFIG_HOME=/root/site/certs
restart: unless-stopped
depends_on:
- gateway
profiles: ["prod"]
- data:/app/data
- logs:/app/logs
develop:
watch:
- action: sync+restart
path: ../backend
target: /app
- action: rebuild
path: ../backend/requirements.txt
# Frontend service - serves the React app
frontend:
build:
context: ..
build:
dockerfile: deployment/frontend/Dockerfile
context: ..
environment:
PORT: 8301
BACKEND_HOST: http://backend:8300
ports:
- "8301:8301"
environment:
- PORT=8301
- BACKEND_HOST=http://gateway:8300
restart: unless-stopped
networks:
- fromchat_external
- fromchat_internal
depends_on:
- backend
develop:
watch:
- action: rebuild
path: ../frontend
- action: sync+restart
path: server.js
target: /server/server.js
- action: rebuild
path: package.json
volumes:
database:
name: fromchat-database
certs:
name: fromchat-certs
logs:
name: fromchat-logs
data:
name: fromchat-data
networks:
fromchat_internal:
driver: bridge
internal: true
fromchat_external:
driver: bridge
logs:
name: fromchat-logs
+1 -3
View File
@@ -3,13 +3,11 @@ FROM node:24 AS frontend
# 1.1. Install npm dependencies
WORKDIR /app
# Copy package.json and workspace package directory first (needed for workspace resolution)
COPY package.json .
COPY frontend/packages/ frontend/packages/
RUN --mount=type=cache,target=/root/.npm \
npm install --ignore-scripts
# 1.2. Copy remaining frontend code and build
# 1.2. Build
COPY frontend frontend
RUN npm run frontend:build
+8 -24
View File
@@ -1,44 +1,28 @@
import express from 'express';
import type { Request, Response } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { resolve } from 'path';
const app = express();
const port = Number(process.env.PORT) || 8301;
const port = process.env.PORT || 3000;
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
const filePath = process.env.STATIC_FILE_PATH || ".";
// Direct WebSocket proxy for chat - bypass gateway (must come before general API proxy)
app.use('/api/chat/ws', createProxyMiddleware({
target: 'http://messaging_service:8305',
// API proxy middleware
app.use('/api', createProxyMiddleware({
target: backendHost,
changeOrigin: true,
pathRewrite: { '^/api/chat/ws': '/messaging/chat/ws' },
pathRewrite: { '^/api': '' },
ws: true
}));
// API proxy middleware (exclude WebSocket paths)
app.use('/api', (req, res, next) => {
// Skip WebSocket upgrade requests - let them be handled by specific proxies
if (req.headers.upgrade === 'websocket') {
return next();
}
createProxyMiddleware({
target: backendHost,
changeOrigin: true,
pathRewrite: { '^/api': '' },
ws: true
})(req, res, next);
});
// Serve static files
app.use(express.static(resolve(filePath)));
// SPA routing - catch all handler for client-side routing
app.use((_req: Request, res: Response) => {
app.use((_req, res) => {
res.sendFile(resolve(filePath, 'index.html'));
});
app.listen(port, '0.0.0.0', () => {
console.log(`Backend host: ${backendHost}`);
console.log(`Server launched on http://0.0.0.0:${port}`);
app.listen(port, () => {
console.log(`Server launched on http://localhost:${port}`);
});
-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 type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
import type { Headers } from "@/core/types";
@@ -125,6 +127,7 @@ export async function deriveAuthSecret(username: string, password: string): Prom
return b64(derived);
}
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
// Try to restore from backup
const blobJson = await fetchBackupBlob(token);
+324 -42
View File
@@ -1,19 +1,148 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import { getCurrentKeys } from "../user/auth";
import api from "@/core/api";
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "../crypto/identity";
import { fetchUsers, searchUsers } from "../user/search";
import { getOrInitProtocol } from "@/utils/crypto/fromchatInit";
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64 } from "@/utils/utils";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { useUserStore } from "@/state/user";
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const protocol = getOrInitProtocol();
const senderPublicKey = ub64(senderPublicKeyB64);
export async function decrypt(envelope: DmEnvelope, senderId: number): Promise<string> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
if (!envelope.ciphertext) {
throw new Error("DM envelope missing ciphertext");
}
const signalService = new SignalProtocolService(user.id.toString());
return await protocol.decryptMessage(senderPublicKey, envelope);
// Remove padding (backward compatible with old messages)
// Check if ciphertext is base64 (padded) or already JSON (unpadded)
let ciphertextStr: string = envelope.ciphertext;
// Check if it's base64 (padded messages are base64)
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
if (isBase64) {
// Try to remove padding
try {
const unpadded = removePadding(envelope.ciphertext);
// Verify it's valid JSON before using it
JSON.parse(unpadded);
ciphertextStr = unpadded;
} catch {
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
try {
JSON.parse(envelope.ciphertext);
ciphertextStr = envelope.ciphertext;
} catch {
// If both fail, throw an error
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
}
}
} else {
// Not base64, assume it's already JSON (unpadded message)
ciphertextStr = envelope.ciphertext;
}
// Parse Signal Protocol message
let signalCiphertext: { type: number; body: string };
try {
signalCiphertext = JSON.parse(ciphertextStr);
} catch (error) {
throw new Error(`Failed to parse ciphertext as JSON: ${error instanceof Error ? error.message : String(error)}. Ciphertext length: ${ciphertextStr.length}, first 100 chars: ${ciphertextStr.substring(0, 100)}`);
}
if (!signalCiphertext || typeof signalCiphertext !== "object") {
throw new Error("Invalid Signal Protocol message format: not an object");
}
if (typeof signalCiphertext.type !== "number") {
throw new Error("Invalid Signal Protocol message format: type is not a number");
}
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
}
// Check if body contains non-printable characters (corrupted binary data from old encryption)
// This must be checked first, before any base64 validation
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
if (hasNonPrintable) {
// This is a corrupted message from before the base64 conversion fix
// It cannot be decrypted - the body contains raw binary data instead of base64
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
return "_This message is corrupted and cannot be displayed._";
}
// Check if body contains Unicode escape sequences (from JSON.stringify escaping)
// If so, we need to unescape them to get the actual base64 string
let bodyToDecode = signalCiphertext.body;
// Check for literal backslash-u sequences (before JSON parsing, these would be "\\u")
// After JSON parsing, Unicode escapes are converted to actual characters, so we check for
// the pattern that indicates it might have been escaped
if (bodyToDecode.includes("\\u") || bodyToDecode.match(/\\u[0-9a-fA-F]{4}/)) {
// Try to unescape Unicode sequences by wrapping in JSON quotes
try {
bodyToDecode = JSON.parse(`"${bodyToDecode.replace(/\\/g, "\\\\")}"`);
} catch {
// If unescaping fails, use the original
bodyToDecode = signalCiphertext.body;
}
}
// Validate that body is valid base64 before attempting decryption
// Check if it's a valid base64 string (only contains base64 characters and padding)
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(bodyToDecode)) {
// Log for debugging - this should help identify the issue
console.error("Invalid base64 in body:", {
bodyType: typeof signalCiphertext.body,
bodyLength: signalCiphertext.body.length,
unescapedLength: bodyToDecode.length,
first50: signalCiphertext.body.substring(0, 50),
unescapedFirst50: bodyToDecode.substring(0, 50),
envelopeId: envelope.id
});
throw new Error(`Invalid base64 format in ciphertext body`);
}
// Use the unescaped body for decryption
signalCiphertext.body = bodyToDecode;
try {
// Try to decode a small portion to validate base64
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
} catch (error) {
// Log for debugging
console.error("Base64 decode failed:", {
bodyLength: signalCiphertext.body.length,
first50: signalCiphertext.body.substring(0, 50),
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
envelopeId: envelope.id,
error: error instanceof Error ? error.message : String(error)
});
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
}
try {
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
return plaintext;
} catch (error) {
// If decryption fails, check if it's a session issue
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("No session exists") || errorMessage.includes("No record for device")) {
console.warn(`Session missing for sender ${senderId} (envelope ID: ${envelope.id}). This may happen after page reload if the session was not properly restored.`);
}
throw error;
}
}
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
@@ -22,22 +151,106 @@ export async function fetchMessages(userId: number, token: string, limit: number
url += `&before_id=${beforeId}`;
}
const response = await globalThis.fetch(url, {
headers: getAuthHeaders(token, true)
headers: api.user.auth.getAuthHeaders(token, true)
});
if (!response.ok) return { messages: [], has_more: false };
const data = await response.json();
return { messages: data.messages || [], has_more: data.has_more ?? false };
}
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const protocol = getOrInitProtocol();
const recipientPublicKey = ub64(recipientPublicKeyB64);
export async function send(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext);
// Check if we have a session, if not, fetch prekey bundle and establish one
let hasSession = false;
try {
hasSession = await signalService.hasSession(recipientId);
} catch (error) {
console.warn("Failed to check session, will attempt to establish new one:", error);
}
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
await signalService.processPreKeyBundle(recipientId, bundle);
} catch (error) {
// Re-throw PrekeyExhaustedError as-is for proper handling
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
throw error;
}
// Log other errors for debugging
console.error("Failed to establish session:", {
recipientId,
error: error instanceof Error ? error.message : String(error)
});
// Re-throw other errors
throw error;
}
}
// Encrypt with Signal Protocol
let ciphertext: { type: number; body: string };
try {
ciphertext = await signalService.encryptMessage(recipientId, plaintext);
} catch (error) {
console.error("Failed to encrypt message:", {
recipientId,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
// Verify the body is valid base64 before stringifying
if (ciphertext.body && typeof ciphertext.body === "string") {
try {
// Test that body is valid base64
atob(ciphertext.body.substring(0, Math.min(4, ciphertext.body.length)));
// Verify the entire body is valid base64
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(ciphertext.body)) {
console.error("Invalid base64 characters in encrypted body:", {
bodyLength: ciphertext.body.length,
first100: ciphertext.body.substring(0, 100),
last100: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 100))
});
throw new Error("Encrypted body contains invalid base64 characters");
}
} catch (error) {
throw new Error(`Encrypted body is not valid base64: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Stringify the ciphertext - JSON.stringify should not escape base64 strings
const ciphertextJson = JSON.stringify(ciphertext);
// Verify the stringified JSON doesn't have escaped characters in the body field
const parsed = JSON.parse(ciphertextJson);
if (parsed.body !== ciphertext.body) {
console.error("Body was modified during JSON stringification:", {
original: ciphertext.body.substring(0, 50),
stringified: parsed.body.substring(0, 50),
originalLength: ciphertext.body.length,
stringifiedLength: parsed.body.length
});
throw new Error("Body was incorrectly escaped during JSON stringification");
}
// Add padding to obfuscate message size (anti-censorship)
const paddedCiphertext = addPadding(ciphertextJson);
const payload: SendDMRequest = {
recipientId: recipientId,
...encrypted
iv: "", // Not used for Signal Protocol
ciphertext: paddedCiphertext, // Padded Signal Protocol message
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: "" // Not used for Signal Protocol
};
if (replyToId) payload.replyToId = replyToId;
@@ -49,20 +262,44 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p
},
data: payload
});
// Note: We'll cache the message when we receive the dmNew confirmation via WebSocket
// which contains the actual message ID
}
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
// For files, we need to use the same message key for both the message and files
// So we'll do the encryption manually here to reuse the mk
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
await signalService.processPreKeyBundle(recipientId, bundle);
} catch (error) {
// Re-throw PrekeyExhaustedError as-is for proper handling
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
throw error;
}
// Re-throw other errors
throw error;
}
}
// Generate master key for file encryption
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, mk);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
const form = new FormData();
const names: string[] = [];
@@ -75,42 +312,84 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
const data = new Uint8Array(await f.arrayBuffer());
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
const serverName = f.name;
const serverName = f.name; // server uses provided name
names.push(serverName);
form.append("files", new File([blob], serverName));
}
form.append("fileNames", JSON.stringify(names));
// Encrypt the plaintext JSON with the same mk
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson));
// Merge files metadata into plaintext JSON and encrypt
let obj: DmEncryptedJSON;
try {
obj = JSON.parse(plaintextJson);
} catch {
obj = { type: "text", data: { content: String(plaintextJson) } };
}
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
form.append("dm_payload", JSON.stringify({
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope));
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(token, false),
headers: api.user.auth.getAuthHeaders(token, false),
body: form
});
}
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
const protocol = getOrInitProtocol();
const recipientPublicKey = ub64(recipientPublicKeyB64);
export async function edit(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson);
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
await signalService.processPreKeyBundle(recipientId, bundle);
} catch (error) {
// Re-throw PrekeyExhaustedError as-is for proper handling
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
throw error;
}
// Re-throw other errors
throw error;
}
}
// Generate fresh master key for the edited message
const mk = randomBytes(32);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
// Encrypt the message content with the master key
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
await request({
type: "dmEdit",
credentials: { scheme: "Bearer", credentials: authToken },
data: {
id,
...encrypted
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
salt: "" // Not used for Signal Protocol
}
} as DMEditRequest);
}
@@ -131,7 +410,7 @@ export interface ConversationResponse {
export async function conversations(token: string): Promise<ConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
headers: api.user.auth.getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
@@ -150,4 +429,7 @@ export async function markRead(id: number, authToken: string): Promise<void> {
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
export { fetchUsers, searchUsers } from "@/core/api/users";
export { fetchUserPublicKey } from "@/core/api/crypto/identity";
+37
View File
@@ -2,6 +2,7 @@ import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
/**
* Fetches the current user's public key
@@ -74,3 +75,39 @@ export async function uploadBackupBlob(blobJson: string, token: string): Promise
if (!res.ok) throw new Error("Failed to upload backup blob");
}
/**
* Uploads Signal Protocol prekey bundle for the current user
*/
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
// Re-export from prekeys.ts
const { uploadPreKeyBundle: upload } = await import("./crypto/prekeys");
return upload(bundle, token);
}
/**
* Uploads all available prekeys to the server for rotation
*/
export async function uploadAllPreKeys(
baseBundle: Omit<PreKeyBundleData, "preKey">,
prekeys: Array<{ keyId: number; publicKey: string }>,
token: string
): Promise<void> {
// Re-export from prekeys.ts
const { uploadAllPreKeys: upload } = await import("./crypto/prekeys");
return upload(baseBundle, prekeys, token);
}
/**
* Fetches Signal Protocol prekey bundle for another user
*/
export async function fetchPreKeyBundle(userId: number, token: string): Promise<any | null> {
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
method: "GET",
headers
});
if (!res.ok) return null;
const data = await res.json();
return data.bundle || null;
}
+3 -3
View File
@@ -1,12 +1,12 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { BackupBlob } from "@/core/types";
import api from "@/core/api";
/**
* Fetches the current user's backup blob
*/
export async function fetchBackupBlob(token: string): Promise<string | null> {
const headers = api.user.auth.getAuthHeaders(token, true);
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "GET",
headers
@@ -25,7 +25,7 @@ export async function fetchBackupBlob(token: string): Promise<string | null> {
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
const payload: BackupBlob = { blob: blobJson }
const headers = api.user.auth.getAuthHeaders(token, true);
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "POST",
headers,
@@ -0,0 +1,69 @@
/**
* API functions for managing encrypted message plaintexts on the server
*/
import { API_BASE_URL } from "@/core/config";
import api from "@/core/api";
export interface MessagePlaintextData {
messageId: number;
recipientId: number;
encryptedData: string;
}
export interface MessagePlaintextResponse {
messageId: number;
recipientId: number;
encryptedData: string;
createdAt: string;
}
/**
* Upload encrypted message plaintexts to the server
*/
export async function uploadMessagePlaintexts(
messages: MessagePlaintextData[],
token: string
): Promise<void> {
const response = await fetch(`${API_BASE_URL}/crypto/signal/message-plaintexts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...api.user.auth.getAuthHeaders(token, false)
},
body: JSON.stringify({ messages })
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to upload message plaintexts" }));
throw new Error(error.detail || "Failed to upload message plaintexts");
}
}
/**
* Fetch encrypted message plaintexts from the server
*/
export async function fetchMessagePlaintexts(
token: string,
recipientId?: number
): Promise<MessagePlaintextResponse[]> {
let url = `${API_BASE_URL}/crypto/signal/message-plaintexts`;
if (recipientId !== undefined) {
const separator = url.includes("?") ? "&" : "?";
url = `${url}${separator}recipient_id=${recipientId}`;
}
const response = await fetch(url, {
method: "GET",
headers: api.user.auth.getAuthHeaders(token, false)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to fetch message plaintexts" }));
throw new Error(error.detail || "Failed to fetch message plaintexts");
}
const data = await response.json();
return data.messages || [];
}
+83 -8
View File
@@ -1,14 +1,89 @@
// Placeholder for Signal Protocol pre-key management
// Will be implemented when Signal Protocol is added
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
export async function upload(_bundle: unknown, _token: string): Promise<void> {
// TODO: Implement Signal Protocol pre-key upload
throw new Error("Not implemented yet");
/**
* Uploads Signal Protocol prekey bundle for the current user
* This uploads the base bundle (identity, signed prekey) and one prekey
*/
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
const payload = { bundle };
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Failed to upload prekey bundle");
}
export async function fetch(_userId: number, _token: string): Promise<unknown> {
// TODO: Implement Signal Protocol pre-key fetch
throw new Error("Not implemented yet");
/**
* Uploads all available prekeys to the server for rotation in a single request
*/
export async function uploadAllPreKeys(
baseBundle: Omit<PreKeyBundleData, "preKey">,
prekeys: Array<{ keyId: number; publicKey: string }>,
token: string
): Promise<void> {
const headers = getAuthHeaders(token, true);
const payload = {
baseBundle,
prekeys
};
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekeys/bulk`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) {
throw new Error(`Failed to upload prekeys: ${res.statusText}`);
}
}
/**
* Custom error for prekey exhaustion
*/
export class PrekeyExhaustedError extends Error {
constructor(public readonly recipientId: number) {
super("Recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys.");
this.name = "PrekeyExhaustedError";
}
}
/**
* Fetches Signal Protocol prekey bundle for another user
* @throws {PrekeyExhaustedError} If the recipient has no unused prekeys available
*/
export async function fetchPreKeyBundle(userId: number, token: string): Promise<PreKeyBundleData> {
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
method: "GET",
headers
});
if (!res.ok) {
if (res.status === 404) {
throw new Error("Recipient has not set up encryption. They need to log in to initialize their encryption keys.");
}
throw new Error("Failed to fetch prekey bundle");
}
const data = await res.json();
const bundle = data.bundle;
// Check if bundle exists but has no prekey (all prekeys exhausted)
if (!bundle) {
throw new PrekeyExhaustedError(userId);
}
// If bundle exists but has no preKey field, it means all prekeys are exhausted
// The backend returns bundle without preKey when no unused prekeys are available
if (!bundle.preKey) {
throw new PrekeyExhaustedError(userId);
}
return bundle;
}
+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";
import { getAuthHeaders } from "./account";
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
import { getCurrentKeys } from "./account";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "./crypto";
import { fetchUsers, searchUsers } from "./users";
// Re-export from dmApi.ts which has Signal Protocol support
export {
decryptDm,
fetchDMHistory,
sendDMViaWebSocket,
sendDmWithFiles,
editDmEnvelope,
deleteDmEnvelope,
fetchDMConversations,
fetchUsers,
searchUsers,
fetchUserPublicKey
} from "./dmApi";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Obtain the key
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
// Decrypt
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg);
}
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data = await response.json();
return data.messages || [];
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Encryption key
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the message
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
const payload: SendDMRequest = {
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
};
if (replyToId) payload.replyToId = replyToId;
await request({
type: "dmSend",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: payload
});
}
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, mk);
const form = new FormData();
const names: string[] = [];
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
}
for (const f of files) {
// Encrypt file with same mk
const data = new Uint8Array(await f.arrayBuffer());
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
const serverName = f.name; // server uses provided name
names.push(serverName);
form.append("files", new File([blob], serverName));
}
form.append("fileNames", JSON.stringify(names));
// Merge files metadata into plaintext JSON and encrypt
let obj: DmEncryptedJSON;
try {
obj = JSON.parse(plaintextJson);
} catch {
obj = { type: "text", data: { content: String(plaintextJson) } };
}
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
form.append("dm_payload", JSON.stringify({
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
} satisfies BaseDmEnvelope));
await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(token, false),
body: form
});
}
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({
type: "dmEdit",
credentials: { scheme: "Bearer", credentials: authToken },
data: {
id,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext),
salt: b64(wkSalt)
}
} as DMEditRequest);
}
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
await request({
type: "dmDelete",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id, recipientId }
});
}
export interface DMConversationResponse {
user: User;
lastMessage: DmEnvelope;
unreadCount: number;
}
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
return data.conversations || [];
}
export type { DMConversationResponse } from "./dmApi";
+204 -65
View File
@@ -1,62 +1,163 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
import { getCurrentKeys } from "./account";
import api from "@/core/api";
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "./crypto";
import { fetchUsers, searchUsers } from "./users";
import { b64 } from "@/utils/utils";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { useUserStore } from "@/state/user";
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
// Obtain the key
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
if (!envelope.ciphertext) {
throw new Error("DM envelope missing ciphertext");
}
// Decrypt
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg);
const signalService = new SignalProtocolService(user.id.toString());
// Remove padding (backward compatible with old messages)
// Check if ciphertext is base64 (padded messages are base64)
let ciphertextStr: string = envelope.ciphertext;
// Check if it's base64 (padded messages are base64)
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
if (isBase64) {
// Try to remove padding
try {
const unpadded = removePadding(envelope.ciphertext);
// Verify it's valid JSON before using it
JSON.parse(unpadded);
ciphertextStr = unpadded;
} catch {
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
try {
JSON.parse(envelope.ciphertext);
ciphertextStr = envelope.ciphertext;
} catch {
// If both fail, throw an error
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
}
}
} else {
// Not base64, assume it's already JSON (unpadded message)
ciphertextStr = envelope.ciphertext;
}
// Parse Signal Protocol message
let signalCiphertext: { type: number; body: string };
try {
signalCiphertext = JSON.parse(ciphertextStr);
} catch (error) {
throw new Error(`Failed to parse Signal Protocol message: ${error instanceof Error ? error.message : String(error)}`);
}
if (!signalCiphertext || typeof signalCiphertext !== "object") {
throw new Error("Invalid Signal Protocol message format: not an object");
}
if (typeof signalCiphertext.type !== "number") {
throw new Error("Invalid Signal Protocol message format: type is not a number");
}
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
}
// Validate that body is valid base64 before attempting decryption
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(signalCiphertext.body)) {
// Check if body contains non-printable characters (corrupted binary data)
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
if (hasNonPrintable) {
// This is a corrupted message from before the base64 conversion fix
// It cannot be decrypted - the body contains raw binary data instead of base64
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
return "_This message is corrupted and cannot be displayed._";
}
console.error("Invalid base64 in body:", {
bodyType: typeof signalCiphertext.body,
bodyLength: signalCiphertext.body.length,
first50: signalCiphertext.body.substring(0, 50),
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
envelopeId: envelope.id
});
throw new Error(`Invalid base64 format in ciphertext body`);
}
try {
// Try to decode a small portion to validate base64
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
} catch (error) {
console.error("Base64 decode failed:", {
bodyLength: signalCiphertext.body.length,
first50: signalCiphertext.body.substring(0, 50),
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
envelopeId: envelope.id,
error: error instanceof Error ? error.message : String(error)
});
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
}
try {
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
return plaintext;
} catch (error) {
throw new Error(`Failed to decrypt DM: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
headers: api.user.auth.getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data = await response.json();
return data.messages || [];
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Encryption key
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the message
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
export async function sendDMViaWebSocket(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
// Fetch prekey bundle from server
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
if (!bundle) {
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Encrypt with Signal Protocol
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
// Add padding to obfuscate message size (anti-censorship)
const paddedCiphertext = addPadding(JSON.stringify(ciphertext));
const payload: SendDMRequest = {
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
iv: "", // Not used for Signal Protocol
ciphertext: paddedCiphertext, // Padded Signal Protocol message
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: "" // Not used for Signal Protocol
};
if (replyToId) payload.replyToId = replyToId;
@@ -70,17 +171,33 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
});
}
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function sendDmWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
if (!bundle) {
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Generate master key for file encryption
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, mk);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
const form = new FormData();
const names: string[] = [];
@@ -112,30 +229,48 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
salt: "", // Not used for Signal Protocol
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
} satisfies BaseDmEnvelope));
await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(token, false),
headers: api.user.auth.getAuthHeaders(token, false),
body: form
});
}
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function editDmEnvelope(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
const signalService = new SignalProtocolService(user.id.toString());
// Check if we have a session, if not, fetch prekey bundle and establish one
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
if (!bundle) {
throw new Error("No Signal Protocol prekey bundle available for recipient");
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Generate fresh master key for the edited message
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the master key using Signal Protocol
const mkBase64 = b64(mk);
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
// Add padding to obfuscate master key size
const paddedMk = addPadding(JSON.stringify(encryptedMk));
// Encrypt the message content with the master key
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({
type: "dmEdit",
@@ -144,9 +279,9 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string,
id,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext),
salt: b64(wkSalt)
iv2: "", // Not used for Signal Protocol
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
salt: "" // Not used for Signal Protocol
}
} as DMEditRequest);
}
@@ -165,9 +300,13 @@ export interface DMConversationResponse {
unreadCount: number;
}
// Re-export for convenience
export { fetchUsers, searchUsers } from "./users";
export { fetchUserPublicKey } from "./crypto/identity";
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
headers: api.user.auth.getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
+5 -1
View File
@@ -7,6 +7,8 @@ import * as userSearch from "./user/search";
import * as cryptoPrekeys from "./crypto/prekeys";
import * as cryptoIdentity from "./crypto/identity";
import * as cryptoBackup from "./crypto/backup";
import * as cryptoSessions from "./crypto/sessions";
import * as cryptoMessagePlaintexts from "./crypto/messagePlaintexts";
import * as moderationBlocklist from "./moderation/blocklist";
import * as moderationUsers from "./moderation/users";
import * as callsModule from "./calls";
@@ -27,7 +29,9 @@ const api = {
crypto: {
prekeys: cryptoPrekeys,
identity: cryptoIdentity,
backup: cryptoBackup
backup: cryptoBackup,
sessions: cryptoSessions,
messagePlaintexts: cryptoMessagePlaintexts
},
moderation: {
blocklist: moderationBlocklist,
+3 -1
View File
@@ -1,7 +1,9 @@
import { API_BASE_URL } from "@/core/config";
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
+58 -159
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 api from "@/core/api";
import type { WrappedSessionKeyPayload } from "@/core/types";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { useUserStore } from "@/state/user";
import { fetchPreKeyBundle } from "@/core/api/crypto";
import { getAuthToken } from "@/core/api/account";
export interface CallSessionKey {
key: Uint8Array;
hash: string; // For emoji display
}
export interface CallKeyExchange {
type: "call_key_exchange";
sessionKeyHash: string;
encryptedSessionKey: EncryptedCallMessage;
}
export interface EncryptedCallMessage {
iv: string;
ciphertext: string;
salt: string;
iv2: string;
wrappedSessionKey: string;
}
/**
* Generates a new call session key for end-to-end encryption
* @returns Promise that resolves to a session key with its hash for display
@@ -58,97 +46,6 @@ export async function rotateCallSessionKey(): Promise<CallSessionKey> {
};
}
/**
* Create session key from hash (for backward compatibility)
* @deprecated Use deriveCallSessionKeyFromSharedSecret instead
*/
export async function createCallSessionKeyFromHash(hash: string): Promise<CallSessionKey> {
// For backward compatibility, generate a deterministic key from the hash
const hashBytes = ub64(hash);
const sessionKey = new Uint8Array(32);
// Repeat the hash bytes to fill 32 bytes
for (let i = 0; i < 32; i++) {
sessionKey[i] = hashBytes[i % hashBytes.length];
}
return {
key: sessionKey,
hash
};
}
/**
* Derive session key from ECDH shared secret and session key hash
* This creates a deterministic but cryptographically secure key
*/
export async function deriveCallSessionKeyFromSharedSecret(
sharedSecret: Uint8Array,
sessionKeyHash: string,
isInitiator: boolean
): Promise<CallSessionKey> {
// Use HKDF to derive the session key from the shared secret
// Include the session key hash and role to ensure uniqueness
const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`);
const salt = new Uint8Array(32); // Zero salt for deterministic derivation
// Import the shared secret as a raw key for HKDF
const sharedKey = await crypto.subtle.importKey(
'raw',
sharedSecret.buffer as ArrayBuffer,
{ name: 'HKDF' },
false,
['deriveKey']
);
// Derive the session key using HKDF
const sessionKey = await crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: salt,
info: info
},
sharedKey,
{ name: 'AES-GCM', length: 256 },
true, // Make the key extractable so we can export it
['encrypt', 'decrypt']
);
// Export the raw key material
const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey);
return {
key: new Uint8Array(sessionKeyMaterial),
hash: sessionKeyHash
};
}
/**
* Encrypt a call signaling message with the session key
*/
export async function encryptCallMessage(message: Record<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
const messageKey = await importAesGcmKey(sessionKey);
const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message)));
return {
iv: b64(encrypted.iv),
ciphertext: b64(encrypted.ciphertext),
salt: "", // Not used for message encryption, only for key wrapping
iv2: "",
wrappedSessionKey: ""
};
}
/**
* Decrypt a call signaling message
*/
export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise<Record<string, unknown>> {
const messageKey = await importAesGcmKey(sessionKey);
const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext));
return JSON.parse(new TextDecoder().decode(decrypted));
}
/**
* Generate 4 emojis representing the call session key
*/
@@ -174,63 +71,65 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
return emojis;
}
// HKDF info for CALL key wrapping (distinct from DM's info)
const CALL_INFO = new Uint8Array([2]);
/**
* Wraps a call session key for a specific recipient using ECDH key exchange
* @param recipientPublicKeyB64 - The recipient's public key in base64 format
* @param sessionKey - The session key to wrap
* @returns Promise that resolves to the wrapped session key payload
* Encrypts a call session key using Signal Protocol
* @param recipientId - The recipient's user ID
* @param sessionKey - The session key to encrypt
* @returns Promise that resolves to encrypted session key data
*/
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> {
const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function encryptCallSessionKey(recipientId: number, sessionKey: Uint8Array): Promise<{ type: number; body: string }> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
const salt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, sessionKey);
return {
salt: b64(salt),
iv2: b64(wrap.iv),
wrapped: b64(wrap.ciphertext)
};
const signalService = new SignalProtocolService(user.id.toString());
// Ensure we have a session with the recipient
const hasSession = await signalService.hasSession(recipientId);
if (!hasSession) {
// Fetch prekey bundle and establish session
const token = getAuthToken();
if (!token) {
throw new Error("No auth token");
}
const bundle = await fetchPreKeyBundle(recipientId, token);
if (!bundle) {
throw new Error("No prekey bundle available for recipient");
}
await signalService.processPreKeyBundle(recipientId, bundle);
}
// Encrypt the session key using Signal Protocol
const sessionKeyString = b64(sessionKey);
const encrypted = await signalService.encryptMessage(recipientId, sessionKeyString);
return encrypted;
}
/**
* Create a shared secret and derive session key for the receiver
* Decrypts a call session key using Signal Protocol
* @param senderId - The sender's user ID
* @param encryptedKey - The encrypted session key data
* @returns Promise that resolves to the decrypted session key
*/
export async function createSharedSecretAndDeriveSessionKey(
senderPublicKeyB64: string,
sessionKeyHash: string,
isInitiator: boolean
): Promise<CallSessionKey> {
const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
export async function decryptCallSessionKey(senderId: number, encryptedKey: { type: number; body: string }): Promise<Uint8Array> {
const user = useUserStore.getState().user.currentUser;
if (!user?.id) {
throw new Error("User not authenticated");
}
// Create shared secret using ECDH
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
// Derive the session key from the shared secret
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator);
}
/**
* Unwraps a call session key received from a sender using ECDH key exchange
* @param senderPublicKeyB64 - The sender's public key in base64 format
* @param payload - The wrapped session key payload
* @returns Promise that resolves to the unwrapped session key
*/
export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> {
const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
const salt = ub64(payload.salt);
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
const wk = await importAesGcmKey(wkRaw);
const sessionKey = await aesGcmDecrypt(wk, ub64(payload.iv2), ub64(payload.wrapped));
return new Uint8Array(sessionKey);
const signalService = new SignalProtocolService(user.id.toString());
// Decrypt using Signal Protocol
const decryptedString = await signalService.decryptMessage(senderId, encryptedKey);
// Convert back to Uint8Array
const sessionKey = new Uint8Array(
atob(decryptedString).split("").map(c => c.charCodeAt(0))
);
return sessionKey;
}
+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";
export interface CallState {
@@ -133,12 +133,16 @@ export class CallSignalingHandler {
private handleCallSessionKey(message: CallSignalingMessage) {
const state = this.getState();
const { sessionKeyHash, data } = message;
const { sessionKeyHash } = message;
const data = message.data as CallSessionKeyData;
if (sessionKeyHash) {
state.setCallSessionKeyHash(sessionKeyHash);
}
if (data && 'wrappedSessionKey' in data && data.wrappedSessionKey && message.fromUserId) {
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash);
// Check if data is CallSessionKeyData and has encryptedSessionKey
if (data && data.encryptedSessionKey && message.fromUserId) {
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.encryptedSessionKey);
}
}
+16 -29
View File
@@ -1,8 +1,8 @@
import api from "@/core/api";
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
import type { CallSignalingMessage } from "@/core/types";
import { request } from "@/core/websocket";
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
import { importAesGcmKey } from "@fromchat/protocol";
import { encryptCallSessionKey, decryptCallSessionKey, rotateCallSessionKey } from "./encryption";
import { importAesGcmKey } from "@/utils/crypto/symmetric";
import E2EEWorker from "./e2eeWorker?worker";
import { delay } from "@/utils/utils";
@@ -855,21 +855,18 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string)
export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
try {
const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!);
if (!recipientPublicKey) {
console.warn("No recipient public key for", userId);
return;
}
const wrapped = await wrapCallSessionKeyForRecipient(recipientPublicKey, sessionKey);
// Encrypt session key using Signal Protocol
const encrypted = await encryptCallSessionKey(userId, sessionKey);
await sendSignalingMessage({
type: "call_session_key",
fromUserId: 0,
toUserId: userId,
sessionKeyHash,
data: { wrappedSessionKey: wrapped }
data: { encryptedSessionKey: encrypted }
});
} catch (e) {
console.error("Failed to send wrapped session key:", e);
console.error("Failed to send encrypted session key:", e);
}
}
@@ -886,31 +883,21 @@ export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promi
export async function receiveWrappedSessionKey(
fromUserId: number,
wrappedPayload: WrappedSessionKeyPayload,
sessionKeyHash?: string
encryptedKey: { type: number; body: string }
): Promise<void> {
try {
const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!);
if (!senderPublicKey) {
console.error("Failed to get sender public key");
return;
}
if (!wrappedPayload || !sessionKeyHash) {
console.error("Missing wrapped payload or session key hash");
if (!encryptedKey) {
console.error("Missing encrypted session key");
return;
}
// Unwrap the session key from the encrypted payload
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, {
salt: wrappedPayload.salt,
iv2: wrappedPayload.iv2,
wrapped: wrappedPayload.wrapped
});
// Decrypt the session key using Signal Protocol
const sessionKey = await decryptCallSessionKey(fromUserId, encryptedKey);
// Use the unwrapped session key directly (both sides should have the same key)
await setSessionKey(fromUserId, unwrappedSessionKey);
// Use the decrypted session key for media encryption
await setSessionKey(fromUserId, sessionKey);
} catch (e) {
console.error("Failed to unwrap session key:", e);
console.error("Failed to decrypt session key:", e);
}
}
+1 -7
View File
@@ -505,7 +505,7 @@ export interface CallEndData {
}
export interface CallSessionKeyData {
wrappedSessionKey?: WrappedSessionKeyPayload;
encryptedSessionKey: { type: number; body: string };
}
export interface CallVideoToggleData {
@@ -526,12 +526,6 @@ export interface CallScreenShareToggleMessageData {
data: CallScreenShareToggleData;
}
export interface WrappedSessionKeyPayload {
salt: string;
iv2: string;
wrapped: string;
}
export interface CallVideoToggleMessage extends CallSignalingMessage {
type: "call_video_toggle";
data: CallVideoToggleData;
+49 -61
View File
@@ -5,7 +5,7 @@
* @version 1.0.0
*/
import { request } from "./websocket";
import { send } from "./websocket";
import type {
TypingWebSocketMessage,
StopTypingWebSocketMessage,
@@ -39,21 +39,18 @@ export class TypingManager {
async sendTyping(): Promise<void> {
if (!this.authToken) return;
try {
const message: TypingRequest = {
type: "typing",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
const message: TypingRequest = {
type: "typing",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
await request(message);
this.scheduleStopTyping("public");
} catch (error) {
console.error("Failed to send typing indicator:", error);
}
// Fire-and-forget - don't wait for response
send(message);
this.scheduleStopTyping("public");
}
/**
@@ -62,21 +59,18 @@ export class TypingManager {
async sendStopTyping(): Promise<void> {
if (!this.authToken) return;
try {
const message: StopTypingRequest = {
type: "stopTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
const message: StopTypingRequest = {
type: "stopTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
await request(message);
this.clearStopTypingTimeout("public");
} catch (error) {
console.error("Failed to send stop typing indicator:", error);
}
// Fire-and-forget - don't wait for response
send(message);
this.clearStopTypingTimeout("public");
}
/**
@@ -85,23 +79,20 @@ export class TypingManager {
async sendDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return;
try {
const message: DmTypingRequest = {
type: "dmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
const message: DmTypingRequest = {
type: "dmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
await request(message);
this.scheduleStopDmTyping(recipientId);
} catch (error) {
console.error("Failed to send DM typing indicator:", error);
}
// Fire-and-forget - don't wait for response
send(message);
this.scheduleStopDmTyping(recipientId);
}
/**
@@ -110,23 +101,20 @@ export class TypingManager {
async sendStopDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return;
try {
const message: StopDmTypingRequest = {
type: "stopDmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
const message: StopDmTypingRequest = {
type: "stopDmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
await request(message);
this.clearStopTypingTimeout(`dm_${recipientId}`);
} catch (error) {
console.error("Failed to send stop DM typing indicator:", error);
}
// Fire-and-forget - don't wait for response
send(message);
this.clearStopTypingTimeout(`dm_${recipientId}`);
}
/**
+4 -35
View File
@@ -6,7 +6,6 @@
*/
import { openDB, type IDBPDatabase } from "idb";
import type { WebSocketCredentials, WebSocketMessage } from "./types";
interface UpdateMessage<T = any> {
type: string;
@@ -72,28 +71,18 @@ export async function setLastSequence(seq: number): Promise<void> {
* Process a batched updates message
* @param message - The batched updates message from the server
* @param handler - Function to handle individual updates
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
*/
export async function processBatchedUpdates(
message: BatchedUpdatesMessage,
handler: (update: UpdateMessage) => void,
requestMissedFn?: (lastSeq: number) => Promise<void>
handler: (update: UpdateMessage) => void
): Promise<void> {
const { seq, updates } = message;
const lastSeq = await getLastSequence();
// Check for gap
// Log gap for debugging, but don't try to recover (getUpdates doesn't work properly)
if (seq !== lastSeq + 1 && lastSeq > 0) {
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`);
// Request missing updates if function provided
if (requestMissedFn) {
try {
await requestMissedFn(lastSeq);
} catch (error) {
console.error("Failed to request missed updates for gap:", error);
}
}
const gapSize = seq - (lastSeq + 1);
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq} (gap size: ${gapSize}). Skipping ${gapSize} updates.`);
}
// Process all updates in the batch
@@ -104,23 +93,3 @@ export async function processBatchedUpdates(
// Update last sequence number
await setLastSequence(seq);
}
/**
* Request missed updates from the server
* @param lastSeq - The last sequence number we received
* @param requestFn - Function to send the request to the server
* @param credentials - Optional WebSocket credentials for authentication
*/
export async function requestMissedUpdates(
lastSeq: number,
requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise<void>,
credentials?: WebSocketCredentials
): Promise<void> {
if (lastSeq > 0) {
await requestFn({
type: "getUpdates",
data: { lastSeq },
credentials
});
}
}
+21 -27
View File
@@ -12,7 +12,7 @@ import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager";
import { useUserStore } from "@/state/user";
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager";
import { processBatchedUpdates } from "./updateManager";
import { getAuthToken } from "@/core/api/user/auth";
interface HttpError extends Error {
@@ -161,21 +161,10 @@ function setupEventHandlers(): void {
// Handle batched updates
if (response.type === "updates" && "seq" in response && "updates" in response) {
// Create function to request missed updates with credentials
const token = getAuthToken();
const requestMissedFn = token ? async (lastSeq: number) => {
await requestMissedUpdates(lastSeq, async (req) => {
await request(req);
}, {
scheme: "Bearer",
credentials: token
});
} : undefined;
await processBatchedUpdates(response as any, (update) => {
// Route individual updates to appropriate handlers
handleUpdate(update);
}, requestMissedFn);
});
return;
}
@@ -250,20 +239,9 @@ function setupEventHandlers(): void {
console.error("Failed to send ping on reconnect:", error);
}
// Send last sequence number and request missed updates on reconnect
// Wait a bit for ping to complete authentication
await delay(100);
try {
const lastSeq = await getLastSequence();
if (lastSeq > 0) {
await requestMissedUpdates(lastSeq, async (req) => {
await request(req);
}, credentials);
}
} catch (error) {
console.error("Failed to request missed updates:", error);
}
// Note: We don't request missed updates on reconnect because getUpdates
// doesn't properly return updates (they're sent directly via WebSocket
// but the client can't handle them). Gaps will be logged but not recovered.
}
} catch (error) {
console.error("Failed to authenticate on reconnect:", error);
@@ -359,6 +337,22 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
});
}
/**
* Send a WebSocket message without waiting for a response (fire-and-forget)
* Useful for typing indicators and other non-critical messages
*/
export function send<T = unknown>(payload: WebSocketMessage<T>): void {
if (websocket.readyState !== WebSocket.OPEN) {
console.warn("WebSocket is not open, cannot send message");
return;
}
try {
websocket.send(JSON.stringify(payload));
} catch (error) {
console.error("Failed to send WebSocket message:", error);
}
}
// --------------
// Initialization
// --------------
+39 -10
View File
@@ -1,12 +1,13 @@
import { AuthContainer } from "./Auth";
import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useState, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "motion/react";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { LoginForm } from "./LoginForm";
import { RegisterForm } from "./RegisterForm";
import type { Variants, Transition } from "motion/react";
import styles from "./auth.module.scss";
import { useUserStore } from "@/state/user";
const slideVariants: Variants = {
enter: (direction: number) => ({
@@ -37,9 +38,9 @@ const slideTransition: Transition = {
export default function AuthPage() {
const [searchParams] = useSearchParams();
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
if (navigateDownloadApp) return navigateDownloadApp;
const navigate = useNavigate();
const { user } = useUserStore();
const [direction, setDirection] = useState(0);
const prevMode = useRef(searchParams.get("mode") || "login");
const containerRef = useRef<HTMLDivElement>(null);
@@ -49,12 +50,33 @@ export default function AuthPage() {
const currentMode = searchParams.get("mode") || "login";
const enteringElementRef = useRef<"login" | "register" | null>(null);
const [effectActivated, setEffectActivated] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
useEffect(() => {
useLayoutEffect(() => {
if (prevMode.current !== currentMode) {
setDirection(currentMode === "register" ? 1 : -1);
const previousMode = prevMode.current;
// Measure the exiting form's height BEFORE changing anything
// This works whether it's relative or absolute
const exitingComponent = previousMode === "login" ? loginFormRef.current : registerFormRef.current;
let measuredHeight: number | null = null;
if (exitingComponent) {
const height = exitingComponent.scrollHeight;
if (height > 0) {
measuredHeight = height;
}
}
// Update mode and direction first
prevMode.current = currentMode;
setDirection(currentMode === "register" ? 1 : -1);
enteringElementRef.current = currentMode as "login" | "register";
// Set height and transition state together
if (measuredHeight !== null) {
setContainerHeight(measuredHeight);
}
setIsTransitioning(true);
}
}, [currentMode]);
@@ -90,6 +112,12 @@ export default function AuthPage() {
}
};
}, [currentMode]);
// Now we can do conditional returns after all hooks are called
if (navigateDownloadApp) return navigateDownloadApp;
if (user.authToken && user.currentUser) {
return <Navigate to="/chat" replace />;
}
function switchMode(newMode: "login" | "register") {
navigate(`/auth?mode=${newMode}`, { replace: true });
@@ -105,6 +133,7 @@ export default function AuthPage() {
return () => {
if (currentMode === mode && enteringElementRef.current === mode) {
enteringElementRef.current = null;
setIsTransitioning(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
@@ -129,9 +158,6 @@ export default function AuthPage() {
width: "100%",
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
transition: "height 0.3s ease"
}}
onAnimationStart={() => {
}}
onAnimationEnd={() => {
setContainerHeight("auto");
@@ -151,7 +177,7 @@ export default function AuthPage() {
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
className={styles.formWrapper}
style={{
position: containerHeight === "auto" ? "relative" : "absolute"
position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
}}
>
<LoginForm onSwitchMode={() => switchMode("register")} />
@@ -168,6 +194,9 @@ export default function AuthPage() {
transition={slideTransition}
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
className={styles.formWrapper}
style={{
position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
}}
>
<RegisterForm onSwitchMode={() => switchMode("login")} />
</motion.div>
+47 -1
View File
@@ -79,7 +79,14 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
setIsLoading(true);
console.log("========================================");
console.log("[LoginForm] 🚀 LOGIN FORM SUBMITTED");
console.log("[LoginForm] Username:", username);
console.log("[LoginForm] Has password:", !!password);
console.log("========================================");
try {
console.log("[LoginForm] Deriving auth secret...");
const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: LoginRequest = {
username: username,
@@ -87,13 +94,52 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
}
try {
console.log("[LoginForm] Calling login API...");
const data = await api.user.auth.login(request);
console.log("[LoginForm] Login successful, user ID:", data.user?.id);
setUser(data.token, data.user);
try {
console.log("[LoginForm] Ensuring keys on login...");
await api.user.auth.ensureKeysOnLogin(password, data.token);
console.log("[LoginForm] Keys ensured");
// Initialize Signal Protocol after keys are set up (non-blocking)
if (data.user?.id) {
console.log("[LoginForm] ✅ User ID exists, scheduling Signal Protocol initialization");
// Run Signal Protocol initialization in background to avoid blocking navigation
// Use setTimeout to ensure it runs even if navigation happens
setTimeout(async () => {
try {
console.log("[LoginForm] 🚀 Starting Signal Protocol initialization...");
const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
await initializeSignalProtocol({
userId: data.user!.id.toString(),
password,
token: data.token,
restoreSessions: true,
uploadSessions: true
});
console.log("[LoginForm] ✅ Signal Protocol initialization completed");
} catch (e) {
console.error("[LoginForm] ❌ Signal Protocol initialization failed:", e);
console.error("[LoginForm] Error details:", {
message: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined
});
}
}, 0);
console.log("[LoginForm] ✅ Signal Protocol initialization scheduled");
} else {
console.warn("[LoginForm] ⚠️ No user ID, skipping Signal Protocol initialization");
}
} catch (e) {
console.error("Key setup failed:", e);
console.error("[LoginForm] ❌ Key setup failed:", e);
console.error("[LoginForm] Error details:", {
message: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined
});
}
// Ensure WebSocket is connected and authenticated
+22 -3
View File
@@ -114,12 +114,31 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
confirm_password: derived
}
try {
const data = await api.user.auth.register(request);
setUser(data.token, data.user);
try {
const data = await api.user.auth.register(request);
setUser(data.token, data.user);
try {
await api.user.auth.ensureKeysOnLogin(password, data.token);
// Initialize Signal Protocol after keys are set up (non-blocking)
if (data.user?.id) {
setTimeout(async () => {
try {
const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
await initializeSignalProtocol({
userId: data.user!.id.toString(),
password,
token: data.token,
restoreSessions: false,
uploadSessions: true
});
} catch (e) {
console.error("[RegisterForm] Signal Protocol initialization failed:", e);
}
}, 0);
}
} catch (e) {
console.error("Key setup failed:", e);
}
+131 -50
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat";
import api from "@/core/api";
import { decryptDm, sendDMViaWebSocket } from "@/core/api/dm";
import type { ConversationResponse } from "@/core/api/chats/dm";
import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "@/core/websocket";
@@ -51,6 +52,10 @@ export function useDM() {
if (!user.authToken) return;
try {
// Wait for session restoration to complete (if in progress)
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
await waitForSessionRestore();
// Get public key
const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
@@ -62,11 +67,22 @@ export function useDM() {
// Find last message
const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null;
const isAuthor = lastMessage.senderId === user.currentUser?.id;
try {
lastPlaintext = (JSON.parse(await api.chats.dm.decrypt(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
if (isAuthor) {
// For our own messages, fetch plaintexts from server (encrypted at rest)
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(dmUser.id);
const cached = plaintexts.get(lastMessage.id);
if (cached) {
lastPlaintext = (JSON.parse(cached) as DmEncryptedJSON).data.content;
}
} else {
// Incoming message - decrypt via Signal
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, lastMessage.senderId)) as DmEncryptedJSON).data.content;
}
} catch (error) {
console.error("Failed to decrypt last message:", error);
console.error("Failed to get last message preview:", error);
}
// Calculate unread count
@@ -101,6 +117,10 @@ export function useDM() {
usersLoadedRef.current = true;
setIsLoadingUsers(true);
try {
// Wait for session restoration to complete (if in progress)
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
await waitForSessionRestore();
const conversations = await api.chats.dm.conversations(user.authToken);
// Process conversations and decrypt last messages
@@ -110,20 +130,30 @@ export function useDM() {
if (conv.lastMessage) {
try {
// Get the public key for the other user
const otherUserId = conv.lastMessage.senderId === user.currentUser?.id
? conv.lastMessage.recipientId
: conv.lastMessage.senderId;
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
// Decrypt the last message
const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!);
const isAuthor = conv.lastMessage.senderId === user.currentUser?.id;
const otherUserId = conv.user.id; // the other party in the conversation
if (isAuthor) {
// Fetch plaintext of our own last message from server
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
const cached = plaintexts.get(conv.lastMessage.id);
if (cached) {
const data = JSON.parse(cached) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(data.data.content, conv.lastMessage.senderId, user.currentUser!.id);
}
} else {
// Incoming message - decrypt
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await decryptDm(conv.lastMessage, conv.lastMessage.senderId);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser!.id);
}
}
} catch (error) {
console.error("Failed to decrypt last message for user", conv.user.id, error);
// Silently fail for last message decryption - it's not critical
// The message will just show "No messages" instead
console.debug("Failed to decrypt last message for user", conv.user.id, error);
}
}
@@ -152,19 +182,43 @@ export function useDM() {
}, [user.authToken]);
// Load DM history for active conversation
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
const loadDMHistory = useCallback(async (userId: number) => {
if (!user.authToken || isLoadingHistory) return;
setIsLoadingHistory(true);
try {
// Wait for session restoration to complete (if in progress)
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
await waitForSessionRestore();
console.log(`[useDM] Session restoration complete, proceeding with message load for user ${userId}`);
const { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
for (const env of messages) {
try {
const text = await api.chats.dm.decrypt(env, publicKey);
const isAuthor = env.senderId !== userId;
// Check if this is a message sent by the current user
const isAuthor = env.senderId === user.currentUser?.id;
let text: string;
if (isAuthor) {
// For sent messages, we can't decrypt them in Signal Protocol
// Try to get the plaintext from the server (encrypted)
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(userId);
const cached = plaintexts.get(env.id);
if (cached) {
text = cached;
} else {
// Not on server - skip this message
continue;
}
} else {
// Decrypt incoming messages
text = await decryptDm(env, env.senderId);
}
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
decryptedMessages.push({
@@ -204,11 +258,11 @@ export function useDM() {
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
// Send DM message
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
const sendDMMessage = useCallback(async (recipientId: number, content: string) => {
if (!user.authToken) return;
try {
await api.chats.dm.send(recipientId, publicKey, content, user.authToken);
await sendDMViaWebSocket(recipientId, content, user.authToken);
} catch (error) {
console.error("Failed to send DM:", error);
}
@@ -234,7 +288,7 @@ export function useDM() {
});
// Load conversation history
await loadDMHistory(dmUser.id, publicKey);
await loadDMHistory(dmUser.id);
} catch (error) {
console.error("Failed to start DM conversation:", error);
}
@@ -254,23 +308,29 @@ export function useDM() {
const conversations = await api.chats.dm.conversations(user.authToken);
const userConversation = conversations.find(conv => conv.user.id === userId);
if (userConversation) {
if (userConversation) {
let lastMessageContent: string | undefined = undefined;
if (userConversation.lastMessage) {
try {
// Get the public key for the other user
const otherUserId = userConversation.lastMessage.senderId === user.currentUser?.id
? userConversation.lastMessage.recipientId
: userConversation.lastMessage.senderId;
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
// Decrypt the last message
const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!);
}
const isAuthor = userConversation.lastMessage.senderId === user.currentUser?.id;
const otherUserId = userId;
if (isAuthor) {
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
const cached = plaintexts.get(userConversation.lastMessage.id);
if (cached) {
const data = JSON.parse(cached) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(data.data.content, userConversation.lastMessage.senderId, user.currentUser!.id);
}
} else {
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await decryptDm(userConversation.lastMessage, userConversation.lastMessage.senderId);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser!.id);
}
}
} catch (error) {
console.error("Failed to decrypt last message for user", userId, error);
}
@@ -313,23 +373,33 @@ export function useDM() {
return;
}
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
// Update unread count and last message preview
try {
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content;
let messageContent: string | null = null;
if (senderId === user.currentUser.id) {
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
const cached = plaintexts.get(envelope.id);
if (cached) {
messageContent = (JSON.parse(cached) as DmEncryptedJSON).data.content;
}
} else {
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await decryptDm(envelope, senderId);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
messageContent = decryptedData.data.content;
}
}
if (messageContent !== null) {
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
lastMessage: formattedMessage,
publicKey
lastMessage: formattedMessage
}
: u
));
@@ -346,18 +416,29 @@ export function useDM() {
}
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
try {
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content;
let messageContent: string | null = null;
if (senderId === user.currentUser.id) {
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
const cached = plaintexts.get(id);
if (cached) {
messageContent = (JSON.parse(cached) as DmEncryptedJSON).data.content;
}
} else {
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await decryptDm(envelope, senderId);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
messageContent = decryptedData.data.content;
}
}
if (messageContent !== null) {
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
lastMessage: formattedMessage,
publicKey
lastMessage: formattedMessage
}
: u
));
+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;
}
+34 -16
View File
@@ -6,10 +6,12 @@ import { parse } from "marked";
import { escape as escapeHtml } from "he";
import { useEffect, useState, useRef, useMemo } from "react";
import api from "@/core/api";
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile";
import { StatusBadge } from "@/core/components/StatusBadge";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
import { removePadding } from "@/utils/crypto/obfuscation";
import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
@@ -211,7 +213,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}, [message.files, isDm, decryptedFiles]);
async function decryptFile(file: Attachment): Promise<string | null> {
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null;
if (!file.encrypted || !isDm || !user.authToken || !dmEnvelope || !user.currentUser?.id) return null;
// Check if already decrypted
if (decryptedFiles.has(file.path)) {
@@ -219,7 +221,6 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}
try {
// no-op decrypt indicator removed from UI
// Fetch encrypted file
const response = await fetch(file.path, {
headers: api.user.auth.getAuthHeaders(user.authToken!)
@@ -228,19 +229,36 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
const encryptedData = await response.arrayBuffer();
// Get current user's keys
const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Derive shared secret with the recipient's public key
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
// Derive wrapping key using the salt from the DM envelope
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Unwrap the message key
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
// Decrypt the master key using Signal Protocol
const signalService = new SignalProtocolService(user.currentUser.id.toString());
const senderId = dmEnvelope.senderId;
// Remove padding from wrappedMk (backward compatible)
let wrappedMkStr: string;
try {
wrappedMkStr = removePadding(dmEnvelope.wrappedMk);
} catch {
// If padding removal fails, assume it's an old message without padding
wrappedMkStr = dmEnvelope.wrappedMk;
}
// Parse wrappedMk - it's a JSON string containing Signal Protocol encrypted data
let mk: Uint8Array;
try {
const encryptedMk = JSON.parse(wrappedMkStr);
if (encryptedMk.type && encryptedMk.body) {
// Signal Protocol encrypted
const mkBase64 = await signalService.decryptMessage(senderId, encryptedMk);
mk = new Uint8Array(
atob(mkBase64).split("").map(c => c.charCodeAt(0))
);
} else {
throw new Error("Invalid Signal Protocol message format");
}
} catch (error) {
console.error("Failed to decrypt master key with Signal Protocol:", error);
throw error;
}
// Decrypt the file using the message key
const iv = new Uint8Array(encryptedData, 0, 12);
@@ -1,10 +1,12 @@
import { MessagePanel } from "./MessagePanel";
import api from "@/core/api";
import { decryptDm, sendDMViaWebSocket, sendDmWithFiles } from "@/core/api/dm";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types";
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager";
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
export interface DMPanelData {
userId: number;
@@ -17,11 +19,16 @@ export interface DMPanelData {
export class DMPanel extends MessagePanel {
public dmData: DMPanelData | null = null;
private messagesLoaded: boolean = false;
private signalService: SignalProtocolService | null = null;
constructor(
user: UserState
) {
super("dm", user);
// Initialize Signal Protocol service if user is available
if (user.currentUser?.id) {
this.signalService = new SignalProtocolService(user.currentUser.id.toString());
}
}
isDm(): boolean {
@@ -52,10 +59,41 @@ export class DMPanel extends MessagePanel {
clearMessages(): void {
super.clearMessages();
this.messagesLoaded = false;
this.processedMessageIds.clear();
this.failedDecryptionIds.clear();
}
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey);
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[], plaintextOverride?: string) {
// Check if this is a message sent by the current user
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
let plaintext: string;
if (isSentByUs) {
// Can't decrypt our own sent messages in Signal Protocol
// The plaintext should be passed in from loadMessages (fetched from server)
if (plaintextOverride) {
plaintext = plaintextOverride;
} else {
// Try to fetch from server as fallback
try {
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData!.userId);
const cached = plaintexts.get(env.id);
if (cached) {
plaintext = cached;
} else {
// Not on server - skip this message
throw new Error("Cannot decrypt own sent message - plaintext not available on server");
}
} catch (error) {
throw new Error("Cannot decrypt own sent message - plaintext must be fetched from server first");
}
}
} else {
// Decrypt incoming messages
plaintext = await decryptDm(env, env.senderId);
}
const username = formatDMUsername(
env.senderId,
env.recipientId,
@@ -103,6 +141,35 @@ export class DMPanel extends MessagePanel {
this.setLoading(true);
try {
// Wait for session restoration to complete (if in progress)
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
await waitForSessionRestore();
console.log(`[DMPanel] Session restoration complete, proceeding with message load for user ${this.dmData.userId}`);
// Ensure Signal Protocol session is established before fetching messages
if (!this.signalService && this.currentUser.currentUser?.id) {
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
}
if (this.signalService) {
const hasSession = await this.signalService.hasSession(this.dmData.userId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during history load.`);
} catch (error) {
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during history load:`, error);
// Continue loading history, but decryption will likely fail for new messages
}
} else {
console.log(`[DMPanel] Signal Protocol session exists for user ${this.dmData.userId}`);
}
}
// Fetch encrypted plaintexts from server for sent messages
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
const limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
const decryptedMessages: Message[] = [];
@@ -110,19 +177,82 @@ export class DMPanel extends MessagePanel {
for (const env of messages) {
try {
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
// Mark as processed to prevent duplicates
if (env.id) {
this.processedMessageIds.add(env.id);
}
// For sent messages, use plaintext from server
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
let dmMsg: Message;
if (isSentByUs) {
const cachedPlaintext = plaintexts.get(env.id);
if (!cachedPlaintext) {
// Not on server - skip this message
continue;
}
// Parse the plaintext as if it came from parseTextPayload
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
let content = cachedPlaintext;
let reply_to_id: number | undefined = undefined;
try {
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
if (obj && obj.type === "text" && obj.data) {
content = obj.data.content;
reply_to_id = Number(obj.data.reply_to_id) || undefined;
}
} catch {}
dmMsg = {
id: env.id,
user_id: env.senderId,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
reactions: env.reactions || [],
runtimeData: {
dmEnvelope: env
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
} else {
dmMsg = await this.parseTextPayload(env, decryptedMessages);
}
decryptedMessages.push(dmMsg);
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (error) {
console.error("Error decrypting message:", error);
// Log warning with deduplication to avoid console spam
if (env.id && !this.failedDecryptionIds.has(env.id)) {
this.failedDecryptionIds.add(env.id);
console.warn(`Failed to decrypt DM ${env.id}:`, error instanceof Error ? error.message : String(error));
}
// Remove from processed set if decryption failed
if (env.id) {
this.processedMessageIds.delete(env.id);
}
}
}
this.clearMessages();
decryptedMessages.forEach(msg => this.addMessage(msg));
// Only clear and replace if we actually decrypted something
if (decryptedMessages.length > 0) {
this.clearMessages();
decryptedMessages.forEach(msg => this.addMessage(msg));
} else {
console.warn("[DMPanel] No messages decrypted; keeping existing messages to avoid empty state after reload.");
}
this.setHasMoreMessages(has_more);
// Update last read ID
@@ -149,6 +279,28 @@ export class DMPanel extends MessagePanel {
this.setLoadingMore(true);
try {
// Ensure Signal Protocol session is established before fetching messages
if (!this.signalService && this.currentUser.currentUser?.id) {
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
}
if (this.signalService) {
const hasSession = await this.signalService.hasSession(this.dmData.userId);
if (!hasSession) {
try {
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during more history load.`);
} catch (error) {
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during more history load:`, error);
// Continue loading history, but decryption will likely fail for new messages
}
}
}
// Fetch encrypted plaintexts from server for sent messages
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
const limit = this.calculateMessageLimit();
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
this.dmData.userId,
@@ -161,10 +313,60 @@ export class DMPanel extends MessagePanel {
const decryptedMessages: Message[] = [];
for (const env of newEnvelopes) {
try {
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
// Mark as processed to prevent duplicates
if (env.id) {
this.processedMessageIds.add(env.id);
}
// For sent messages, use plaintext from server
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
let dmMsg: Message;
if (isSentByUs) {
const cachedPlaintext = plaintexts.get(env.id);
if (!cachedPlaintext) {
// Not on server - skip this message
continue;
}
// Parse the plaintext as if it came from parseTextPayload
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
let content = cachedPlaintext;
let reply_to_id: number | undefined = undefined;
try {
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
if (obj && obj.type === "text" && obj.data) {
content = obj.data.content;
reply_to_id = Number(obj.data.reply_to_id) || undefined;
}
} catch {}
dmMsg = {
id: env.id,
user_id: env.senderId,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
reactions: env.reactions || [],
runtimeData: {
dmEnvelope: env
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
} else {
dmMsg = await this.parseTextPayload(env, decryptedMessages);
}
decryptedMessages.push(dmMsg);
} catch (error) {
console.error("Error decrypting message:", error);
// Silently skip messages that can't be decrypted
}
}
@@ -193,21 +395,30 @@ export class DMPanel extends MessagePanel {
}
const json = JSON.stringify(payload);
if (files.length === 0) {
await api.chats.dm.send(
this.dmData.userId,
this.dmData.publicKey,
json,
this.currentUser.authToken
);
} else {
await api.chats.dm.sendWithFiles(
this.dmData.userId,
this.dmData.publicKey,
json,
files,
this.currentUser.authToken
);
try {
if (files.length === 0) {
await sendDMViaWebSocket(
this.dmData.userId,
json,
this.currentUser.authToken
);
} else {
await sendDmWithFiles(
this.dmData.userId,
json,
files,
this.currentUser.authToken
);
}
} catch (error) {
console.error("Failed to send DM:", error);
// Check if it's a prekey exhaustion error
const { PrekeyExhaustedError } = await import("@/core/api/crypto/prekeys");
if (error instanceof PrekeyExhaustedError) {
const { alert } = await import("@/core/components/AlertDialog");
await alert("Cannot Send Message: The recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys. This ensures maximum privacy and security.");
}
}
}
@@ -224,28 +435,102 @@ export class DMPanel extends MessagePanel {
}
// Track processed message IDs to prevent duplicates
private processedMessageIds: Set<number> = new Set();
private failedDecryptionIds: Set<number> = new Set(); // Track messages that failed decryption to avoid spam
// Handle incoming WebSocket DM messages
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
// Only process actual DM messages, not typing indicators or other events
if (response.type === "dmNew" && this.dmData) {
const envelope = response.data;
// Validate envelope has required fields
if (!envelope || !envelope.ciphertext || !envelope.senderId || !envelope.id) {
console.warn("Invalid DM envelope received, skipping");
return;
}
// Skip if we've already processed this message
if (this.processedMessageIds.has(envelope.id)) {
return;
}
// If this is for the active DM conversation
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
try {
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
// Mark as processed before attempting decryption
this.processedMessageIds.add(envelope.id);
// Check if this is a confirmation of a message we sent
const isOurMessage = envelope.senderId !== this.dmData.userId;
const isOurMessage = envelope.senderId === this.currentUser.currentUser?.id;
let dmMsg: Message;
if (isOurMessage) {
// This is our message being confirmed, find the temp message and replace it
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
for (const tempMsg of tempMessages) {
if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) {
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg);
// For sent messages, fetch plaintext from server
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
const cachedPlaintext = plaintexts.get(envelope.id);
if (cachedPlaintext) {
// Parse the plaintext and create message
dmMsg = await this.parseTextPayload(envelope, this.getMessages(), cachedPlaintext);
} else {
// Plaintext not available yet - this might be a new message confirmation
// Try to get it from temp message content
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
let tempMsgContent: string | null = null;
for (const tempMsg of tempMessages) {
if (tempMsg.runtimeData?.sendingState?.retryData?.content) {
tempMsgContent = tempMsg.content;
break;
}
}
if (tempMsgContent) {
dmMsg = await this.parseTextPayload(envelope, this.getMessages(), tempMsgContent);
} else {
// Can't display without plaintext - skip
console.warn(`Cannot display sent message ${envelope.id} - plaintext not available`);
return;
}
}
} else {
// Incoming message - decrypt normally
dmMsg = await this.parseTextPayload(envelope, this.getMessages());
}
if (isOurMessage) {
// This is our message being confirmed, find the temp message and replace it
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
let tempMsgContent: string | null = null;
for (const tempMsg of tempMessages) {
if ((tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content ||
tempMsg.content === dmMsg.content) && tempMsg.runtimeData?.sendingState?.tempId) {
tempMsgContent = tempMsg.content; // Get plaintext from temp message
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId, dmMsg);
// Upload the plaintext to server (encrypted) so we can display it in history
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
if (tempMsgContent) {
await uploadMessagePlaintext(this.dmData.userId, envelope.id, tempMsgContent);
}
return;
}
}
// If we didn't find a temp message, try to upload from dmMsg content
// (this might happen if the page was reloaded)
if (!tempMsgContent && dmMsg.content) {
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
await uploadMessagePlaintext(this.dmData.userId, envelope.id, dmMsg.content);
}
// Add the message to the chat
this.addMessage(dmMsg);
return;
}
// Incoming message - add to chat
this.addMessage(dmMsg);
this.addMessage(dmMsg);
@@ -254,19 +539,27 @@ export class DMPanel extends MessagePanel {
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
}
} catch (error) {
console.error("Failed to decrypt incoming DM:", error);
// Only log each failed message once to avoid console spam
if (envelope.id && !this.failedDecryptionIds.has(envelope.id)) {
this.failedDecryptionIds.add(envelope.id);
console.warn(`Failed to decrypt DM ${envelope.id}:`, error instanceof Error ? error.message : String(error));
}
// Remove from processed set so we can retry if needed
if (envelope.id) {
this.processedMessageIds.delete(envelope.id);
}
}
}
}
if (response.type === "dmEdited" && this.dmData) {
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
const { id, senderId, recipientId, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
try {
// Decrypt new content in-place
const plaintext = await api.chats.dm.decrypt(
const plaintext = await decryptDm(
{
id,
senderId: 0,
recipientId: 0,
senderId,
recipientId,
iv,
ciphertext,
salt,
@@ -274,7 +567,7 @@ export class DMPanel extends MessagePanel {
wrappedMk,
timestamp: new Date().toISOString()
},
this.dmData.publicKey
senderId
);
let content = plaintext;
let files: Message["files"] | undefined = undefined;
@@ -311,6 +604,7 @@ export class DMPanel extends MessagePanel {
this.dmData = null;
this.messagesLoaded = false;
this.clearMessages();
this.failedDecryptionIds.clear(); // Clear failed decryption tracking
this.updateState({
id: "dm",
title: "Select a user",
@@ -379,7 +673,7 @@ export class DMPanel extends MessagePanel {
reply_to_id: msg?.reply_to?.id ?? undefined
}
};
api.chats.dm.edit(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
api.chats.dm.edit(messageId, this.dmData.userId, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
console.error("Failed to edit DM:", e);
});
}

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