This commit introduces major enhancements to Talk2Me: ## Database Integration - PostgreSQL support with SQLAlchemy ORM - Redis integration for caching and real-time analytics - Automated database initialization scripts - Migration support infrastructure ## User Authentication System - JWT-based API authentication - Session-based web authentication - API key authentication for programmatic access - User roles and permissions (admin/user) - Login history and session tracking - Rate limiting per user with customizable limits ## Admin Dashboard - Real-time analytics and monitoring - User management interface (create, edit, delete users) - System health monitoring - Request/error tracking - Language pair usage statistics - Performance metrics visualization ## Key Features - Dual authentication support (token + user accounts) - Graceful fallback for missing services - Non-blocking analytics middleware - Comprehensive error handling - Session management with security features ## Bug Fixes - Fixed rate limiting bypass for admin routes - Added missing email validation method - Improved error handling for missing database tables - Fixed session-based authentication for API endpoints 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
389 lines
15 KiB
Python
389 lines
15 KiB
Python
# Redis-based session management system
|
|
import time
|
|
import uuid
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Dict, Any, Optional, List
|
|
from dataclasses import dataclass, asdict
|
|
from flask import session, request, g
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@dataclass
|
|
class SessionInfo:
|
|
"""Session information stored in Redis"""
|
|
session_id: str
|
|
user_id: Optional[str] = None
|
|
ip_address: Optional[str] = None
|
|
user_agent: Optional[str] = None
|
|
created_at: float = None
|
|
last_activity: float = None
|
|
request_count: int = 0
|
|
resource_count: int = 0
|
|
total_bytes_used: int = 0
|
|
metadata: Dict[str, Any] = None
|
|
|
|
def __post_init__(self):
|
|
if self.created_at is None:
|
|
self.created_at = time.time()
|
|
if self.last_activity is None:
|
|
self.last_activity = time.time()
|
|
if self.metadata is None:
|
|
self.metadata = {}
|
|
|
|
|
|
class RedisSessionManager:
|
|
"""
|
|
Session management using Redis for distributed sessions
|
|
"""
|
|
def __init__(self, redis_manager, config: Dict[str, Any] = None):
|
|
self.redis = redis_manager
|
|
self.config = config or {}
|
|
|
|
# Configuration
|
|
self.max_session_duration = self.config.get('max_session_duration', 3600) # 1 hour
|
|
self.max_idle_time = self.config.get('max_idle_time', 900) # 15 minutes
|
|
self.max_resources_per_session = self.config.get('max_resources_per_session', 100)
|
|
self.max_bytes_per_session = self.config.get('max_bytes_per_session', 100 * 1024 * 1024) # 100MB
|
|
|
|
logger.info("Redis session manager initialized")
|
|
|
|
def create_session(self, session_id: str = None, user_id: str = None,
|
|
ip_address: str = None, user_agent: str = None) -> SessionInfo:
|
|
"""Create a new session"""
|
|
if not session_id:
|
|
session_id = str(uuid.uuid4())
|
|
|
|
# Check if session already exists
|
|
existing = self.get_session(session_id)
|
|
if existing:
|
|
logger.warning(f"Session {session_id} already exists")
|
|
return existing
|
|
|
|
session_info = SessionInfo(
|
|
session_id=session_id,
|
|
user_id=user_id,
|
|
ip_address=ip_address,
|
|
user_agent=user_agent
|
|
)
|
|
|
|
# Save to Redis
|
|
self._save_session(session_info)
|
|
|
|
# Add to active sessions set
|
|
self.redis.sadd('active_sessions', session_id)
|
|
|
|
# Update stats
|
|
self.redis.incr('stats:sessions:created')
|
|
|
|
logger.info(f"Created session {session_id}")
|
|
return session_info
|
|
|
|
def get_session(self, session_id: str) -> Optional[SessionInfo]:
|
|
"""Get a session by ID"""
|
|
data = self.redis.get(f'session:{session_id}')
|
|
if not data:
|
|
return None
|
|
|
|
# Update last activity
|
|
session_info = SessionInfo(**data)
|
|
session_info.last_activity = time.time()
|
|
self._save_session(session_info)
|
|
|
|
return session_info
|
|
|
|
def update_session_activity(self, session_id: str):
|
|
"""Update session last activity time"""
|
|
session_info = self.get_session(session_id)
|
|
if session_info:
|
|
session_info.last_activity = time.time()
|
|
session_info.request_count += 1
|
|
self._save_session(session_info)
|
|
|
|
def add_resource(self, session_id: str, resource_type: str,
|
|
resource_id: str = None, path: str = None,
|
|
size_bytes: int = 0, metadata: Dict[str, Any] = None) -> bool:
|
|
"""Add a resource to a session"""
|
|
session_info = self.get_session(session_id)
|
|
if not session_info:
|
|
logger.warning(f"Session {session_id} not found")
|
|
return False
|
|
|
|
# Check limits
|
|
if session_info.resource_count >= self.max_resources_per_session:
|
|
logger.warning(f"Session {session_id} reached resource limit")
|
|
# Clean up oldest resources
|
|
self._cleanup_oldest_resources(session_id, 1)
|
|
|
|
if session_info.total_bytes_used + size_bytes > self.max_bytes_per_session:
|
|
logger.warning(f"Session {session_id} reached size limit")
|
|
bytes_to_free = (session_info.total_bytes_used + size_bytes) - self.max_bytes_per_session
|
|
self._cleanup_resources_by_size(session_id, bytes_to_free)
|
|
|
|
# Generate resource ID if not provided
|
|
if not resource_id:
|
|
resource_id = str(uuid.uuid4())
|
|
|
|
# Store resource info
|
|
resource_key = f'session:{session_id}:resource:{resource_id}'
|
|
resource_data = {
|
|
'resource_id': resource_id,
|
|
'resource_type': resource_type,
|
|
'path': path,
|
|
'size_bytes': size_bytes,
|
|
'created_at': time.time(),
|
|
'metadata': metadata or {}
|
|
}
|
|
|
|
self.redis.set(resource_key, resource_data, expire=self.max_session_duration)
|
|
|
|
# Add to session's resource set
|
|
self.redis.sadd(f'session:{session_id}:resources', resource_id)
|
|
|
|
# Update session info
|
|
session_info.resource_count += 1
|
|
session_info.total_bytes_used += size_bytes
|
|
self._save_session(session_info)
|
|
|
|
# Update global stats
|
|
self.redis.incr('stats:resources:active')
|
|
self.redis.incr('stats:bytes:active', size_bytes)
|
|
|
|
logger.debug(f"Added {resource_type} resource {resource_id} to session {session_id}")
|
|
return True
|
|
|
|
def remove_resource(self, session_id: str, resource_id: str) -> bool:
|
|
"""Remove a resource from a session"""
|
|
# Get resource info
|
|
resource_key = f'session:{session_id}:resource:{resource_id}'
|
|
resource_data = self.redis.get(resource_key)
|
|
if not resource_data:
|
|
return False
|
|
|
|
# Clean up the actual resource (file, etc.)
|
|
self._cleanup_resource(resource_data)
|
|
|
|
# Remove from Redis
|
|
self.redis.delete(resource_key)
|
|
self.redis.srem(f'session:{session_id}:resources', resource_id)
|
|
|
|
# Update session info
|
|
session_info = self.get_session(session_id)
|
|
if session_info:
|
|
session_info.resource_count -= 1
|
|
session_info.total_bytes_used -= resource_data.get('size_bytes', 0)
|
|
self._save_session(session_info)
|
|
|
|
# Update stats
|
|
self.redis.decr('stats:resources:active')
|
|
self.redis.decr('stats:bytes:active', resource_data.get('size_bytes', 0))
|
|
self.redis.incr('stats:resources:cleaned')
|
|
self.redis.incr('stats:bytes:cleaned', resource_data.get('size_bytes', 0))
|
|
|
|
logger.debug(f"Removed resource {resource_id} from session {session_id}")
|
|
return True
|
|
|
|
def cleanup_session(self, session_id: str) -> bool:
|
|
"""Clean up a session and all its resources"""
|
|
session_info = self.get_session(session_id)
|
|
if not session_info:
|
|
return False
|
|
|
|
# Get all resources
|
|
resource_ids = self.redis.smembers(f'session:{session_id}:resources')
|
|
|
|
# Clean up each resource
|
|
for resource_id in resource_ids:
|
|
self.remove_resource(session_id, resource_id)
|
|
|
|
# Remove session data
|
|
self.redis.delete(f'session:{session_id}')
|
|
self.redis.delete(f'session:{session_id}:resources')
|
|
self.redis.srem('active_sessions', session_id)
|
|
|
|
# Update stats
|
|
self.redis.incr('stats:sessions:cleaned')
|
|
|
|
logger.info(f"Cleaned up session {session_id}")
|
|
return True
|
|
|
|
def cleanup_expired_sessions(self):
|
|
"""Clean up sessions that have exceeded max duration"""
|
|
now = time.time()
|
|
active_sessions = self.redis.smembers('active_sessions')
|
|
|
|
for session_id in active_sessions:
|
|
session_info = self.get_session(session_id)
|
|
if session_info and (now - session_info.created_at > self.max_session_duration):
|
|
logger.info(f"Cleaning up expired session {session_id}")
|
|
self.cleanup_session(session_id)
|
|
|
|
def cleanup_idle_sessions(self):
|
|
"""Clean up sessions that have been idle too long"""
|
|
now = time.time()
|
|
active_sessions = self.redis.smembers('active_sessions')
|
|
|
|
for session_id in active_sessions:
|
|
session_info = self.get_session(session_id)
|
|
if session_info and (now - session_info.last_activity > self.max_idle_time):
|
|
logger.info(f"Cleaning up idle session {session_id}")
|
|
self.cleanup_session(session_id)
|
|
|
|
def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get detailed information about a session"""
|
|
session_info = self.get_session(session_id)
|
|
if not session_info:
|
|
return None
|
|
|
|
# Get resources
|
|
resource_ids = self.redis.smembers(f'session:{session_id}:resources')
|
|
resources = []
|
|
|
|
for resource_id in resource_ids:
|
|
resource_data = self.redis.get(f'session:{session_id}:resource:{resource_id}')
|
|
if resource_data:
|
|
resources.append({
|
|
'resource_id': resource_data['resource_id'],
|
|
'resource_type': resource_data['resource_type'],
|
|
'size_bytes': resource_data['size_bytes'],
|
|
'created_at': datetime.fromtimestamp(resource_data['created_at']).isoformat()
|
|
})
|
|
|
|
return {
|
|
'session_id': session_info.session_id,
|
|
'user_id': session_info.user_id,
|
|
'ip_address': session_info.ip_address,
|
|
'created_at': datetime.fromtimestamp(session_info.created_at).isoformat(),
|
|
'last_activity': datetime.fromtimestamp(session_info.last_activity).isoformat(),
|
|
'duration_seconds': int(time.time() - session_info.created_at),
|
|
'idle_seconds': int(time.time() - session_info.last_activity),
|
|
'request_count': session_info.request_count,
|
|
'resource_count': session_info.resource_count,
|
|
'total_bytes_used': session_info.total_bytes_used,
|
|
'resources': resources
|
|
}
|
|
|
|
def get_all_sessions_info(self) -> List[Dict[str, Any]]:
|
|
"""Get information about all active sessions"""
|
|
active_sessions = self.redis.smembers('active_sessions')
|
|
return [
|
|
self.get_session_info(session_id)
|
|
for session_id in active_sessions
|
|
if self.get_session_info(session_id)
|
|
]
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
"""Get session manager statistics"""
|
|
active_sessions = self.redis.scard('active_sessions')
|
|
|
|
return {
|
|
'active_sessions': active_sessions,
|
|
'total_sessions_created': self.redis.get('stats:sessions:created', 0),
|
|
'total_sessions_cleaned': self.redis.get('stats:sessions:cleaned', 0),
|
|
'active_resources': self.redis.get('stats:resources:active', 0),
|
|
'total_resources_cleaned': self.redis.get('stats:resources:cleaned', 0),
|
|
'active_bytes': self.redis.get('stats:bytes:active', 0),
|
|
'total_bytes_cleaned': self.redis.get('stats:bytes:cleaned', 0)
|
|
}
|
|
|
|
def _save_session(self, session_info: SessionInfo):
|
|
"""Save session info to Redis"""
|
|
key = f'session:{session_info.session_id}'
|
|
data = asdict(session_info)
|
|
self.redis.set(key, data, expire=self.max_session_duration)
|
|
|
|
def _cleanup_resource(self, resource_data: Dict[str, Any]):
|
|
"""Clean up a resource (e.g., delete file)"""
|
|
import os
|
|
|
|
if resource_data.get('resource_type') in ['audio_file', 'temp_file']:
|
|
path = resource_data.get('path')
|
|
if path and os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
logger.debug(f"Removed file {path}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to remove file {path}: {e}")
|
|
|
|
def _cleanup_oldest_resources(self, session_id: str, count: int):
|
|
"""Clean up oldest resources from a session"""
|
|
resource_ids = list(self.redis.smembers(f'session:{session_id}:resources'))
|
|
|
|
# Get resource creation times
|
|
resources_with_time = []
|
|
for resource_id in resource_ids:
|
|
resource_data = self.redis.get(f'session:{session_id}:resource:{resource_id}')
|
|
if resource_data:
|
|
resources_with_time.append((resource_id, resource_data.get('created_at', 0)))
|
|
|
|
# Sort by creation time and remove oldest
|
|
resources_with_time.sort(key=lambda x: x[1])
|
|
for resource_id, _ in resources_with_time[:count]:
|
|
self.remove_resource(session_id, resource_id)
|
|
|
|
def _cleanup_resources_by_size(self, session_id: str, bytes_to_free: int):
|
|
"""Clean up resources to free up space"""
|
|
resource_ids = list(self.redis.smembers(f'session:{session_id}:resources'))
|
|
|
|
# Get resource sizes
|
|
resources_with_size = []
|
|
for resource_id in resource_ids:
|
|
resource_data = self.redis.get(f'session:{session_id}:resource:{resource_id}')
|
|
if resource_data:
|
|
resources_with_size.append((resource_id, resource_data.get('size_bytes', 0)))
|
|
|
|
# Sort by size (largest first) and remove until we've freed enough
|
|
resources_with_size.sort(key=lambda x: x[1], reverse=True)
|
|
freed_bytes = 0
|
|
|
|
for resource_id, size in resources_with_size:
|
|
if freed_bytes >= bytes_to_free:
|
|
break
|
|
freed_bytes += size
|
|
self.remove_resource(session_id, resource_id)
|
|
|
|
|
|
def init_app(app):
|
|
"""Initialize Redis session management for Flask app"""
|
|
# Get Redis manager
|
|
redis_manager = getattr(app, 'redis', None)
|
|
if not redis_manager:
|
|
raise RuntimeError("Redis manager not initialized. Call init_redis() first.")
|
|
|
|
config = {
|
|
'max_session_duration': app.config.get('MAX_SESSION_DURATION', 3600),
|
|
'max_idle_time': app.config.get('MAX_SESSION_IDLE_TIME', 900),
|
|
'max_resources_per_session': app.config.get('MAX_RESOURCES_PER_SESSION', 100),
|
|
'max_bytes_per_session': app.config.get('MAX_BYTES_PER_SESSION', 100 * 1024 * 1024)
|
|
}
|
|
|
|
manager = RedisSessionManager(redis_manager, config)
|
|
app.redis_session_manager = manager
|
|
|
|
# Add before_request handler
|
|
@app.before_request
|
|
def before_request_session():
|
|
# Get or create session
|
|
session_id = session.get('session_id')
|
|
if not session_id:
|
|
session_id = str(uuid.uuid4())
|
|
session['session_id'] = session_id
|
|
session.permanent = True
|
|
|
|
# Get session from manager
|
|
user_session = manager.get_session(session_id)
|
|
if not user_session:
|
|
user_session = manager.create_session(
|
|
session_id=session_id,
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get('User-Agent')
|
|
)
|
|
|
|
# Update activity
|
|
manager.update_session_activity(session_id)
|
|
|
|
# Store in g for request access
|
|
g.user_session = user_session
|
|
g.session_manager = manager
|
|
|
|
logger.info("Redis session management initialized") |