mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement real, working microservices architecture
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Shared modules package
|
||||
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
|
||||
# Database
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/database.db")
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET", "default-jwt-secret-for-development")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
# Token inactivity expiration - token expires if not used for this duration
|
||||
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
||||
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
|
||||
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
|
||||
|
||||
# Owner user
|
||||
OWNER_USERNAME = os.getenv("OWNER_USERNAME", "owner")
|
||||
|
||||
# Push notifications
|
||||
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "")
|
||||
VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "")
|
||||
VAPID_SUBJECT = os.getenv("VAPID_SUBJECT", "mailto:admin@example.com")
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
||||
|
||||
# File uploads
|
||||
MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "10485760")) # 10MB
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".avi", ".mp3", ".wav"}
|
||||
|
||||
# WebSocket
|
||||
WEBSOCKET_PING_INTERVAL = 30
|
||||
WEBSOCKET_PING_TIMEOUT = 60
|
||||
|
||||
# Encryption
|
||||
ENCRYPTION_KEY_LENGTH = 32
|
||||
ENCRYPTION_NONCE_LENGTH = 12
|
||||
|
||||
# Moderation
|
||||
PROFANITY_THRESHOLD = float(os.getenv("PROFANITY_THRESHOLD", "0.8"))
|
||||
SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", "0.85"))
|
||||
|
||||
# WebRTC
|
||||
WEBRTC_ICE_SERVERS = [
|
||||
{"urls": "stun:stun.l.google.com:19302"},
|
||||
{"urls": "stun:stun1.l.google.com:19302"}
|
||||
]
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy import create_engine
|
||||
from .constants import DATABASE_URL
|
||||
|
||||
# Database connection settings
|
||||
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
|
||||
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
|
||||
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
||||
|
||||
POOL_CONFIG = {
|
||||
"pool_size": POOL_SIZE,
|
||||
"max_overflow": MAX_OVERFLOW,
|
||||
"pool_recycle": POOL_RECYCLE,
|
||||
"pool_timeout": POOL_TIMEOUT,
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
|
||||
def create_engine_from_url(database_url: str):
|
||||
"""Create SQLAlchemy engine from database URL."""
|
||||
connect_args = {}
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine_kwargs = {
|
||||
"pool_size": POOL_SIZE,
|
||||
"max_overflow": MAX_OVERFLOW,
|
||||
"pool_recycle": POOL_RECYCLE,
|
||||
"pool_pre_ping": True,
|
||||
"pool_timeout": POOL_TIMEOUT,
|
||||
}
|
||||
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
connect_args=connect_args,
|
||||
**engine_kwargs,
|
||||
)
|
||||
|
||||
return engine
|
||||
|
||||
# Create engine - this should be called by each service with its own DATABASE_URL
|
||||
def get_engine(database_url: str = None):
|
||||
"""Get SQLAlchemy engine for the given database URL."""
|
||||
url = database_url or DATABASE_URL
|
||||
return create_engine_from_url(url)
|
||||
|
||||
# Session factory - create per service
|
||||
def get_session_factory(database_url: str = None):
|
||||
"""Get session factory for the given database URL."""
|
||||
engine = get_engine(database_url)
|
||||
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Dependency for FastAPI - create per service
|
||||
def get_db(database_url: str = None):
|
||||
"""FastAPI dependency to get database session."""
|
||||
SessionLocal = get_session_factory(database_url)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,122 @@
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.shared.utils import verify_token
|
||||
from backend.shared.models import User, DeviceSession
|
||||
from backend.shared.db import get_session_factory
|
||||
import logging
|
||||
|
||||
security = HTTPBearer()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Зависимость для получения сессии БД
|
||||
SessionLocal = get_session_factory()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Зависимость для получения текущего пользователя
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = verify_token(token)
|
||||
except Exception as e:
|
||||
logger.warning("get_current_user: token verification error: %s", str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not payload:
|
||||
logger.info("get_current_user: verify_token returned empty payload")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
||||
if not user:
|
||||
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if user.id == 1 and user.suspended:
|
||||
user.suspended = False
|
||||
user.suspension_reason = None
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Validate device session from JWT
|
||||
session_id = payload.get("session_id")
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid session",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
device_session = (
|
||||
db.query(DeviceSession)
|
||||
.filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not device_session or device_session.revoked:
|
||||
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session revoked or not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if session has been inactive for too long (sliding expiration)
|
||||
from backend.shared.constants import TOKEN_INACTIVITY_EXPIRE_HOURS
|
||||
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
|
||||
if device_session.last_seen < inactivity_threshold:
|
||||
# Session expired due to inactivity - revoke it
|
||||
device_session.revoked = True
|
||||
db.commit()
|
||||
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session expired due to inactivity",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Touch last_seen on valid session (sliding expiration - extends token life)
|
||||
device_session.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
# Check if user is suspended
|
||||
if user.suspended:
|
||||
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account suspended",
|
||||
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
|
||||
)
|
||||
|
||||
# Check if user is deleted
|
||||
if user.deleted:
|
||||
logger.info("get_current_user: account deleted for user_id=%s", user.id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account deleted",
|
||||
)
|
||||
|
||||
request.state.current_user = user
|
||||
request.state.session_id = session_id
|
||||
|
||||
return user
|
||||
@@ -0,0 +1,404 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float, JSON, BigInteger, UniqueConstraint
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pydantic import BaseModel
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(100), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
salt = Column(String(64), nullable=False)
|
||||
display_name = Column(String(100), nullable=True)
|
||||
bio = Column(Text, nullable=True)
|
||||
avatar_url = Column(String(255), nullable=True)
|
||||
is_online = Column(Boolean, default=False)
|
||||
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
verified = Column(Boolean, default=False)
|
||||
verification_token = Column(String(255), nullable=True)
|
||||
reset_token = Column(String(255), nullable=True)
|
||||
reset_token_expires = Column(DateTime, nullable=True)
|
||||
two_factor_enabled = Column(Boolean, default=False)
|
||||
two_factor_secret = Column(String(255), nullable=True)
|
||||
login_attempts = Column(Integer, default=0)
|
||||
locked_until = Column(DateTime, nullable=True)
|
||||
public_key = Column(Text, nullable=True)
|
||||
private_key = Column(Text, nullable=True)
|
||||
encryption_enabled = Column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
messages = relationship("Message", back_populates="sender", cascade="all, delete-orphan")
|
||||
message_recipients = relationship("MessageRecipient", back_populates="recipient", cascade="all, delete-orphan")
|
||||
devices = relationship("Device", back_populates="user", cascade="all, delete-orphan")
|
||||
push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
sender_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
|
||||
content = Column(Text, nullable=False)
|
||||
content_type = Column(String(50), default="text")
|
||||
encrypted_content = Column(Text, nullable=True)
|
||||
signature = Column(Text, nullable=True)
|
||||
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
edited_at = Column(DateTime, nullable=True)
|
||||
edited = Column(Boolean, default=False)
|
||||
deleted = Column(Boolean, default=False)
|
||||
reply_to_id = Column(BigInteger, ForeignKey("messages.id"), nullable=True)
|
||||
thread_id = Column(BigInteger, ForeignKey("messages.id"), nullable=True)
|
||||
is_public = Column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
sender = relationship("User", back_populates="messages")
|
||||
recipients = relationship("MessageRecipient", back_populates="message", cascade="all, delete-orphan")
|
||||
reply_to = relationship("Message", remote_side=[id], foreign_keys=[reply_to_id])
|
||||
thread = relationship("Message", remote_side=[id], foreign_keys=[thread_id])
|
||||
reactions = relationship("MessageReaction", back_populates="message", cascade="all, delete-orphan")
|
||||
|
||||
class MessageRecipient(Base):
|
||||
__tablename__ = "message_recipients"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
message_id = Column(BigInteger, ForeignKey("messages.id"), nullable=False, index=True)
|
||||
recipient_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
|
||||
read_at = Column(DateTime, nullable=True)
|
||||
delivered_at = Column(DateTime, nullable=True)
|
||||
encrypted_key = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
message = relationship("Message", back_populates="recipients")
|
||||
recipient = relationship("User", back_populates="message_recipients")
|
||||
|
||||
class MessageReaction(Base):
|
||||
__tablename__ = "message_reactions"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
message_id = Column(BigInteger, ForeignKey("messages.id"), nullable=False, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
|
||||
reaction = Column(String(50), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
message = relationship("Message", back_populates="reactions")
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "devices"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
|
||||
device_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
device_name = Column(String(255), nullable=True)
|
||||
device_type = Column(String(50), nullable=True)
|
||||
public_key = Column(Text, nullable=True)
|
||||
signed_prekey = Column(Text, nullable=True)
|
||||
one_time_prekeys = Column(JSON, nullable=True)
|
||||
last_active = Column(DateTime, default=datetime.utcnow)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="devices")
|
||||
push_subscriptions = relationship("PushSubscription", back_populates="device", cascade="all, delete-orphan")
|
||||
|
||||
class PushSubscription(Base):
|
||||
__tablename__ = "push_subscriptions"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
|
||||
device_id = Column(BigInteger, ForeignKey("devices.id"), nullable=True, index=True)
|
||||
endpoint = Column(String(500), nullable=False)
|
||||
p256dh = Column(String(255), nullable=False)
|
||||
auth = Column(String(255), nullable=False)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="push_subscriptions")
|
||||
device = relationship("Device", back_populates="push_subscriptions")
|
||||
|
||||
class WebRTCSession(Base):
|
||||
__tablename__ = "webrtc_sessions"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
session_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
initiator_id = Column(BigInteger, ForeignKey("users.id"), nullable=False)
|
||||
participant_ids = Column(JSON, nullable=False)
|
||||
offer = Column(JSON, nullable=True)
|
||||
answer = Column(JSON, nullable=True)
|
||||
ice_candidates = Column(JSON, nullable=True)
|
||||
status = Column(String(50), default="pending")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
class ModerationAction(Base):
|
||||
__tablename__ = "moderation_actions"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
moderator_id = Column(BigInteger, ForeignKey("users.id"), nullable=False)
|
||||
target_user_id = Column(BigInteger, ForeignKey("users.id"), nullable=True)
|
||||
target_message_id = Column(BigInteger, ForeignKey("messages.id"), nullable=True)
|
||||
action_type = Column(String(50), nullable=False)
|
||||
reason = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class MessageFile(Base):
|
||||
__tablename__ = "message_file"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
path = Column(Text, nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("Message", back_populates="files")
|
||||
|
||||
|
||||
class CryptoPublicKey(Base):
|
||||
__tablename__ = "crypto_public_key"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
public_key_b64 = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class CryptoBackup(Base):
|
||||
__tablename__ = "crypto_backup"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
blob_json = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class DMEnvelope(Base):
|
||||
__tablename__ = "dm_envelope"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
iv_b64 = Column(Text, nullable=False)
|
||||
ciphertext_b64 = Column(Text, nullable=False)
|
||||
salt_b64 = Column(Text, nullable=False)
|
||||
iv2_b64 = Column(Text, nullable=False)
|
||||
wrapped_mk_b64 = Column(Text, nullable=False)
|
||||
reply_to_id = Column(Integer, nullable=True)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
|
||||
class DMFile(Base):
|
||||
__tablename__ = "dm_file"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
path = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("DMEnvelope", back_populates="files")
|
||||
|
||||
|
||||
class FcmToken(Base):
|
||||
__tablename__ = "fcm_token"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
token = Column(Text, nullable=False, unique=True)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
|
||||
# Ensure unique combination of message, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
|
||||
|
||||
|
||||
class DMReaction(Base):
|
||||
__tablename__ = "dm_reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
|
||||
|
||||
# Ensure unique combination of dm_envelope, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
||||
|
||||
|
||||
# Tracks authenticated device sessions per user
|
||||
class DeviceSession(Base):
|
||||
__tablename__ = "device_session"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
|
||||
# Raw User-Agent for reference/debugging
|
||||
raw_user_agent = Column(Text, nullable=True)
|
||||
|
||||
# Parsed fields
|
||||
device_name = Column(String(128), nullable=True)
|
||||
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
|
||||
os_name = Column(String(64), nullable=True)
|
||||
os_version = Column(String(64), nullable=True)
|
||||
browser_name = Column(String(64), nullable=True)
|
||||
browser_version = Column(String(64), nullable=True)
|
||||
brand = Column(String(64), nullable=True)
|
||||
model = Column(String(64), nullable=True)
|
||||
|
||||
# Session identity embedded into JWTs
|
||||
session_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
|
||||
# Lifecycle
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
last_seen = Column(DateTime, default=datetime.now)
|
||||
revoked = Column(Boolean, default=False)
|
||||
|
||||
# Relationship back to user (optional lazy to avoid heavy loads)
|
||||
user = relationship("User", lazy="select")
|
||||
|
||||
|
||||
# Pydantic models
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
display_name: str
|
||||
password: str
|
||||
confirm_password: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
currentPasswordDerived: str
|
||||
newPasswordDerived: str
|
||||
logoutAllExceptCurrent: bool = False
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
content: str
|
||||
reply_to_id: int | None = None
|
||||
|
||||
|
||||
class EditMessageRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class DeleteMessageRequest(BaseModel):
|
||||
message_id: int
|
||||
|
||||
|
||||
class UpdateBioRequest(BaseModel):
|
||||
bio: str
|
||||
|
||||
|
||||
class PushSubscriptionRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: dict
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
display_name: str
|
||||
profile_picture: str | None
|
||||
bio: str | None
|
||||
online: bool
|
||||
last_seen: datetime | None
|
||||
created_at: datetime | None
|
||||
verified: bool
|
||||
suspended: bool
|
||||
suspension_reason: str | None
|
||||
deleted: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
timestamp: datetime
|
||||
is_author: bool
|
||||
is_read: bool
|
||||
username: str
|
||||
profile_picture: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReactionRequest(BaseModel):
|
||||
message_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class ReactionResponse(BaseModel):
|
||||
id: int
|
||||
message_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DMReactionRequest(BaseModel):
|
||||
dm_envelope_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class DMReactionResponse(BaseModel):
|
||||
id: int
|
||||
dm_envelope_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UpdateLog(Base):
|
||||
"""Stores update sequence numbers and updates for gap detection"""
|
||||
__tablename__ = "update_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
sequence = Column(Integer, nullable=False, index=True)
|
||||
updates = Column(Text, nullable=False) # JSON array of updates
|
||||
timestamp = Column(DateTime, default=datetime.now, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "sequence", name="uq_user_sequence"),
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
import secrets
|
||||
import string
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import re
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
import nacl.secret
|
||||
import nacl.utils
|
||||
from fastapi import Request
|
||||
import jwt
|
||||
import bcrypt
|
||||
from backend.shared.constants import JWT_SECRET_KEY, JWT_ALGORITHM, MAX_TOKEN_LIFETIME_HOURS
|
||||
import ipaddress
|
||||
|
||||
def generate_secure_token(length: int = 32) -> str:
|
||||
"""Generate a cryptographically secure random token."""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
def hash_password(password: str, salt: Optional[bytes] = None) -> tuple[str, bytes]:
|
||||
"""Hash a password with PBKDF2 and return (hash, salt)."""
|
||||
if salt is None:
|
||||
salt = secrets.token_bytes(32)
|
||||
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
key = kdf.derive(password.encode())
|
||||
return base64.b64encode(key).decode(), salt
|
||||
|
||||
def verify_password(password: str, hashed: str, salt: bytes) -> bool:
|
||||
"""Verify a password against its hash and salt."""
|
||||
try:
|
||||
key = base64.b64decode(hashed)
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
backend=default_backend()
|
||||
)
|
||||
kdf.verify(password.encode(), key)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
"""Generate a verification token for email verification."""
|
||||
return generate_secure_token(64)
|
||||
|
||||
def generate_reset_token() -> str:
|
||||
"""Generate a password reset token."""
|
||||
return generate_secure_token(64)
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract the real client IP from the request."""
|
||||
# Check X-Forwarded-For header first
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
# Take the first IP in case of multiple proxies
|
||||
client_ip = forwarded_for.split(",")[0].strip()
|
||||
try:
|
||||
# Validate IP address
|
||||
ipaddress.ip_address(client_ip)
|
||||
return client_ip
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Check X-Real-IP header
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
try:
|
||||
ipaddress.ip_address(real_ip)
|
||||
return real_ip
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Fallback to request.client.host
|
||||
client_host = request.client.host if request.client else "unknown"
|
||||
try:
|
||||
ipaddress.ip_address(client_host)
|
||||
return client_host
|
||||
except ValueError:
|
||||
return "unknown"
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
"""Validate email address format."""
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
|
||||
def validate_username(username: str) -> bool:
|
||||
"""Validate username format."""
|
||||
if not username or len(username) < 3 or len(username) > 50:
|
||||
return False
|
||||
|
||||
# Allow alphanumeric, underscore, and hyphen
|
||||
pattern = r'^[a-zA-Z0-9_-]+$'
|
||||
return re.match(pattern, username) is not None
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize filename to prevent directory traversal."""
|
||||
return re.sub(r'[^\w\.-]', '_', filename)
|
||||
|
||||
def generate_file_hash(content: bytes) -> str:
|
||||
"""Generate SHA256 hash of file content."""
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
def encrypt_data(data: str, key: bytes) -> str:
|
||||
"""Encrypt data using NaCl secret box."""
|
||||
box = nacl.secret.SecretBox(key)
|
||||
encrypted = box.encrypt(data.encode())
|
||||
return base64.b64encode(encrypted).decode()
|
||||
|
||||
def decrypt_data(encrypted_data: str, key: bytes) -> str:
|
||||
"""Decrypt data using NaCl secret box."""
|
||||
box = nacl.secret.SecretBox(key)
|
||||
encrypted = base64.b64decode(encrypted_data)
|
||||
decrypted = box.decrypt(encrypted)
|
||||
return decrypted.decode()
|
||||
|
||||
def generate_encryption_key() -> bytes:
|
||||
"""Generate a new encryption key."""
|
||||
return nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
|
||||
|
||||
def format_datetime(dt: datetime) -> str:
|
||||
"""Format datetime for API responses."""
|
||||
return dt.isoformat()
|
||||
|
||||
def parse_datetime(dt_str: str) -> Optional[datetime]:
|
||||
"""Parse datetime from API requests."""
|
||||
try:
|
||||
return datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||
except:
|
||||
return None
|
||||
|
||||
def calculate_age(birth_date: datetime) -> int:
|
||||
"""Calculate age from birth date."""
|
||||
today = datetime.now()
|
||||
age = today.year - birth_date.year
|
||||
if today.month < birth_date.month or (today.month == birth_date.month and today.day < birth_date.day):
|
||||
age -= 1
|
||||
return age
|
||||
|
||||
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
|
||||
"""Truncate text to max length with suffix."""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
def is_valid_url(url: str) -> bool:
|
||||
"""Validate URL format."""
|
||||
pattern = r'^https?://[^\s/$.?#].[^\s]*$'
|
||||
return re.match(pattern, url) is not None
|
||||
|
||||
def generate_device_id() -> str:
|
||||
"""Generate a unique device identifier."""
|
||||
return generate_secure_token(32)
|
||||
|
||||
def normalize_phone_number(phone: str) -> str:
|
||||
"""Normalize phone number format."""
|
||||
# Remove all non-digit characters except +
|
||||
normalized = re.sub(r'[^\d+]', '', phone)
|
||||
|
||||
# Ensure it starts with +
|
||||
if not normalized.startswith('+'):
|
||||
if normalized.startswith('00'):
|
||||
normalized = '+' + normalized[2:]
|
||||
else:
|
||||
normalized = '+' + normalized
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def create_token(user_id: int, username: str, session_id: str) -> str:
|
||||
# Set a long expiration as safety net (actual expiration based on inactivity)
|
||||
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int)
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_admin(user) -> bool:
|
||||
return user.id == 1
|
||||
|
||||
|
||||
def convert_user(user) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_seen": user.last_seen.isoformat(),
|
||||
"online": user.online,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"profile_picture": user.profile_picture,
|
||||
"bio": user.bio,
|
||||
"admin": _is_admin(user),
|
||||
"verified": user.verified,
|
||||
"suspended": user.suspended or False,
|
||||
"suspension_reason": user.suspension_reason,
|
||||
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
import re
|
||||
|
||||
|
||||
def is_valid_username(username: str) -> bool:
|
||||
if len(username) < 3 or len(username) > 20:
|
||||
return False
|
||||
# Only allow English letters, numbers, dashes and underscores
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_display_name(display_name: str) -> bool:
|
||||
if len(display_name) < 1 or len(display_name) > 64:
|
||||
return False
|
||||
# Check if not blank (only whitespace)
|
||||
if not display_name.strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_password(password: str) -> bool:
|
||||
if len(password) < 5 or len(password) > 50:
|
||||
return False
|
||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
|
||||
return False
|
||||
return True
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
display_name: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
@validator('username')
|
||||
def username_alphanumeric(cls, v):
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
|
||||
raise ValueError('Username must be alphanumeric with underscores or hyphens')
|
||||
return v
|
||||
|
||||
@validator('password')
|
||||
def password_strength(cls, v):
|
||||
if not re.search(r'[A-Z]', v):
|
||||
raise ValueError('Password must contain at least one uppercase letter')
|
||||
if not re.search(r'[a-z]', v):
|
||||
raise ValueError('Password must contain at least one lowercase letter')
|
||||
if not re.search(r'\d', v):
|
||||
raise ValueError('Password must contain at least one digit')
|
||||
return v
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username_or_email: str = Field(min_length=1, max_length=100)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
display_name: Optional[str] = Field(None, max_length=100)
|
||||
bio: Optional[str] = Field(None, max_length=500)
|
||||
avatar_url: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
@validator('avatar_url')
|
||||
def validate_avatar_url(cls, v):
|
||||
if v and not v.startswith(('http://', 'https://')):
|
||||
raise ValueError('Avatar URL must be a valid HTTP/HTTPS URL')
|
||||
return v
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=10000)
|
||||
content_type: str = Field(default="text", pattern=r'^(text|image|video|audio|file)$')
|
||||
reply_to_id: Optional[int] = None
|
||||
recipient_ids: list[int] = Field(min_items=1, max_items=100)
|
||||
|
||||
class MessageUpdate(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=10000)
|
||||
|
||||
class DeviceRegister(BaseModel):
|
||||
device_id: str = Field(min_length=1, max_length=255)
|
||||
device_name: Optional[str] = Field(None, max_length=255)
|
||||
device_type: Optional[str] = Field(None, max_length=50)
|
||||
public_key: Optional[str] = Field(None, max_length=10000)
|
||||
|
||||
class PushSubscriptionCreate(BaseModel):
|
||||
endpoint: str = Field(max_length=500)
|
||||
p256dh: str = Field(max_length=255)
|
||||
auth: str = Field(max_length=255)
|
||||
device_id: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
class WebRTCOffer(BaseModel):
|
||||
offer: dict
|
||||
participant_ids: list[int] = Field(min_items=1, max_items=10)
|
||||
|
||||
class WebRTCAnswer(BaseModel):
|
||||
answer: dict
|
||||
session_id: str = Field(max_length=255)
|
||||
|
||||
class WebRTCIceCandidate(BaseModel):
|
||||
candidate: dict
|
||||
session_id: str = Field(max_length=255)
|
||||
|
||||
class ModerationActionCreate(BaseModel):
|
||||
target_user_id: Optional[int] = None
|
||||
target_message_id: Optional[int] = None
|
||||
action_type: str = Field(pattern=r'^(ban|mute|delete|warn)$')
|
||||
reason: Optional[str] = Field(None, max_length=1000)
|
||||
duration_hours: Optional[int] = Field(None, gt=0, le=8760) # Max 1 year
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
token: str = Field(min_length=64, max_length=64)
|
||||
new_password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
@validator('new_password')
|
||||
def password_strength(cls, v):
|
||||
if not re.search(r'[A-Z]', v):
|
||||
raise ValueError('Password must contain at least one uppercase letter')
|
||||
if not re.search(r'[a-z]', v):
|
||||
raise ValueError('Password must contain at least one lowercase letter')
|
||||
if not re.search(r'\d', v):
|
||||
raise ValueError('Password must contain at least one digit')
|
||||
return v
|
||||
|
||||
class TwoFactorSetup(BaseModel):
|
||||
code: str = Field(pattern=r'^\d{6}$')
|
||||
|
||||
class TwoFactorVerify(BaseModel):
|
||||
code: str = Field(pattern=r'^\d{6}$')
|
||||
|
||||
class EmailVerification(BaseModel):
|
||||
token: str = Field(min_length=64, max_length=64)
|
||||
Reference in New Issue
Block a user