Add comprehensive database integration, authentication, and admin dashboard

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>
This commit is contained in:
2025-06-03 18:21:56 -06:00
parent d818ec7d73
commit fa951c3141
41 changed files with 10120 additions and 325 deletions

284
app.py
View File

@@ -5,7 +5,7 @@ import requests
import json
import logging
from dotenv import load_dotenv
from flask import Flask, render_template, request, jsonify, Response, send_file, send_from_directory, stream_with_context, g
from flask import Flask, render_template, request, jsonify, Response, send_file, send_from_directory, stream_with_context, g, session, redirect, url_for
from flask_cors import CORS, cross_origin
import whisper
import torch
@@ -42,6 +42,13 @@ from session_manager import init_app as init_session_manager, track_resource
from request_size_limiter import RequestSizeLimiter, limit_request_size
from error_logger import ErrorLogger, log_errors, log_performance, log_exception, get_logger
from memory_manager import MemoryManager, AudioProcessingContext, with_memory_management
from analytics_middleware import analytics_tracker, track_translation, track_transcription, track_tts
# Admin module will be loaded dynamically based on service availability
from admin_loader import load_admin_module
from auth import init_auth, require_auth, get_current_user, update_user_usage_stats
from auth_routes import auth_bp
from auth_models import User
from user_rate_limiter import user_aware_rate_limit, get_user_rate_limit_status
# Error boundary decorator for Flask routes
def with_error_boundary(func):
@@ -166,6 +173,97 @@ error_logger = ErrorLogger(app, {
# Update logger to use the new system
logger = get_logger(__name__)
# Initialize analytics tracking
analytics_tracker.init_app(app)
# Initialize database
from database import db, init_db
init_db(app)
# Initialize authentication system
init_auth(app)
# Initialize admin dashboard dynamically
admin_bp, init_admin = load_admin_module()
init_admin(app)
app.register_blueprint(admin_bp, url_prefix='/admin')
# Register authentication routes
app.register_blueprint(auth_bp, url_prefix='/api/auth')
# Test route for session auth
@app.route('/api/test-auth')
def test_auth():
"""Test authentication methods"""
from flask import session as flask_session
user = get_current_user()
# Also check admin count
admin_count = User.query.filter_by(role='admin').count() if User else 0
return jsonify({
'session_data': {
'logged_in': flask_session.get('logged_in'),
'user_id': flask_session.get('user_id'),
'username': flask_session.get('username'),
'user_role': flask_session.get('user_role'),
'admin_token': bool(flask_session.get('admin_token'))
},
'current_user': {
'found': user is not None,
'username': user.username if user else None,
'role': user.role if user else None,
'is_admin': user.is_admin if user else None
} if user else None,
'admin_users_in_db': admin_count
})
# Initialize admin user if none exists
@app.route('/api/init-admin-user', methods=['POST'])
def init_admin_user():
"""Create initial admin user if none exists"""
try:
# Check if any admin users exist
admin_exists = User.query.filter_by(role='admin').first()
if admin_exists:
return jsonify({
'success': False,
'error': 'Admin user already exists'
}), 400
# Create default admin user
from auth import create_user
user, error = create_user(
email='admin@talk2me.local',
username='admin',
password='admin123', # Change this in production!
full_name='Administrator',
role='admin',
is_verified=True
)
if error:
return jsonify({
'success': False,
'error': error
}), 400
return jsonify({
'success': True,
'message': 'Admin user created successfully',
'credentials': {
'username': 'admin',
'password': 'admin123',
'note': 'Please change the password immediately!'
}
})
except Exception as e:
logger.error(f"Failed to create admin user: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to create admin user'
}), 500
# Initialize memory management
memory_manager = MemoryManager(app, {
'memory_threshold_mb': app.config.get('MEMORY_THRESHOLD_MB', 4096),
@@ -673,14 +771,130 @@ LANGUAGE_TO_VOICE = {
def index():
return render_template('index.html', languages=sorted(SUPPORTED_LANGUAGES.values()))
@app.route('/login', methods=['GET', 'POST'])
def login():
"""User login page"""
if request.method == 'POST':
# Handle form-based login (for users without JavaScript)
username = request.form.get('username')
password = request.form.get('password')
# Special case: Check if it's the admin token being used as password
admin_token = app.config.get('ADMIN_TOKEN', os.environ.get('ADMIN_TOKEN', 'default-admin-token'))
if username == 'admin' and password == admin_token:
# Direct admin login with token
session['user_id'] = 'admin-token-user'
session['username'] = 'admin'
session['user_role'] = 'admin'
session['logged_in'] = True
session['admin_token'] = admin_token
next_url = request.args.get('next', url_for('admin.dashboard'))
return redirect(next_url)
if username and password:
# Try regular database authentication
try:
# Import here to avoid circular imports
from auth import authenticate_user
user, error = authenticate_user(username, password)
if not error and user:
# Store user info in session
session['user_id'] = str(user.id)
session['username'] = user.username
session['user_role'] = user.role
session['logged_in'] = True
# Redirect based on role
next_url = request.args.get('next')
if next_url:
return redirect(next_url)
elif user.role == 'admin':
return redirect(url_for('admin.dashboard'))
else:
return redirect(url_for('index'))
else:
return render_template('login.html', error=error or 'Login failed')
except Exception as e:
logger.error(f"Database login error: {e}")
# If database login fails, still show error
return render_template('login.html', error='Login failed - database error')
return render_template('login.html')
@app.route('/logout')
def logout():
"""Logout user"""
session.clear()
return redirect(url_for('index'))
@app.route('/admin-token-login', methods=['GET', 'POST'])
def admin_token_login():
"""Simple admin login with token only"""
if request.method == 'POST':
token = request.form.get('token', request.form.get('password', ''))
admin_token = app.config.get('ADMIN_TOKEN', os.environ.get('ADMIN_TOKEN', 'default-admin-token'))
if token == admin_token:
# Set admin session
session['user_id'] = 'admin-token-user'
session['username'] = 'admin'
session['user_role'] = 'admin'
session['logged_in'] = True
session['admin_token'] = admin_token
next_url = request.args.get('next', url_for('admin.dashboard'))
return redirect(next_url)
else:
error = 'Invalid admin token'
else:
error = None
# Simple form template
return f'''
<!DOCTYPE html>
<html>
<head>
<title>Admin Token Login</title>
<style>
body {{ font-family: Arial; padding: 50px; background: #f0f0f0; }}
.container {{ max-width: 400px; margin: auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
input {{ width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 5px; }}
button {{ width: 100%; padding: 10px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; }}
button:hover {{ background: #5a67d8; }}
.error {{ color: red; margin: 10px 0; }}
.info {{ color: #666; font-size: 14px; margin-top: 20px; }}
</style>
</head>
<body>
<div class="container">
<h2>Admin Token Login</h2>
{f'<div class="error">{error}</div>' if error else ''}
<form method="POST">
<input type="password" name="token" placeholder="Enter admin token" required autofocus>
<button type="submit">Login as Admin</button>
</form>
<div class="info">
<p>Use the ADMIN_TOKEN from your .env file</p>
<p>Current token: {admin_token if app.debug else '[hidden in production]'}</p>
</div>
</div>
</body>
</html>
'''
@app.route('/transcribe', methods=['POST'])
@rate_limit(requests_per_minute=10, requests_per_hour=100, check_size=True)
@user_aware_rate_limit(requests_per_minute=10, requests_per_hour=100, check_size=True)
@limit_request_size(max_audio_size=25 * 1024 * 1024) # 25MB limit for audio
@with_error_boundary
@track_resource('audio_file')
@log_performance('transcribe_audio')
@with_memory_management
def transcribe():
# Get current user if authenticated
user = get_current_user()
# Use memory management context
with AudioProcessingContext(app.memory_manager, name='transcribe') as ctx:
if 'audio' not in request.files:
@@ -767,6 +981,24 @@ def transcribe():
# Log detected language
logger.info(f"Auto-detected language: {detected_language} ({detected_code})")
# Update user usage stats if authenticated
if user:
update_user_usage_stats(user, 'transcription')
# Track transcription analytics
analytics_tracker._track_operation_complete(
'transcriptions',
int((time.time() - g.start_time) * 1000),
True,
None,
{
'detected_language': detected_language or source_lang,
'audio_duration': len(transcribed_text.split()) / 3, # Rough estimate
'file_size': os.path.getsize(temp_path)
},
{'detected_language': detected_language, 'text': transcribed_text}
)
# Send notification if push is enabled
if len(push_subscriptions) > 0:
send_push_notification(
@@ -804,12 +1036,15 @@ def transcribe():
gc.collect()
@app.route('/translate', methods=['POST'])
@rate_limit(requests_per_minute=20, requests_per_hour=300, check_size=True)
@user_aware_rate_limit(requests_per_minute=20, requests_per_hour=300, check_size=True)
@limit_request_size(max_size=1 * 1024 * 1024) # 1MB limit for JSON
@with_error_boundary
@log_performance('translate_text')
def translate():
try:
# Get current user if authenticated
user = get_current_user()
# Validate request size
if not Validators.validate_json_size(request.json, max_size_kb=100):
return jsonify({'error': 'Request too large'}), 413
@@ -856,6 +1091,24 @@ def translate():
translated_text = response['message']['content'].strip()
# Update user usage stats if authenticated
if user:
update_user_usage_stats(user, 'translation')
# Track translation analytics
analytics_tracker._track_operation_complete(
'translations',
int((time.time() - g.start_time) * 1000),
True,
None,
{
'source_lang': source_lang,
'target_lang': target_lang,
'text_length': len(text)
},
{'translation': translated_text}
)
# Send notification if push is enabled
if len(push_subscriptions) > 0:
send_push_notification(
@@ -874,7 +1127,7 @@ def translate():
return jsonify({'error': f'Translation failed: {str(e)}'}), 500
@app.route('/translate/stream', methods=['POST'])
@rate_limit(requests_per_minute=10, requests_per_hour=150, check_size=True)
@user_aware_rate_limit(requests_per_minute=10, requests_per_hour=150, check_size=True)
@limit_request_size(max_size=1 * 1024 * 1024) # 1MB limit for JSON
@with_error_boundary
def translate_stream():
@@ -972,12 +1225,15 @@ def translate_stream():
return jsonify({'error': f'Translation failed: {str(e)}'}), 500
@app.route('/speak', methods=['POST'])
@rate_limit(requests_per_minute=15, requests_per_hour=200, check_size=True)
@user_aware_rate_limit(requests_per_minute=15, requests_per_hour=200, check_size=True)
@limit_request_size(max_size=1 * 1024 * 1024) # 1MB limit for JSON
@with_error_boundary
@track_resource('audio_file')
def speak():
try:
# Get current user if authenticated
user = get_current_user()
# Validate request size
if not Validators.validate_json_size(request.json, max_size_kb=100):
return jsonify({'error': 'Request too large'}), 413
@@ -1066,6 +1322,24 @@ def speak():
# Register for cleanup
register_temp_file(temp_audio_path)
# Update user usage stats if authenticated
if user:
update_user_usage_stats(user, 'tts')
# Track TTS analytics
analytics_tracker._track_operation_complete(
'tts',
int((time.time() - g.start_time) * 1000),
True,
None,
{
'language': language,
'text_length': len(text),
'voice': voice
},
{'audio_file': temp_audio_filename}
)
# Add to session resources
if hasattr(g, 'session_manager') and hasattr(g, 'user_session'):
file_size = os.path.getsize(temp_audio_path)