Implement real, working microservices architecture

This commit is contained in:
2026-01-06 17:28:25 +03:00
Unverified
parent c7c9d9606d
commit 75ef3fc604
52 changed files with 2088 additions and 165 deletions
+1
View File
@@ -0,0 +1 @@
# Shared modules package
+50
View File
@@ -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"
+62
View File
@@ -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()
+122
View File
@@ -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
+404
View File
@@ -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"),
)
+234
View File
@@ -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
}
+132
View File
@@ -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)