Compare commits
8 Commits
01-2025-06
...
02-2025-06
| Author | SHA1 | Date | |
|---|---|---|---|
| e5a274d191 | |||
| c97d025acb | |||
| d8d330fd9d | |||
| fa951c3141 | |||
| d818ec7d73 | |||
| b5f2b53262 | |||
| bcbac5c8b3 | |||
| e5333d8410 |
79
.env.example
79
.env.example
@@ -1,22 +1,73 @@
|
||||
# Example environment configuration for Talk2Me
|
||||
# Copy this file to .env and update with your actual values
|
||||
# Talk2Me Environment Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Flask Configuration
|
||||
SECRET_KEY=your-secret-key-here-change-this
|
||||
FLASK_ENV=development
|
||||
FLASK_SECRET_KEY=your-secret-key-here-change-in-production
|
||||
FLASK_DEBUG=False
|
||||
|
||||
# Upload Configuration
|
||||
UPLOAD_FOLDER=/path/to/secure/upload/folder
|
||||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=5005
|
||||
|
||||
# TTS Server Configuration
|
||||
TTS_SERVER_URL=http://localhost:5050/v1/audio/speech
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/talk2me
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Ollama Configuration
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=gemma2:9b
|
||||
OLLAMA_LARGE_MODEL=gemma3:27b
|
||||
|
||||
# TTS Configuration
|
||||
TTS_SERVER_URL=http://localhost:8000
|
||||
TTS_API_KEY=your-tts-api-key-here
|
||||
|
||||
# CORS Configuration (for production)
|
||||
CORS_ORIGINS=https://yourdomain.com,https://app.yourdomain.com
|
||||
ADMIN_CORS_ORIGINS=https://admin.yourdomain.com
|
||||
# Security Configuration
|
||||
JWT_SECRET_KEY=your-jwt-secret-key-here
|
||||
JWT_ACCESS_TOKEN_EXPIRES=3600
|
||||
JWT_REFRESH_TOKEN_EXPIRES=2592000
|
||||
|
||||
# Admin Token (for admin endpoints)
|
||||
ADMIN_TOKEN=your-secure-admin-token-here
|
||||
# Admin Configuration
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-this-password
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# Optional: GPU Configuration
|
||||
# CUDA_VISIBLE_DEVICES=0
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PER_MINUTE=60
|
||||
RATE_LIMIT_PER_HOUR=1000
|
||||
|
||||
# Session Configuration
|
||||
SESSION_LIFETIME=86400
|
||||
SESSION_CLEANUP_INTERVAL=3600
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FORMAT=json
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5005
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_ANALYTICS=true
|
||||
ENABLE_RATE_LIMITING=true
|
||||
ENABLE_SESSION_MANAGEMENT=true
|
||||
ENABLE_ERROR_TRACKING=true
|
||||
|
||||
# Performance Settings
|
||||
MAX_CONTENT_LENGTH=16777216
|
||||
REQUEST_TIMEOUT=300
|
||||
WHISPER_MODEL=base
|
||||
WHISPER_DEVICE=auto
|
||||
|
||||
# Email Configuration (Optional)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASSWORD=your-email-password
|
||||
SMTP_FROM=noreply@example.com
|
||||
|
||||
# External Services (Optional)
|
||||
SENTRY_DSN=
|
||||
DATADOG_API_KEY=
|
||||
NEWRELIC_LICENSE_KEY=
|
||||
54
.env.template
Normal file
54
.env.template
Normal file
@@ -0,0 +1,54 @@
|
||||
# Talk2Me Environment Configuration Template
|
||||
# Copy this file to .env and update with your values
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your-secret-key-here-change-this
|
||||
|
||||
# Security Settings for HTTPS/Reverse Proxy
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SESSION_COOKIE_SAMESITE=Lax
|
||||
PREFERRED_URL_SCHEME=https
|
||||
|
||||
# TTS Server Configuration
|
||||
TTS_SERVER_URL=http://localhost:5050/v1/audio/speech
|
||||
TTS_API_KEY=your-tts-api-key-here
|
||||
|
||||
# Whisper Configuration
|
||||
WHISPER_MODEL_SIZE=base
|
||||
WHISPER_DEVICE=auto
|
||||
|
||||
# Ollama Configuration
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
OLLAMA_MODEL=gemma3:27b
|
||||
|
||||
# Admin Configuration
|
||||
ADMIN_TOKEN=your-admin-token-here-change-this
|
||||
|
||||
# CORS Configuration (comma-separated)
|
||||
CORS_ORIGINS=https://talk2me.dr74.net,http://localhost:5000
|
||||
ADMIN_CORS_ORIGINS=https://talk2me.dr74.net
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_STORAGE_URL=memory://
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_PUSH_NOTIFICATIONS=true
|
||||
ENABLE_OFFLINE_MODE=true
|
||||
ENABLE_STREAMING=true
|
||||
ENABLE_MULTI_SPEAKER=true
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/talk2me.log
|
||||
|
||||
# Upload Configuration
|
||||
UPLOAD_FOLDER=/tmp/talk2me_uploads
|
||||
MAX_CONTENT_LENGTH=52428800
|
||||
MAX_AUDIO_SIZE=26214400
|
||||
MAX_JSON_SIZE=1048576
|
||||
|
||||
# Worker Configuration (for Gunicorn)
|
||||
WORKER_CONNECTIONS=1000
|
||||
WORKER_TIMEOUT=120
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -67,3 +67,15 @@ vapid_public.pem
|
||||
.master_key
|
||||
secrets.db
|
||||
*.key
|
||||
|
||||
# Test files
|
||||
test_*.py
|
||||
*_test_output.*
|
||||
test-*.html
|
||||
*-debug-script.py
|
||||
|
||||
# Claude IDE
|
||||
.claude/
|
||||
|
||||
# Standalone compiled JS (use dist/ instead)
|
||||
static/js/app.js
|
||||
|
||||
221
ADMIN_DASHBOARD.md
Normal file
221
ADMIN_DASHBOARD.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Talk2Me Admin Analytics Dashboard
|
||||
|
||||
A comprehensive analytics dashboard for monitoring and managing the Talk2Me application.
|
||||
|
||||
## Features
|
||||
|
||||
### Real-time Monitoring
|
||||
- **Request Volume**: Track requests per minute, hour, and day
|
||||
- **Active Sessions**: Monitor current active user sessions
|
||||
- **Error Rates**: Real-time error tracking and analysis
|
||||
- **System Health**: Monitor Redis, PostgreSQL, and ML services status
|
||||
|
||||
### Analytics & Insights
|
||||
- **Translation & Transcription Metrics**: Usage statistics by operation type
|
||||
- **Language Pair Analysis**: Most popular translation combinations
|
||||
- **Response Time Monitoring**: Track performance across all operations
|
||||
- **Cache Performance**: Monitor cache hit rates for optimization
|
||||
|
||||
### Performance Metrics
|
||||
- **Response Time Percentiles**: P95 and P99 latency tracking
|
||||
- **Throughput Analysis**: Requests per minute visualization
|
||||
- **Slow Request Detection**: Identify and analyze performance bottlenecks
|
||||
- **Resource Usage**: Memory and GPU utilization tracking
|
||||
|
||||
### Data Management
|
||||
- **Export Capabilities**: Download analytics data in JSON format
|
||||
- **Historical Data**: View trends over time (daily, weekly, monthly)
|
||||
- **Error Logs**: Detailed error tracking with stack traces
|
||||
- **Session Management**: Track and manage user sessions
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Database Setup
|
||||
|
||||
Initialize the analytics database tables:
|
||||
|
||||
```bash
|
||||
python init_analytics_db.py
|
||||
```
|
||||
|
||||
This creates the following tables:
|
||||
- `error_logs`: Detailed error tracking
|
||||
- `request_logs`: Request-level analytics
|
||||
- `translation_logs`: Translation operation metrics
|
||||
- `transcription_logs`: Transcription operation metrics
|
||||
- `tts_logs`: Text-to-speech operation metrics
|
||||
- `daily_stats`: Aggregated daily statistics
|
||||
|
||||
### 2. Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```bash
|
||||
# Admin access token (required)
|
||||
export ADMIN_TOKEN="your-secure-admin-token"
|
||||
|
||||
# Database configuration
|
||||
export DATABASE_URL="postgresql://user:password@localhost/talk2me"
|
||||
|
||||
# Redis configuration
|
||||
export REDIS_URL="redis://localhost:6379/0"
|
||||
```
|
||||
|
||||
### 3. Access the Dashboard
|
||||
|
||||
1. Navigate to: `http://your-domain/admin`
|
||||
2. Enter your admin token
|
||||
3. Access the analytics dashboard
|
||||
|
||||
## Dashboard Sections
|
||||
|
||||
### Overview Cards
|
||||
- Total Requests (all-time and today)
|
||||
- Active Sessions (real-time)
|
||||
- Error Rate (24-hour percentage)
|
||||
- Cache Hit Rate (performance metric)
|
||||
|
||||
### Charts & Visualizations
|
||||
|
||||
#### Request Volume Chart
|
||||
- Toggle between minute, hour, and day views
|
||||
- Real-time updates every 5 seconds
|
||||
- Historical data for trend analysis
|
||||
|
||||
#### Language Pairs Donut Chart
|
||||
- Top 6 most used language combinations
|
||||
- Visual breakdown of translation patterns
|
||||
|
||||
#### Operations Bar Chart
|
||||
- Daily translation and transcription counts
|
||||
- 7-day historical view
|
||||
|
||||
#### Response Time Line Chart
|
||||
- Average, P95, and P99 response times
|
||||
- Broken down by operation type
|
||||
|
||||
#### Error Analysis
|
||||
- Error type distribution pie chart
|
||||
- Recent errors list with details
|
||||
- Timeline of error occurrences
|
||||
|
||||
### Performance Table
|
||||
- Detailed metrics for each operation type
|
||||
- Average response times
|
||||
- 95th and 99th percentile latencies
|
||||
|
||||
## Real-time Updates
|
||||
|
||||
The dashboard uses Server-Sent Events (SSE) for real-time updates:
|
||||
- Automatic refresh every 5 seconds
|
||||
- Connection status indicator
|
||||
- Automatic reconnection on disconnect
|
||||
|
||||
## Data Export
|
||||
|
||||
Export analytics data for external analysis:
|
||||
|
||||
1. Click "Export Data" in the navigation
|
||||
2. Choose data type:
|
||||
- `requests`: Request and operation counts
|
||||
- `errors`: Error logs and details
|
||||
- `performance`: Response time metrics
|
||||
- `all`: Complete data export
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The admin dashboard provides the following API endpoints:
|
||||
|
||||
### Authentication Required
|
||||
All endpoints require the `X-Admin-Token` header.
|
||||
|
||||
### Available Endpoints
|
||||
|
||||
#### Overview Stats
|
||||
```
|
||||
GET /admin/api/stats/overview
|
||||
```
|
||||
Returns overall system statistics
|
||||
|
||||
#### Request Statistics
|
||||
```
|
||||
GET /admin/api/stats/requests/{timeframe}
|
||||
```
|
||||
Timeframes: `minute`, `hour`, `day`
|
||||
|
||||
#### Operation Statistics
|
||||
```
|
||||
GET /admin/api/stats/operations
|
||||
```
|
||||
Translation and transcription metrics
|
||||
|
||||
#### Error Statistics
|
||||
```
|
||||
GET /admin/api/stats/errors
|
||||
```
|
||||
Error types, timeline, and recent errors
|
||||
|
||||
#### Performance Statistics
|
||||
```
|
||||
GET /admin/api/stats/performance
|
||||
```
|
||||
Response times and throughput metrics
|
||||
|
||||
#### Data Export
|
||||
```
|
||||
GET /admin/api/export/{data_type}
|
||||
```
|
||||
Data types: `requests`, `errors`, `performance`, `all`
|
||||
|
||||
#### Real-time Updates
|
||||
```
|
||||
GET /admin/api/stream/updates
|
||||
```
|
||||
Server-Sent Events stream for real-time updates
|
||||
|
||||
## Mobile Optimization
|
||||
|
||||
The dashboard is fully responsive and optimized for mobile devices:
|
||||
- Touch-friendly controls
|
||||
- Responsive charts that adapt to screen size
|
||||
- Collapsible navigation for small screens
|
||||
- Optimized data tables for mobile viewing
|
||||
|
||||
## Security
|
||||
|
||||
- Admin token authentication required
|
||||
- Session-based authentication after login
|
||||
- Separate CORS configuration for admin endpoints
|
||||
- All sensitive data masked in exports
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Dashboard Not Loading
|
||||
1. Check Redis and PostgreSQL connections
|
||||
2. Verify admin token is set correctly
|
||||
3. Check browser console for JavaScript errors
|
||||
|
||||
### Missing Data
|
||||
1. Ensure analytics middleware is initialized
|
||||
2. Check database tables are created
|
||||
3. Verify Redis is running and accessible
|
||||
|
||||
### Real-time Updates Not Working
|
||||
1. Check SSE support in your reverse proxy
|
||||
2. Ensure `X-Accel-Buffering: no` header is set
|
||||
3. Verify firewall allows SSE connections
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Charts limited to reasonable data points for performance
|
||||
- Automatic data aggregation for historical views
|
||||
- Efficient database queries with proper indexing
|
||||
- Client-side caching for static data
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- WebSocket support for lower latency updates
|
||||
- Customizable dashboards and widgets
|
||||
- Alert configuration for thresholds
|
||||
- Integration with external monitoring tools
|
||||
- Machine learning for anomaly detection
|
||||
315
AUTHENTICATION.md
Normal file
315
AUTHENTICATION.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Talk2Me Authentication System
|
||||
|
||||
This document describes the comprehensive user authentication and authorization system implemented for the Talk2Me application.
|
||||
|
||||
## Overview
|
||||
|
||||
The authentication system provides:
|
||||
- User account management with roles (admin, user)
|
||||
- JWT-based API authentication
|
||||
- Session management for web interface
|
||||
- API key authentication for programmatic access
|
||||
- User-specific rate limiting
|
||||
- Admin dashboard for user management
|
||||
- Secure password hashing with bcrypt
|
||||
|
||||
## Features
|
||||
|
||||
### 1. User Management
|
||||
|
||||
#### User Account Model
|
||||
- **Email & Username**: Unique identifiers for each user
|
||||
- **Password**: Securely hashed using bcrypt
|
||||
- **API Key**: Unique key for each user (format: `tk_<random_string>`)
|
||||
- **Roles**: `admin` or `user`
|
||||
- **Account Status**: Active, verified, suspended
|
||||
- **Rate Limits**: Configurable per-user limits
|
||||
- **Usage Tracking**: Tracks requests, translations, transcriptions, and TTS usage
|
||||
|
||||
#### Admin Features
|
||||
- Create, update, delete users
|
||||
- Suspend/unsuspend accounts
|
||||
- Reset passwords
|
||||
- Manage user permissions
|
||||
- View login history
|
||||
- Monitor active sessions
|
||||
- Bulk operations
|
||||
|
||||
### 2. Authentication Methods
|
||||
|
||||
#### JWT Authentication
|
||||
- Access tokens (1 hour expiration)
|
||||
- Refresh tokens (30 days expiration)
|
||||
- Token blacklisting for revocation
|
||||
- Secure token storage
|
||||
|
||||
#### API Key Authentication
|
||||
- Bearer token in `X-API-Key` header
|
||||
- Query parameter fallback: `?api_key=tk_xxx`
|
||||
- Per-key rate limiting
|
||||
|
||||
#### Session Management
|
||||
- Track active sessions per user
|
||||
- Session expiration handling
|
||||
- Multi-device support
|
||||
- Session revocation
|
||||
|
||||
### 3. Security Features
|
||||
|
||||
#### Password Security
|
||||
- Bcrypt hashing with salt
|
||||
- Minimum 8 character requirement
|
||||
- Password change tracking
|
||||
- Failed login attempt tracking
|
||||
- Account lockout after 5 failed attempts (30 minutes)
|
||||
|
||||
#### Rate Limiting
|
||||
- User-specific limits (per minute/hour/day)
|
||||
- IP-based fallback for unauthenticated requests
|
||||
- Admin users get 10x higher limits
|
||||
- Endpoint-specific overrides
|
||||
|
||||
#### Audit Trail
|
||||
- Login history with IP and user agent
|
||||
- Success/failure tracking
|
||||
- Suspicious activity flagging
|
||||
- Security event logging
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Users Table
|
||||
```sql
|
||||
- id (UUID, primary key)
|
||||
- email (unique)
|
||||
- username (unique)
|
||||
- password_hash
|
||||
- api_key (unique)
|
||||
- role (admin/user)
|
||||
- is_active, is_verified, is_suspended
|
||||
- rate limits (per_minute, per_hour, per_day)
|
||||
- usage stats (total_requests, translations, etc.)
|
||||
- timestamps (created_at, updated_at, last_login_at)
|
||||
```
|
||||
|
||||
### Login History Table
|
||||
```sql
|
||||
- id (UUID)
|
||||
- user_id (foreign key)
|
||||
- login_at, logout_at
|
||||
- login_method (password/api_key/jwt)
|
||||
- success (boolean)
|
||||
- ip_address, user_agent
|
||||
- session_id, jwt_jti
|
||||
```
|
||||
|
||||
### User Sessions Table
|
||||
```sql
|
||||
- id (UUID)
|
||||
- session_id (unique)
|
||||
- user_id (foreign key)
|
||||
- access_token_jti, refresh_token_jti
|
||||
- created_at, last_active_at, expires_at
|
||||
- ip_address, user_agent
|
||||
```
|
||||
|
||||
### Revoked Tokens Table
|
||||
```sql
|
||||
- id (UUID)
|
||||
- jti (unique, token ID)
|
||||
- token_type (access/refresh)
|
||||
- user_id
|
||||
- revoked_at, expires_at
|
||||
- reason
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
#### POST /api/auth/login
|
||||
Login with username/email and password.
|
||||
```json
|
||||
{
|
||||
"username": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"user": { ... },
|
||||
"tokens": {
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ...",
|
||||
"expires_in": 3600
|
||||
},
|
||||
"session_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/auth/logout
|
||||
Logout and revoke current token.
|
||||
|
||||
#### POST /api/auth/refresh
|
||||
Refresh access token using refresh token.
|
||||
|
||||
#### GET /api/auth/profile
|
||||
Get current user profile.
|
||||
|
||||
#### PUT /api/auth/profile
|
||||
Update user profile (name, settings).
|
||||
|
||||
#### POST /api/auth/change-password
|
||||
Change user password.
|
||||
|
||||
#### POST /api/auth/regenerate-api-key
|
||||
Generate new API key.
|
||||
|
||||
### Admin User Management
|
||||
|
||||
#### GET /api/auth/admin/users
|
||||
List all users with filtering and pagination.
|
||||
|
||||
#### POST /api/auth/admin/users
|
||||
Create new user (admin only).
|
||||
|
||||
#### GET /api/auth/admin/users/:id
|
||||
Get user details with login history.
|
||||
|
||||
#### PUT /api/auth/admin/users/:id
|
||||
Update user details.
|
||||
|
||||
#### DELETE /api/auth/admin/users/:id
|
||||
Delete user account.
|
||||
|
||||
#### POST /api/auth/admin/users/:id/suspend
|
||||
Suspend user account.
|
||||
|
||||
#### POST /api/auth/admin/users/:id/reset-password
|
||||
Reset user password.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Authenticating Requests
|
||||
|
||||
#### Using JWT Token
|
||||
```bash
|
||||
curl -H "Authorization: Bearer eyJ..." \
|
||||
https://api.talk2me.app/translate
|
||||
```
|
||||
|
||||
#### Using API Key
|
||||
```bash
|
||||
curl -H "X-API-Key: tk_your_api_key" \
|
||||
https://api.talk2me.app/translate
|
||||
```
|
||||
|
||||
### Python Client Example
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Login and get token
|
||||
response = requests.post('https://api.talk2me.app/api/auth/login', json={
|
||||
'username': 'user@example.com',
|
||||
'password': 'password123'
|
||||
})
|
||||
tokens = response.json()['tokens']
|
||||
|
||||
# Use token for requests
|
||||
headers = {'Authorization': f"Bearer {tokens['access_token']}"}
|
||||
translation = requests.post(
|
||||
'https://api.talk2me.app/translate',
|
||||
headers=headers,
|
||||
json={'text': 'Hello', 'target_lang': 'Spanish'}
|
||||
)
|
||||
```
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Initialize Database
|
||||
```bash
|
||||
python init_auth_db.py
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create all database tables
|
||||
- Prompt you to create an admin user
|
||||
- Display the admin's API key
|
||||
|
||||
### 3. Configure Environment
|
||||
Add to your `.env` file:
|
||||
```env
|
||||
JWT_SECRET_KEY=your-secret-key-change-in-production
|
||||
DATABASE_URL=postgresql://user:pass@localhost/talk2me
|
||||
```
|
||||
|
||||
### 4. Run Migrations (if needed)
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **JWT Secret**: Use a strong, random secret key in production
|
||||
2. **HTTPS Only**: Always use HTTPS in production
|
||||
3. **Rate Limiting**: Configure appropriate limits per user role
|
||||
4. **Password Policy**: Enforce strong passwords
|
||||
5. **Session Timeout**: Configure appropriate session durations
|
||||
6. **Audit Logging**: Monitor login attempts and suspicious activity
|
||||
7. **API Key Rotation**: Encourage regular API key rotation
|
||||
8. **Database Security**: Use encrypted connections to database
|
||||
|
||||
## Admin Dashboard
|
||||
|
||||
Access the admin dashboard at `/admin/users` (requires admin login).
|
||||
|
||||
Features:
|
||||
- User list with search and filters
|
||||
- User details with usage statistics
|
||||
- Create/edit/delete users
|
||||
- Suspend/unsuspend accounts
|
||||
- View login history
|
||||
- Monitor active sessions
|
||||
- Bulk operations
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Default limits:
|
||||
- **Regular Users**: 30/min, 500/hour, 5000/day
|
||||
- **Admin Users**: 300/min, 5000/hour, 50000/day
|
||||
|
||||
Endpoint-specific limits are configured in `user_rate_limiter.py`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Token expired"**: Refresh token using `/api/auth/refresh`
|
||||
2. **"Account locked"**: Wait 30 minutes or contact admin
|
||||
3. **"Rate limit exceeded"**: Check your usage limits
|
||||
4. **"Invalid API key"**: Regenerate key in profile settings
|
||||
|
||||
### Debug Mode
|
||||
Enable debug logging:
|
||||
```python
|
||||
import logging
|
||||
logging.getLogger('auth').setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] OAuth2 integration (Google, GitHub)
|
||||
- [ ] Two-factor authentication
|
||||
- [ ] Email verification workflow
|
||||
- [ ] Password reset via email
|
||||
- [ ] User groups and team management
|
||||
- [ ] Fine-grained permissions
|
||||
- [ ] API key scopes
|
||||
- [ ] Usage quotas and billing
|
||||
@@ -1,173 +0,0 @@
|
||||
# Connection Retry Logic Documentation
|
||||
|
||||
This document explains the connection retry and network interruption handling features in Talk2Me.
|
||||
|
||||
## Overview
|
||||
|
||||
Talk2Me implements robust connection retry logic to handle network interruptions gracefully. When a connection is lost or a request fails due to network issues, the application automatically queues requests and retries them when the connection is restored.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Automatic Connection Monitoring
|
||||
- Monitors browser online/offline events
|
||||
- Periodic health checks to the server (every 5 seconds when offline)
|
||||
- Visual connection status indicator
|
||||
- Automatic detection when returning from sleep/hibernation
|
||||
|
||||
### 2. Request Queuing
|
||||
- Failed requests are automatically queued during network interruptions
|
||||
- Requests maintain their priority and are processed in order
|
||||
- Queue persists across connection failures
|
||||
- Visual indication of queued requests
|
||||
|
||||
### 3. Exponential Backoff Retry
|
||||
- Failed requests are retried with exponential backoff
|
||||
- Initial retry delay: 1 second
|
||||
- Maximum retry delay: 30 seconds
|
||||
- Backoff multiplier: 2x
|
||||
- Maximum retries: 3 attempts
|
||||
|
||||
### 4. Connection Status UI
|
||||
- Real-time connection status indicator (bottom-right corner)
|
||||
- Offline banner with retry button
|
||||
- Queue status showing pending requests by type
|
||||
- Temporary status messages for important events
|
||||
|
||||
## User Experience
|
||||
|
||||
### When Connection is Lost
|
||||
|
||||
1. **Visual Indicators**:
|
||||
- Connection status shows "Offline" or "Connection error"
|
||||
- Red banner appears at top of screen
|
||||
- Queued request count is displayed
|
||||
|
||||
2. **Request Handling**:
|
||||
- New requests are automatically queued
|
||||
- User sees "Connection error - queued" message
|
||||
- Requests will be sent when connection returns
|
||||
|
||||
3. **Manual Retry**:
|
||||
- Users can click "Retry" button in offline banner
|
||||
- Forces immediate connection check
|
||||
|
||||
### When Connection is Restored
|
||||
|
||||
1. **Automatic Recovery**:
|
||||
- Connection status changes to "Connecting..."
|
||||
- Queued requests are processed automatically
|
||||
- Success message shown briefly
|
||||
|
||||
2. **Request Processing**:
|
||||
- Queued requests maintain their order
|
||||
- Higher priority requests (transcription) processed first
|
||||
- Progress indicators show processing status
|
||||
|
||||
## Configuration
|
||||
|
||||
The connection retry logic can be configured programmatically:
|
||||
|
||||
```javascript
|
||||
// In app.ts or initialization code
|
||||
connectionManager.configure({
|
||||
maxRetries: 3, // Maximum retry attempts
|
||||
initialDelay: 1000, // Initial retry delay (ms)
|
||||
maxDelay: 30000, // Maximum retry delay (ms)
|
||||
backoffMultiplier: 2, // Exponential backoff multiplier
|
||||
timeout: 10000, // Request timeout (ms)
|
||||
onlineCheckInterval: 5000 // Health check interval (ms)
|
||||
});
|
||||
```
|
||||
|
||||
## Request Priority
|
||||
|
||||
Requests are prioritized as follows:
|
||||
1. **Transcription** (Priority: 8) - Highest priority
|
||||
2. **Translation** (Priority: 5) - Normal priority
|
||||
3. **TTS/Audio** (Priority: 3) - Lower priority
|
||||
|
||||
## Error Types
|
||||
|
||||
### Retryable Errors
|
||||
- Network errors
|
||||
- Connection timeouts
|
||||
- Server errors (5xx)
|
||||
- CORS errors (in some cases)
|
||||
|
||||
### Non-Retryable Errors
|
||||
- Client errors (4xx)
|
||||
- Authentication errors
|
||||
- Rate limit errors
|
||||
- Invalid request errors
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **For Users**:
|
||||
- Wait for queued requests to complete before closing the app
|
||||
- Use the manual retry button if automatic recovery fails
|
||||
- Check the connection status indicator for current state
|
||||
|
||||
2. **For Developers**:
|
||||
- All fetch requests should go through RequestQueueManager
|
||||
- Use appropriate request priorities
|
||||
- Handle both online and offline scenarios in UI
|
||||
- Provide clear feedback about connection status
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **ConnectionManager** (`connectionManager.ts`):
|
||||
- Monitors connection state
|
||||
- Implements retry logic with exponential backoff
|
||||
- Provides connection state subscriptions
|
||||
|
||||
2. **RequestQueueManager** (`requestQueue.ts`):
|
||||
- Queues failed requests
|
||||
- Integrates with ConnectionManager
|
||||
- Handles request prioritization
|
||||
|
||||
3. **ConnectionUI** (`connectionUI.ts`):
|
||||
- Displays connection status
|
||||
- Shows offline banner
|
||||
- Updates queue information
|
||||
|
||||
### Integration Example
|
||||
|
||||
```typescript
|
||||
// Automatic integration through RequestQueueManager
|
||||
const queue = RequestQueueManager.getInstance();
|
||||
const data = await queue.enqueue<ResponseType>(
|
||||
'translate', // Request type
|
||||
async () => {
|
||||
// Your fetch request
|
||||
const response = await fetch('/api/translate', options);
|
||||
return response.json();
|
||||
},
|
||||
5 // Priority (1-10, higher = more important)
|
||||
);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Not Detected
|
||||
- Check browser permissions for network status
|
||||
- Ensure health endpoint (/health) is accessible
|
||||
- Verify no firewall/proxy blocking
|
||||
|
||||
### Requests Not Retrying
|
||||
- Check browser console for errors
|
||||
- Verify request type is retryable
|
||||
- Check if max retries exceeded
|
||||
|
||||
### Queue Not Processing
|
||||
- Manually trigger retry with button
|
||||
- Check if requests are timing out
|
||||
- Verify server is responding
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Persistent queue storage (survive page refresh)
|
||||
- Configurable retry strategies per request type
|
||||
- Network speed detection and adaptation
|
||||
- Progressive web app offline mode
|
||||
152
CORS_CONFIG.md
152
CORS_CONFIG.md
@@ -1,152 +0,0 @@
|
||||
# CORS Configuration Guide
|
||||
|
||||
This document explains how to configure Cross-Origin Resource Sharing (CORS) for the Talk2Me application.
|
||||
|
||||
## Overview
|
||||
|
||||
CORS is configured using Flask-CORS to enable secure cross-origin usage of the API endpoints. This allows the Talk2Me application to be embedded in other websites or accessed from different domains while maintaining security.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### `CORS_ORIGINS`
|
||||
|
||||
Controls which domains are allowed to access the API endpoints.
|
||||
|
||||
- **Default**: `*` (allows all origins - use only for development)
|
||||
- **Production Example**: `https://yourdomain.com,https://app.yourdomain.com`
|
||||
- **Format**: Comma-separated list of allowed origins
|
||||
|
||||
```bash
|
||||
# Development (allows all origins)
|
||||
export CORS_ORIGINS="*"
|
||||
|
||||
# Production (restrict to specific domains)
|
||||
export CORS_ORIGINS="https://talk2me.example.com,https://app.example.com"
|
||||
```
|
||||
|
||||
### `ADMIN_CORS_ORIGINS`
|
||||
|
||||
Controls which domains can access admin endpoints (more restrictive).
|
||||
|
||||
- **Default**: `http://localhost:*` (allows all localhost ports)
|
||||
- **Production Example**: `https://admin.yourdomain.com`
|
||||
- **Format**: Comma-separated list of allowed admin origins
|
||||
|
||||
```bash
|
||||
# Development
|
||||
export ADMIN_CORS_ORIGINS="http://localhost:*"
|
||||
|
||||
# Production
|
||||
export ADMIN_CORS_ORIGINS="https://admin.talk2me.example.com"
|
||||
```
|
||||
|
||||
## Configuration Details
|
||||
|
||||
The CORS configuration includes:
|
||||
|
||||
- **Allowed Methods**: GET, POST, OPTIONS
|
||||
- **Allowed Headers**: Content-Type, Authorization, X-Requested-With, X-Admin-Token
|
||||
- **Exposed Headers**: Content-Range, X-Content-Range
|
||||
- **Credentials Support**: Enabled (supports cookies and authorization headers)
|
||||
- **Max Age**: 3600 seconds (preflight requests cached for 1 hour)
|
||||
|
||||
## Endpoints
|
||||
|
||||
All endpoints have CORS enabled with the following configuration:
|
||||
|
||||
### Regular API Endpoints
|
||||
- `/api/*`
|
||||
- `/transcribe`
|
||||
- `/translate`
|
||||
- `/translate/stream`
|
||||
- `/speak`
|
||||
- `/get_audio/*`
|
||||
- `/check_tts_server`
|
||||
- `/update_tts_config`
|
||||
- `/health/*`
|
||||
|
||||
### Admin Endpoints (More Restrictive)
|
||||
- `/admin/*` - Uses `ADMIN_CORS_ORIGINS` instead of general `CORS_ORIGINS`
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never use `*` in production** - Always specify exact allowed origins
|
||||
2. **Use HTTPS** - Always use HTTPS URLs in production CORS origins
|
||||
3. **Separate admin origins** - Keep admin endpoints on a separate, more restrictive origin list
|
||||
4. **Review regularly** - Periodically review and update allowed origins
|
||||
|
||||
## Example Configurations
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
export CORS_ORIGINS="*"
|
||||
export ADMIN_CORS_ORIGINS="http://localhost:*"
|
||||
```
|
||||
|
||||
### Staging Environment
|
||||
```bash
|
||||
export CORS_ORIGINS="https://staging.talk2me.com,https://staging-app.talk2me.com"
|
||||
export ADMIN_CORS_ORIGINS="https://staging-admin.talk2me.com"
|
||||
```
|
||||
|
||||
### Production Environment
|
||||
```bash
|
||||
export CORS_ORIGINS="https://talk2me.com,https://app.talk2me.com"
|
||||
export ADMIN_CORS_ORIGINS="https://admin.talk2me.com"
|
||||
```
|
||||
|
||||
### Mobile App Integration
|
||||
```bash
|
||||
# Include mobile app schemes if needed
|
||||
export CORS_ORIGINS="https://talk2me.com,https://app.talk2me.com,capacitor://localhost,ionic://localhost"
|
||||
```
|
||||
|
||||
## Testing CORS Configuration
|
||||
|
||||
You can test CORS configuration using curl:
|
||||
|
||||
```bash
|
||||
# Test preflight request
|
||||
curl -X OPTIONS https://your-api.com/api/transcribe \
|
||||
-H "Origin: https://allowed-origin.com" \
|
||||
-H "Access-Control-Request-Method: POST" \
|
||||
-H "Access-Control-Request-Headers: Content-Type" \
|
||||
-v
|
||||
|
||||
# Test actual request
|
||||
curl -X POST https://your-api.com/api/transcribe \
|
||||
-H "Origin: https://allowed-origin.com" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"test": "data"}' \
|
||||
-v
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### CORS Errors in Browser Console
|
||||
|
||||
If you see CORS errors:
|
||||
|
||||
1. Check that the origin is included in `CORS_ORIGINS`
|
||||
2. Ensure the URL protocol matches (http vs https)
|
||||
3. Check for trailing slashes in origins
|
||||
4. Verify environment variables are set correctly
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"No 'Access-Control-Allow-Origin' header"**
|
||||
- Origin not in allowed list
|
||||
- Check `CORS_ORIGINS` environment variable
|
||||
|
||||
2. **"CORS policy: The request client is not a secure context"**
|
||||
- Using HTTP instead of HTTPS
|
||||
- Update to use HTTPS in production
|
||||
|
||||
3. **"CORS policy: Credentials flag is true, but Access-Control-Allow-Credentials is not 'true'"**
|
||||
- This should not occur with current configuration
|
||||
- Check that `supports_credentials` is True in CORS config
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [MDN CORS Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
|
||||
- [Flask-CORS Documentation](https://flask-cors.readthedocs.io/)
|
||||
302
DATABASE_INTEGRATION.md
Normal file
302
DATABASE_INTEGRATION.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Database Integration Guide
|
||||
|
||||
This guide explains the Redis and PostgreSQL integration for the Talk2Me application.
|
||||
|
||||
## Overview
|
||||
|
||||
The Talk2Me application now uses:
|
||||
- **PostgreSQL**: For persistent storage of translations, transcriptions, user preferences, and analytics
|
||||
- **Redis**: For caching, session management, and rate limiting
|
||||
|
||||
## Architecture
|
||||
|
||||
### PostgreSQL Database Schema
|
||||
|
||||
1. **translations** - Stores translation history
|
||||
- Source and target text
|
||||
- Languages
|
||||
- Translation time and model used
|
||||
- Session and user tracking
|
||||
|
||||
2. **transcriptions** - Stores transcription history
|
||||
- Transcribed text
|
||||
- Detected language
|
||||
- Audio metadata
|
||||
- Performance metrics
|
||||
|
||||
3. **user_preferences** - Stores user settings
|
||||
- Preferred languages
|
||||
- Voice preferences
|
||||
- Usage statistics
|
||||
|
||||
4. **usage_analytics** - Aggregated analytics
|
||||
- Hourly and daily metrics
|
||||
- Service performance
|
||||
- Language pair statistics
|
||||
|
||||
5. **api_keys** - API key management
|
||||
- Rate limits
|
||||
- Permissions
|
||||
- Usage tracking
|
||||
|
||||
### Redis Usage
|
||||
|
||||
1. **Translation Cache**
|
||||
- Key: `translation:{source_lang}:{target_lang}:{text_hash}`
|
||||
- Expires: 24 hours
|
||||
- Reduces API calls to Ollama
|
||||
|
||||
2. **Session Management**
|
||||
- Key: `session:{session_id}`
|
||||
- Stores session data and resources
|
||||
- Expires: 1 hour (configurable)
|
||||
|
||||
3. **Rate Limiting**
|
||||
- Token bucket implementation
|
||||
- Per-client and global limits
|
||||
- Sliding window tracking
|
||||
|
||||
4. **Push Subscriptions**
|
||||
- Set: `push_subscriptions`
|
||||
- Individual subscriptions: `push_subscription:{id}`
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Install PostgreSQL:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install postgresql postgresql-contrib
|
||||
|
||||
# MacOS
|
||||
brew install postgresql
|
||||
```
|
||||
|
||||
2. Install Redis:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install redis-server
|
||||
|
||||
# MacOS
|
||||
brew install redis
|
||||
```
|
||||
|
||||
3. Install Python dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Quick Setup
|
||||
|
||||
Run the setup script:
|
||||
```bash
|
||||
./setup_databases.sh
|
||||
```
|
||||
|
||||
### Manual Setup
|
||||
|
||||
1. Create PostgreSQL database:
|
||||
```bash
|
||||
createdb talk2me
|
||||
```
|
||||
|
||||
2. Start Redis:
|
||||
```bash
|
||||
redis-server
|
||||
```
|
||||
|
||||
3. Create .env file with database URLs:
|
||||
```env
|
||||
DATABASE_URL=postgresql://username@localhost/talk2me
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
4. Initialize database:
|
||||
```bash
|
||||
python database_init.py
|
||||
```
|
||||
|
||||
5. Run migrations:
|
||||
```bash
|
||||
python migrations.py init
|
||||
python migrations.py create "Initial migration"
|
||||
python migrations.py run
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
# PostgreSQL
|
||||
DATABASE_URL=postgresql://username:password@host:port/database
|
||||
SQLALCHEMY_DATABASE_URI=${DATABASE_URL}
|
||||
SQLALCHEMY_ENGINE_OPTIONS_POOL_SIZE=10
|
||||
SQLALCHEMY_ENGINE_OPTIONS_POOL_RECYCLE=3600
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_DECODE_RESPONSES=false
|
||||
REDIS_MAX_CONNECTIONS=50
|
||||
REDIS_SOCKET_TIMEOUT=5
|
||||
|
||||
# Session Management
|
||||
MAX_SESSION_DURATION=3600
|
||||
MAX_SESSION_IDLE_TIME=900
|
||||
MAX_RESOURCES_PER_SESSION=100
|
||||
MAX_BYTES_PER_SESSION=104857600
|
||||
```
|
||||
|
||||
## Migration from In-Memory to Database
|
||||
|
||||
### What Changed
|
||||
|
||||
1. **Rate Limiting**
|
||||
- Before: In-memory dictionaries
|
||||
- After: Redis sorted sets and hashes
|
||||
|
||||
2. **Session Management**
|
||||
- Before: In-memory session storage
|
||||
- After: Redis with automatic expiration
|
||||
|
||||
3. **Translation Cache**
|
||||
- Before: Client-side IndexedDB only
|
||||
- After: Server-side Redis cache + client cache
|
||||
|
||||
4. **Analytics**
|
||||
- Before: No persistent analytics
|
||||
- After: PostgreSQL aggregated metrics
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. Backup current app.py:
|
||||
```bash
|
||||
cp app.py app_backup.py
|
||||
```
|
||||
|
||||
2. Use the new app with database support:
|
||||
```bash
|
||||
cp app_with_db.py app.py
|
||||
```
|
||||
|
||||
3. Update any custom configurations in the new app.py
|
||||
|
||||
## API Changes
|
||||
|
||||
### New Endpoints
|
||||
|
||||
- `/api/history/translations` - Get translation history
|
||||
- `/api/history/transcriptions` - Get transcription history
|
||||
- `/api/preferences` - Get/update user preferences
|
||||
- `/api/analytics` - Get usage analytics
|
||||
|
||||
### Enhanced Features
|
||||
|
||||
1. **Translation Caching**
|
||||
- Automatic server-side caching
|
||||
- Reduced response time for repeated translations
|
||||
|
||||
2. **Session Persistence**
|
||||
- Sessions survive server restarts
|
||||
- Better resource tracking
|
||||
|
||||
3. **Improved Rate Limiting**
|
||||
- Distributed rate limiting across multiple servers
|
||||
- More accurate tracking
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Database Indexes**
|
||||
- Indexes on session_id, user_id, languages
|
||||
- Composite indexes for common queries
|
||||
|
||||
2. **Redis Memory Usage**
|
||||
- Monitor with: `redis-cli info memory`
|
||||
- Configure maxmemory policy
|
||||
|
||||
3. **Connection Pooling**
|
||||
- PostgreSQL: 10 connections default
|
||||
- Redis: 50 connections default
|
||||
|
||||
## Monitoring
|
||||
|
||||
### PostgreSQL
|
||||
```sql
|
||||
-- Check database size
|
||||
SELECT pg_database_size('talk2me');
|
||||
|
||||
-- Active connections
|
||||
SELECT count(*) FROM pg_stat_activity;
|
||||
|
||||
-- Slow queries
|
||||
SELECT * FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10;
|
||||
```
|
||||
|
||||
### Redis
|
||||
```bash
|
||||
# Memory usage
|
||||
redis-cli info memory
|
||||
|
||||
# Connected clients
|
||||
redis-cli info clients
|
||||
|
||||
# Monitor commands
|
||||
redis-cli monitor
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **PostgreSQL Connection Failed**
|
||||
- Check if PostgreSQL is running: `sudo systemctl status postgresql`
|
||||
- Verify DATABASE_URL in .env
|
||||
- Check pg_hba.conf for authentication
|
||||
|
||||
2. **Redis Connection Failed**
|
||||
- Check if Redis is running: `redis-cli ping`
|
||||
- Verify REDIS_URL in .env
|
||||
- Check Redis logs: `sudo journalctl -u redis`
|
||||
|
||||
3. **Migration Errors**
|
||||
- Drop and recreate database if needed
|
||||
- Check migration files in `migrations/`
|
||||
- Run `python migrations.py init` to reinitialize
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### PostgreSQL Backup
|
||||
```bash
|
||||
# Backup
|
||||
pg_dump talk2me > talk2me_backup.sql
|
||||
|
||||
# Restore
|
||||
psql talk2me < talk2me_backup.sql
|
||||
```
|
||||
|
||||
### Redis Backup
|
||||
```bash
|
||||
# Backup (if persistence enabled)
|
||||
redis-cli BGSAVE
|
||||
|
||||
# Copy dump.rdb file
|
||||
cp /var/lib/redis/dump.rdb redis_backup.rdb
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **Database Credentials**
|
||||
- Never commit .env file
|
||||
- Use strong passwords
|
||||
- Limit database user permissions
|
||||
|
||||
2. **Redis Security**
|
||||
- Consider enabling Redis AUTH
|
||||
- Bind to localhost only
|
||||
- Use SSL for remote connections
|
||||
|
||||
3. **Data Privacy**
|
||||
- Translations/transcriptions contain user data
|
||||
- Implement data retention policies
|
||||
- Consider encryption at rest
|
||||
460
ERROR_LOGGING.md
460
ERROR_LOGGING.md
@@ -1,460 +0,0 @@
|
||||
# Error Logging Documentation
|
||||
|
||||
This document describes the comprehensive error logging system implemented in Talk2Me for debugging production issues.
|
||||
|
||||
## Overview
|
||||
|
||||
Talk2Me implements a structured logging system that provides:
|
||||
- JSON-formatted structured logs for easy parsing
|
||||
- Multiple log streams (app, errors, access, security, performance)
|
||||
- Automatic log rotation to prevent disk space issues
|
||||
- Request tracing with unique IDs
|
||||
- Performance metrics collection
|
||||
- Security event tracking
|
||||
- Error deduplication and frequency tracking
|
||||
|
||||
## Log Types
|
||||
|
||||
### 1. Application Logs (`logs/talk2me.log`)
|
||||
General application logs including info, warnings, and debug messages.
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:30:45.123Z",
|
||||
"level": "INFO",
|
||||
"logger": "talk2me",
|
||||
"message": "Whisper model loaded successfully",
|
||||
"app": "talk2me",
|
||||
"environment": "production",
|
||||
"hostname": "server-1",
|
||||
"thread": "MainThread",
|
||||
"process": 12345
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Error Logs (`logs/errors.log`)
|
||||
Dedicated error logging with full exception details and stack traces.
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:31:00.456Z",
|
||||
"level": "ERROR",
|
||||
"logger": "talk2me.errors",
|
||||
"message": "Error in transcribe: File too large",
|
||||
"exception": {
|
||||
"type": "ValueError",
|
||||
"message": "Audio file exceeds maximum size",
|
||||
"traceback": ["...full stack trace..."]
|
||||
},
|
||||
"request_id": "1234567890-abcdef",
|
||||
"endpoint": "transcribe",
|
||||
"method": "POST",
|
||||
"path": "/transcribe",
|
||||
"ip": "192.168.1.100"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Access Logs (`logs/access.log`)
|
||||
HTTP request/response logging for traffic analysis.
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:32:00.789Z",
|
||||
"level": "INFO",
|
||||
"message": "request_complete",
|
||||
"request_id": "1234567890-abcdef",
|
||||
"method": "POST",
|
||||
"path": "/transcribe",
|
||||
"status": 200,
|
||||
"duration_ms": 1250,
|
||||
"content_length": 4096,
|
||||
"ip": "192.168.1.100",
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Security Logs (`logs/security.log`)
|
||||
Security-related events and suspicious activities.
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:33:00.123Z",
|
||||
"level": "WARNING",
|
||||
"message": "Security event: rate_limit_exceeded",
|
||||
"event": "rate_limit_exceeded",
|
||||
"severity": "warning",
|
||||
"ip": "192.168.1.100",
|
||||
"endpoint": "/transcribe",
|
||||
"attempts": 15,
|
||||
"blocked": true
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Performance Logs (`logs/performance.log`)
|
||||
Performance metrics and slow request tracking.
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:34:00.456Z",
|
||||
"level": "INFO",
|
||||
"message": "Performance metric: transcribe_audio",
|
||||
"metric": "transcribe_audio",
|
||||
"duration_ms": 2500,
|
||||
"function": "transcribe",
|
||||
"module": "app",
|
||||
"request_id": "1234567890-abcdef"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
export LOG_LEVEL=INFO
|
||||
|
||||
# Log file paths
|
||||
export LOG_FILE=logs/talk2me.log
|
||||
export ERROR_LOG_FILE=logs/errors.log
|
||||
|
||||
# Log rotation settings
|
||||
export LOG_MAX_BYTES=52428800 # 50MB
|
||||
export LOG_BACKUP_COUNT=10 # Keep 10 backup files
|
||||
|
||||
# Environment
|
||||
export FLASK_ENV=production
|
||||
```
|
||||
|
||||
### Flask Configuration
|
||||
|
||||
```python
|
||||
app.config.update({
|
||||
'LOG_LEVEL': 'INFO',
|
||||
'LOG_FILE': 'logs/talk2me.log',
|
||||
'ERROR_LOG_FILE': 'logs/errors.log',
|
||||
'LOG_MAX_BYTES': 50 * 1024 * 1024,
|
||||
'LOG_BACKUP_COUNT': 10
|
||||
})
|
||||
```
|
||||
|
||||
## Admin API Endpoints
|
||||
|
||||
### GET /admin/logs/errors
|
||||
View recent error logs and error frequency statistics.
|
||||
|
||||
```bash
|
||||
curl -H "X-Admin-Token: your-token" http://localhost:5005/admin/logs/errors
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"error_summary": {
|
||||
"abc123def456": {
|
||||
"count_last_hour": 5,
|
||||
"last_seen": 1705320000
|
||||
}
|
||||
},
|
||||
"recent_errors": [...],
|
||||
"total_errors_logged": 150
|
||||
}
|
||||
```
|
||||
|
||||
### GET /admin/logs/performance
|
||||
View performance metrics and slow requests.
|
||||
|
||||
```bash
|
||||
curl -H "X-Admin-Token: your-token" http://localhost:5005/admin/logs/performance
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"performance_metrics": {
|
||||
"transcribe_audio": {
|
||||
"avg_ms": 850.5,
|
||||
"max_ms": 3200,
|
||||
"min_ms": 125,
|
||||
"count": 1024
|
||||
}
|
||||
},
|
||||
"slow_requests": [
|
||||
{
|
||||
"metric": "transcribe_audio",
|
||||
"duration_ms": 3200,
|
||||
"timestamp": "2024-01-15T10:35:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /admin/logs/security
|
||||
View security events and suspicious activities.
|
||||
|
||||
```bash
|
||||
curl -H "X-Admin-Token: your-token" http://localhost:5005/admin/logs/security
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"security_events": [...],
|
||||
"event_summary": {
|
||||
"rate_limit_exceeded": 25,
|
||||
"suspicious_error": 3,
|
||||
"high_error_rate": 1
|
||||
},
|
||||
"total_events": 29
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### 1. Logging Errors with Context
|
||||
|
||||
```python
|
||||
from error_logger import log_exception
|
||||
|
||||
try:
|
||||
# Some operation
|
||||
process_audio(file)
|
||||
except Exception as e:
|
||||
log_exception(
|
||||
e,
|
||||
message="Failed to process audio",
|
||||
user_id=user.id,
|
||||
file_size=file.size,
|
||||
file_type=file.content_type
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Performance Monitoring
|
||||
|
||||
```python
|
||||
from error_logger import log_performance
|
||||
|
||||
@log_performance('expensive_operation')
|
||||
def process_large_file(file):
|
||||
# This will automatically log execution time
|
||||
return processed_data
|
||||
```
|
||||
|
||||
### 3. Security Event Logging
|
||||
|
||||
```python
|
||||
app.error_logger.log_security(
|
||||
'unauthorized_access',
|
||||
severity='warning',
|
||||
ip=request.remote_addr,
|
||||
attempted_resource='/admin',
|
||||
user_agent=request.headers.get('User-Agent')
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Request Context
|
||||
|
||||
```python
|
||||
from error_logger import log_context
|
||||
|
||||
with log_context(user_id=user.id, feature='translation'):
|
||||
# All logs within this context will include user_id and feature
|
||||
translate_text(text)
|
||||
```
|
||||
|
||||
## Log Analysis
|
||||
|
||||
### Finding Specific Errors
|
||||
|
||||
```bash
|
||||
# Find all authentication errors
|
||||
grep '"error_type":"AuthenticationError"' logs/errors.log | jq .
|
||||
|
||||
# Find errors from specific IP
|
||||
grep '"ip":"192.168.1.100"' logs/errors.log | jq .
|
||||
|
||||
# Find errors in last hour
|
||||
grep "$(date -u -d '1 hour ago' +%Y-%m-%dT%H)" logs/errors.log | jq .
|
||||
```
|
||||
|
||||
### Performance Analysis
|
||||
|
||||
```bash
|
||||
# Find slow requests (>2000ms)
|
||||
jq 'select(.extra_fields.duration_ms > 2000)' logs/performance.log
|
||||
|
||||
# Calculate average response time for endpoint
|
||||
jq 'select(.extra_fields.metric == "transcribe_audio") | .extra_fields.duration_ms' logs/performance.log | awk '{sum+=$1; count++} END {print sum/count}'
|
||||
```
|
||||
|
||||
### Security Monitoring
|
||||
|
||||
```bash
|
||||
# Count security events by type
|
||||
jq '.extra_fields.event' logs/security.log | sort | uniq -c
|
||||
|
||||
# Find all blocked IPs
|
||||
jq 'select(.extra_fields.blocked == true) | .extra_fields.ip' logs/security.log | sort -u
|
||||
```
|
||||
|
||||
## Log Rotation
|
||||
|
||||
Logs are automatically rotated based on size or time:
|
||||
|
||||
- **Application/Error logs**: Rotate at 50MB, keep 10 backups
|
||||
- **Access logs**: Daily rotation, keep 30 days
|
||||
- **Performance logs**: Hourly rotation, keep 7 days
|
||||
- **Security logs**: Rotate at 50MB, keep 10 backups
|
||||
|
||||
Rotated logs are named with numeric suffixes:
|
||||
- `talk2me.log` (current)
|
||||
- `talk2me.log.1` (most recent backup)
|
||||
- `talk2me.log.2` (older backup)
|
||||
- etc.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Structured Logging
|
||||
|
||||
Always include relevant context:
|
||||
```python
|
||||
logger.info("User action completed", extra={
|
||||
'extra_fields': {
|
||||
'user_id': user.id,
|
||||
'action': 'upload_audio',
|
||||
'file_size': file.size,
|
||||
'duration_ms': processing_time
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Error Handling
|
||||
|
||||
Log errors at appropriate levels:
|
||||
```python
|
||||
try:
|
||||
result = risky_operation()
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Validation failed: {e}") # Expected errors
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}", exc_info=True) # Unexpected errors
|
||||
```
|
||||
|
||||
### 3. Performance Tracking
|
||||
|
||||
Track key operations:
|
||||
```python
|
||||
start = time.time()
|
||||
result = expensive_operation()
|
||||
duration = (time.time() - start) * 1000
|
||||
|
||||
app.error_logger.log_performance(
|
||||
'expensive_operation',
|
||||
value=duration,
|
||||
input_size=len(data),
|
||||
output_size=len(result)
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Security Awareness
|
||||
|
||||
Log security-relevant events:
|
||||
```python
|
||||
if failed_attempts > 3:
|
||||
app.error_logger.log_security(
|
||||
'multiple_failed_attempts',
|
||||
severity='warning',
|
||||
ip=request.remote_addr,
|
||||
attempts=failed_attempts
|
||||
)
|
||||
```
|
||||
|
||||
## Monitoring Integration
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
Export log metrics for Prometheus:
|
||||
```python
|
||||
@app.route('/metrics')
|
||||
def prometheus_metrics():
|
||||
error_summary = app.error_logger.get_error_summary()
|
||||
# Format as Prometheus metrics
|
||||
return format_prometheus_metrics(error_summary)
|
||||
```
|
||||
|
||||
### ELK Stack
|
||||
|
||||
Ship logs to Elasticsearch:
|
||||
```yaml
|
||||
filebeat.inputs:
|
||||
- type: log
|
||||
paths:
|
||||
- /app/logs/*.log
|
||||
json.keys_under_root: true
|
||||
json.add_error_key: true
|
||||
```
|
||||
|
||||
### CloudWatch
|
||||
|
||||
For AWS deployments:
|
||||
```python
|
||||
# Install boto3 and watchtower
|
||||
import watchtower
|
||||
cloudwatch_handler = watchtower.CloudWatchLogHandler()
|
||||
logger.addHandler(cloudwatch_handler)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Logs Not Being Written
|
||||
|
||||
Check permissions:
|
||||
```bash
|
||||
ls -la logs/
|
||||
# Should show write permissions for app user
|
||||
```
|
||||
|
||||
Create logs directory:
|
||||
```bash
|
||||
mkdir -p logs
|
||||
chmod 755 logs
|
||||
```
|
||||
|
||||
#### 2. Disk Space Issues
|
||||
|
||||
Monitor log sizes:
|
||||
```bash
|
||||
du -sh logs/*
|
||||
```
|
||||
|
||||
Force rotation:
|
||||
```bash
|
||||
# Manually rotate logs
|
||||
mv logs/talk2me.log logs/talk2me.log.backup
|
||||
# App will create new log file
|
||||
```
|
||||
|
||||
#### 3. Performance Impact
|
||||
|
||||
If logging impacts performance:
|
||||
- Increase LOG_LEVEL to WARNING or ERROR
|
||||
- Reduce backup count
|
||||
- Use asynchronous logging (future enhancement)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Log Sanitization**: Sensitive data is automatically masked
|
||||
2. **Access Control**: Admin endpoints require authentication
|
||||
3. **Log Retention**: Old logs are automatically deleted
|
||||
4. **Encryption**: Consider encrypting logs at rest in production
|
||||
5. **Audit Trail**: All log access is itself logged
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Centralized Logging**: Ship logs to centralized service
|
||||
2. **Real-time Alerts**: Trigger alerts on error patterns
|
||||
3. **Log Analytics**: Built-in log analysis dashboard
|
||||
4. **Correlation IDs**: Track requests across microservices
|
||||
5. **Async Logging**: Reduce performance impact
|
||||
@@ -1,68 +0,0 @@
|
||||
# GPU Support for Talk2Me
|
||||
|
||||
## Current GPU Support Status
|
||||
|
||||
### ✅ NVIDIA GPUs (Full Support)
|
||||
- **Requirements**: CUDA 11.x or 12.x
|
||||
- **Optimizations**:
|
||||
- TensorFloat-32 (TF32) for Ampere GPUs (RTX 30xx, A100)
|
||||
- cuDNN auto-tuning
|
||||
- Half-precision (FP16) inference
|
||||
- CUDA kernel pre-caching
|
||||
- Memory pre-allocation
|
||||
|
||||
### ⚠️ AMD GPUs (Limited Support)
|
||||
- **Requirements**: ROCm 5.x installation
|
||||
- **Status**: Falls back to CPU unless ROCm is properly configured
|
||||
- **To enable AMD GPU**:
|
||||
```bash
|
||||
# Install PyTorch with ROCm support
|
||||
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm5.6
|
||||
```
|
||||
- **Limitations**:
|
||||
- No cuDNN optimizations
|
||||
- May have compatibility issues
|
||||
- Performance varies by GPU model
|
||||
|
||||
### ✅ Apple Silicon (M1/M2/M3)
|
||||
- **Requirements**: macOS 12.3+
|
||||
- **Status**: Uses Metal Performance Shaders (MPS)
|
||||
- **Optimizations**:
|
||||
- Native Metal acceleration
|
||||
- Unified memory architecture benefits
|
||||
- No FP16 (not well supported on MPS yet)
|
||||
|
||||
### 📊 Performance Comparison
|
||||
|
||||
| GPU Type | First Transcription | Subsequent | Notes |
|
||||
|----------|-------------------|------------|-------|
|
||||
| NVIDIA RTX 3080 | ~2s | ~0.5s | Full optimizations |
|
||||
| AMD RX 6800 XT | ~3-4s | ~1-2s | With ROCm |
|
||||
| Apple M2 | ~2.5s | ~1s | MPS acceleration |
|
||||
| CPU (i7-12700K) | ~5-10s | ~5-10s | No acceleration |
|
||||
|
||||
## Checking Your GPU Status
|
||||
|
||||
Run the app and check the logs:
|
||||
```
|
||||
INFO: NVIDIA GPU detected - using CUDA acceleration
|
||||
INFO: GPU memory allocated: 542.00 MB
|
||||
INFO: Whisper model loaded and optimized for NVIDIA GPU
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### AMD GPU Not Detected
|
||||
1. Install ROCm-compatible PyTorch
|
||||
2. Set environment variable: `export HSA_OVERRIDE_GFX_VERSION=10.3.0`
|
||||
3. Check with: `rocm-smi`
|
||||
|
||||
### NVIDIA GPU Not Used
|
||||
1. Check CUDA installation: `nvidia-smi`
|
||||
2. Verify PyTorch CUDA: `python -c "import torch; print(torch.cuda.is_available())"`
|
||||
3. Install CUDA toolkit if needed
|
||||
|
||||
### Apple Silicon Not Accelerated
|
||||
1. Update macOS to 12.3+
|
||||
2. Update PyTorch: `pip install --upgrade torch`
|
||||
3. Check MPS: `python -c "import torch; print(torch.backends.mps.is_available())"`
|
||||
@@ -1,285 +0,0 @@
|
||||
# Memory Management Documentation
|
||||
|
||||
This document describes the comprehensive memory management system implemented in Talk2Me to prevent memory leaks and crashes after extended use.
|
||||
|
||||
## Overview
|
||||
|
||||
Talk2Me implements a dual-layer memory management system:
|
||||
1. **Backend (Python)**: Manages GPU memory, Whisper model, and temporary files
|
||||
2. **Frontend (JavaScript)**: Manages audio blobs, object URLs, and Web Audio contexts
|
||||
|
||||
## Memory Leak Issues Addressed
|
||||
|
||||
### Backend Memory Leaks
|
||||
|
||||
1. **GPU Memory Fragmentation**
|
||||
- Whisper model accumulates GPU memory over time
|
||||
- Solution: Periodic GPU cache clearing and model reloading
|
||||
|
||||
2. **Temporary File Accumulation**
|
||||
- Audio files not cleaned up quickly enough under load
|
||||
- Solution: Aggressive cleanup with tracking and periodic sweeps
|
||||
|
||||
3. **Session Resource Leaks**
|
||||
- Long-lived sessions accumulate resources
|
||||
- Solution: Integration with session manager for resource limits
|
||||
|
||||
### Frontend Memory Leaks
|
||||
|
||||
1. **Audio Blob Leaks**
|
||||
- MediaRecorder chunks kept in memory
|
||||
- Solution: SafeMediaRecorder wrapper with automatic cleanup
|
||||
|
||||
2. **Object URL Leaks**
|
||||
- URLs created but not revoked
|
||||
- Solution: Centralized tracking and automatic revocation
|
||||
|
||||
3. **AudioContext Leaks**
|
||||
- Contexts created but never closed
|
||||
- Solution: MemoryManager tracks and closes contexts
|
||||
|
||||
4. **MediaStream Leaks**
|
||||
- Microphone streams not properly stopped
|
||||
- Solution: Automatic track stopping and stream cleanup
|
||||
|
||||
## Backend Memory Management
|
||||
|
||||
### MemoryManager Class
|
||||
|
||||
The `MemoryManager` monitors and manages memory usage:
|
||||
|
||||
```python
|
||||
memory_manager = MemoryManager(app, {
|
||||
'memory_threshold_mb': 4096, # 4GB process memory limit
|
||||
'gpu_memory_threshold_mb': 2048, # 2GB GPU memory limit
|
||||
'cleanup_interval': 30 # Check every 30 seconds
|
||||
})
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
1. **Automatic Monitoring**
|
||||
- Background thread checks memory usage
|
||||
- Triggers cleanup when thresholds exceeded
|
||||
- Logs statistics every 5 minutes
|
||||
|
||||
2. **GPU Memory Management**
|
||||
- Clears CUDA cache after each operation
|
||||
- Reloads Whisper model if fragmentation detected
|
||||
- Tracks reload count and timing
|
||||
|
||||
3. **Temporary File Cleanup**
|
||||
- Tracks all temporary files
|
||||
- Age-based cleanup (5 minutes normal, 1 minute aggressive)
|
||||
- Cleanup on process exit
|
||||
|
||||
4. **Context Managers**
|
||||
```python
|
||||
with AudioProcessingContext(memory_manager) as ctx:
|
||||
# Process audio
|
||||
ctx.add_temp_file(temp_path)
|
||||
# Files automatically cleaned up
|
||||
```
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
- `GET /admin/memory` - View current memory statistics
|
||||
- `POST /admin/memory/cleanup` - Trigger manual cleanup
|
||||
|
||||
## Frontend Memory Management
|
||||
|
||||
### MemoryManager Class
|
||||
|
||||
Centralized tracking of all browser resources:
|
||||
|
||||
```typescript
|
||||
const memoryManager = MemoryManager.getInstance();
|
||||
|
||||
// Register resources
|
||||
memoryManager.registerAudioContext(context);
|
||||
memoryManager.registerObjectURL(url);
|
||||
memoryManager.registerMediaStream(stream);
|
||||
```
|
||||
|
||||
### SafeMediaRecorder
|
||||
|
||||
Wrapper for MediaRecorder with automatic cleanup:
|
||||
|
||||
```typescript
|
||||
const recorder = new SafeMediaRecorder();
|
||||
await recorder.start(constraints);
|
||||
// Recording...
|
||||
const blob = await recorder.stop(); // Automatically cleans up
|
||||
```
|
||||
|
||||
### AudioBlobHandler
|
||||
|
||||
Safe handling of audio blobs and object URLs:
|
||||
|
||||
```typescript
|
||||
const handler = new AudioBlobHandler(blob);
|
||||
const url = handler.getObjectURL(); // Tracked automatically
|
||||
// Use URL...
|
||||
handler.cleanup(); // Revokes URL and clears references
|
||||
```
|
||||
|
||||
## Memory Thresholds
|
||||
|
||||
### Backend Thresholds
|
||||
|
||||
| Resource | Default Limit | Configurable Via |
|
||||
|----------|--------------|------------------|
|
||||
| Process Memory | 4096 MB | MEMORY_THRESHOLD_MB |
|
||||
| GPU Memory | 2048 MB | GPU_MEMORY_THRESHOLD_MB |
|
||||
| Temp File Age | 300 seconds | Built-in |
|
||||
| Model Reload Interval | 300 seconds | Built-in |
|
||||
|
||||
### Frontend Thresholds
|
||||
|
||||
| Resource | Cleanup Trigger |
|
||||
|----------|----------------|
|
||||
| Closed AudioContexts | Every 30 seconds |
|
||||
| Stopped MediaStreams | Every 30 seconds |
|
||||
| Orphaned Object URLs | On navigation/unload |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Use Context Managers**
|
||||
```python
|
||||
@with_memory_management
|
||||
def process_audio():
|
||||
# Automatic cleanup
|
||||
```
|
||||
|
||||
2. **Register Temporary Files**
|
||||
```python
|
||||
register_temp_file(path)
|
||||
ctx.add_temp_file(path)
|
||||
```
|
||||
|
||||
3. **Clear GPU Memory**
|
||||
```python
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.synchronize()
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **Use Safe Wrappers**
|
||||
```typescript
|
||||
// Don't use raw MediaRecorder
|
||||
const recorder = new SafeMediaRecorder();
|
||||
```
|
||||
|
||||
2. **Clean Up Handlers**
|
||||
```typescript
|
||||
if (audioHandler) {
|
||||
audioHandler.cleanup();
|
||||
}
|
||||
```
|
||||
|
||||
3. **Register All Resources**
|
||||
```typescript
|
||||
const context = new AudioContext();
|
||||
memoryManager.registerAudioContext(context);
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Backend Monitoring
|
||||
|
||||
```bash
|
||||
# View memory stats
|
||||
curl -H "X-Admin-Token: token" http://localhost:5005/admin/memory
|
||||
|
||||
# Response
|
||||
{
|
||||
"memory": {
|
||||
"process_mb": 850.5,
|
||||
"system_percent": 45.2,
|
||||
"gpu_mb": 1250.0,
|
||||
"gpu_percent": 61.0
|
||||
},
|
||||
"temp_files": {
|
||||
"count": 5,
|
||||
"size_mb": 12.5
|
||||
},
|
||||
"model": {
|
||||
"reload_count": 2,
|
||||
"last_reload": "2024-01-15T10:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Monitoring
|
||||
|
||||
```javascript
|
||||
// Get memory stats
|
||||
const stats = memoryManager.getStats();
|
||||
console.log('Active contexts:', stats.audioContexts);
|
||||
console.log('Object URLs:', stats.objectURLs);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
1. **Check Current Usage**
|
||||
```bash
|
||||
curl -H "X-Admin-Token: token" http://localhost:5005/admin/memory
|
||||
```
|
||||
|
||||
2. **Trigger Manual Cleanup**
|
||||
```bash
|
||||
curl -X POST -H "X-Admin-Token: token" \
|
||||
http://localhost:5005/admin/memory/cleanup
|
||||
```
|
||||
|
||||
3. **Check Logs**
|
||||
```bash
|
||||
grep "Memory" logs/talk2me.log
|
||||
grep "GPU memory" logs/talk2me.log
|
||||
```
|
||||
|
||||
### Memory Leak Symptoms
|
||||
|
||||
1. **Backend**
|
||||
- Process memory continuously increasing
|
||||
- GPU memory not returning to baseline
|
||||
- Temp files accumulating in upload folder
|
||||
- Slower transcription over time
|
||||
|
||||
2. **Frontend**
|
||||
- Browser tab memory increasing
|
||||
- Page becoming unresponsive
|
||||
- Audio playback issues
|
||||
- Console errors about contexts
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
```python
|
||||
# Backend
|
||||
app.config['DEBUG_MEMORY'] = True
|
||||
|
||||
# Frontend (in console)
|
||||
localStorage.setItem('DEBUG_MEMORY', 'true');
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
Memory management adds minimal overhead:
|
||||
- Backend: ~30ms per cleanup cycle
|
||||
- Frontend: <5ms per resource registration
|
||||
- Cleanup operations are non-blocking
|
||||
- Model reloading takes ~2-3 seconds (rare)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Predictive Cleanup**: Clean resources based on usage patterns
|
||||
2. **Memory Pooling**: Reuse audio buffers and contexts
|
||||
3. **Distributed Memory**: Share memory stats across instances
|
||||
4. **Alert System**: Notify admins of memory issues
|
||||
5. **Auto-scaling**: Scale resources based on memory pressure
|
||||
@@ -1,435 +0,0 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide covers deploying Talk2Me in a production environment using a proper WSGI server.
|
||||
|
||||
## Overview
|
||||
|
||||
The Flask development server is not suitable for production use. This guide covers:
|
||||
- Gunicorn as the WSGI server
|
||||
- Nginx as a reverse proxy
|
||||
- Docker for containerization
|
||||
- Systemd for process management
|
||||
- Security best practices
|
||||
|
||||
## Quick Start with Docker
|
||||
|
||||
### 1. Using Docker Compose
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/your-repo/talk2me.git
|
||||
cd talk2me
|
||||
|
||||
# Create .env file with production settings
|
||||
cat > .env <<EOF
|
||||
TTS_API_KEY=your-api-key
|
||||
ADMIN_TOKEN=your-secure-admin-token
|
||||
SECRET_KEY=your-secure-secret-key
|
||||
POSTGRES_PASSWORD=your-secure-db-password
|
||||
EOF
|
||||
|
||||
# Build and start services
|
||||
docker-compose up -d
|
||||
|
||||
# Check status
|
||||
docker-compose ps
|
||||
docker-compose logs -f talk2me
|
||||
```
|
||||
|
||||
### 2. Using Docker (standalone)
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t talk2me .
|
||||
|
||||
# Run the container
|
||||
docker run -d \
|
||||
--name talk2me \
|
||||
-p 5005:5005 \
|
||||
-e TTS_API_KEY=your-api-key \
|
||||
-e ADMIN_TOKEN=your-secure-token \
|
||||
-e SECRET_KEY=your-secure-key \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
talk2me
|
||||
```
|
||||
|
||||
## Manual Deployment
|
||||
|
||||
### 1. System Requirements
|
||||
|
||||
- Ubuntu 20.04+ or similar Linux distribution
|
||||
- Python 3.8+
|
||||
- Nginx
|
||||
- Systemd
|
||||
- 4GB+ RAM recommended
|
||||
- GPU (optional, for faster transcription)
|
||||
|
||||
### 2. Installation
|
||||
|
||||
Run the deployment script as root:
|
||||
|
||||
```bash
|
||||
sudo ./deploy.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
# Install system dependencies
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-pip python3-venv nginx
|
||||
|
||||
# Create application user
|
||||
sudo useradd -m -s /bin/bash talk2me
|
||||
|
||||
# Create directories
|
||||
sudo mkdir -p /opt/talk2me /var/log/talk2me
|
||||
sudo chown talk2me:talk2me /opt/talk2me /var/log/talk2me
|
||||
|
||||
# Copy application files
|
||||
sudo cp -r . /opt/talk2me/
|
||||
sudo chown -R talk2me:talk2me /opt/talk2me
|
||||
|
||||
# Install Python dependencies
|
||||
sudo -u talk2me python3 -m venv /opt/talk2me/venv
|
||||
sudo -u talk2me /opt/talk2me/venv/bin/pip install -r requirements-prod.txt
|
||||
|
||||
# Configure and start services
|
||||
sudo cp talk2me.service /etc/systemd/system/
|
||||
sudo systemctl enable talk2me
|
||||
sudo systemctl start talk2me
|
||||
```
|
||||
|
||||
## Gunicorn Configuration
|
||||
|
||||
The `gunicorn_config.py` file contains production-ready settings:
|
||||
|
||||
### Worker Configuration
|
||||
|
||||
```python
|
||||
# Number of worker processes
|
||||
workers = multiprocessing.cpu_count() * 2 + 1
|
||||
|
||||
# Worker timeout (increased for audio processing)
|
||||
timeout = 120
|
||||
|
||||
# Restart workers periodically to prevent memory leaks
|
||||
max_requests = 1000
|
||||
max_requests_jitter = 50
|
||||
```
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
For different workloads:
|
||||
|
||||
```bash
|
||||
# CPU-bound (transcription heavy)
|
||||
export GUNICORN_WORKERS=8
|
||||
export GUNICORN_THREADS=1
|
||||
|
||||
# I/O-bound (many concurrent requests)
|
||||
export GUNICORN_WORKERS=4
|
||||
export GUNICORN_THREADS=4
|
||||
export GUNICORN_WORKER_CLASS=gthread
|
||||
|
||||
# Async (best concurrency)
|
||||
export GUNICORN_WORKER_CLASS=gevent
|
||||
export GUNICORN_WORKER_CONNECTIONS=1000
|
||||
```
|
||||
|
||||
## Nginx Configuration
|
||||
|
||||
### Basic Setup
|
||||
|
||||
The provided `nginx.conf` includes:
|
||||
- Reverse proxy to Gunicorn
|
||||
- Static file serving
|
||||
- WebSocket support
|
||||
- Security headers
|
||||
- Gzip compression
|
||||
|
||||
### SSL/TLS Setup
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# Strong SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required
|
||||
|
||||
```bash
|
||||
# Security
|
||||
SECRET_KEY=your-very-secure-secret-key
|
||||
ADMIN_TOKEN=your-admin-api-token
|
||||
|
||||
# TTS Configuration
|
||||
TTS_API_KEY=your-tts-api-key
|
||||
TTS_SERVER_URL=http://your-tts-server:5050/v1/audio/speech
|
||||
|
||||
# Flask
|
||||
FLASK_ENV=production
|
||||
```
|
||||
|
||||
### Optional
|
||||
|
||||
```bash
|
||||
# Performance
|
||||
GUNICORN_WORKERS=4
|
||||
GUNICORN_THREADS=2
|
||||
MEMORY_THRESHOLD_MB=4096
|
||||
GPU_MEMORY_THRESHOLD_MB=2048
|
||||
|
||||
# Database (for session storage)
|
||||
DATABASE_URL=postgresql://user:pass@localhost/talk2me
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN=your-sentry-dsn
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Basic health check
|
||||
curl http://localhost:5005/health
|
||||
|
||||
# Detailed health check
|
||||
curl http://localhost:5005/health/detailed
|
||||
|
||||
# Memory usage
|
||||
curl -H "X-Admin-Token: your-token" http://localhost:5005/admin/memory
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# Application logs
|
||||
tail -f /var/log/talk2me/talk2me.log
|
||||
|
||||
# Error logs
|
||||
tail -f /var/log/talk2me/errors.log
|
||||
|
||||
# Gunicorn logs
|
||||
journalctl -u talk2me -f
|
||||
|
||||
# Nginx logs
|
||||
tail -f /var/log/nginx/access.log
|
||||
tail -f /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
### Metrics
|
||||
|
||||
With Prometheus client installed:
|
||||
|
||||
```bash
|
||||
# Prometheus metrics endpoint
|
||||
curl http://localhost:5005/metrics
|
||||
```
|
||||
|
||||
## Scaling
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
For multiple servers:
|
||||
|
||||
1. Use Redis for session storage
|
||||
2. Use PostgreSQL for persistent data
|
||||
3. Load balance with Nginx:
|
||||
|
||||
```nginx
|
||||
upstream talk2me_backends {
|
||||
least_conn;
|
||||
server server1:5005 weight=1;
|
||||
server server2:5005 weight=1;
|
||||
server server3:5005 weight=1;
|
||||
}
|
||||
```
|
||||
|
||||
### Vertical Scaling
|
||||
|
||||
Adjust based on load:
|
||||
|
||||
```bash
|
||||
# High memory usage
|
||||
MEMORY_THRESHOLD_MB=8192
|
||||
GPU_MEMORY_THRESHOLD_MB=4096
|
||||
|
||||
# More workers
|
||||
GUNICORN_WORKERS=16
|
||||
GUNICORN_THREADS=4
|
||||
|
||||
# Larger file limits
|
||||
client_max_body_size 100M;
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Firewall
|
||||
|
||||
```bash
|
||||
# Allow only necessary ports
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw allow 22/tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
### File Permissions
|
||||
|
||||
```bash
|
||||
# Secure file permissions
|
||||
sudo chmod 750 /opt/talk2me
|
||||
sudo chmod 640 /opt/talk2me/.env
|
||||
sudo chmod 755 /opt/talk2me/static
|
||||
```
|
||||
|
||||
### AppArmor/SELinux
|
||||
|
||||
Create security profiles to restrict application access.
|
||||
|
||||
## Backup
|
||||
|
||||
### Database Backup
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
pg_dump talk2me > backup.sql
|
||||
|
||||
# Redis
|
||||
redis-cli BGSAVE
|
||||
```
|
||||
|
||||
### Application Backup
|
||||
|
||||
```bash
|
||||
# Backup application and logs
|
||||
tar -czf talk2me-backup.tar.gz \
|
||||
/opt/talk2me \
|
||||
/var/log/talk2me \
|
||||
/etc/systemd/system/talk2me.service \
|
||||
/etc/nginx/sites-available/talk2me
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service Won't Start
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl status talk2me
|
||||
|
||||
# Check logs
|
||||
journalctl -u talk2me -n 100
|
||||
|
||||
# Test configuration
|
||||
sudo -u talk2me /opt/talk2me/venv/bin/gunicorn --check-config wsgi:application
|
||||
```
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
```bash
|
||||
# Trigger cleanup
|
||||
curl -X POST -H "X-Admin-Token: token" http://localhost:5005/admin/memory/cleanup
|
||||
|
||||
# Restart workers
|
||||
systemctl reload talk2me
|
||||
```
|
||||
|
||||
### Slow Response Times
|
||||
|
||||
1. Check worker count
|
||||
2. Enable async workers
|
||||
3. Check GPU availability
|
||||
4. Review nginx buffering settings
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### 1. Enable GPU
|
||||
|
||||
Ensure CUDA/ROCm is properly installed:
|
||||
|
||||
```bash
|
||||
# Check GPU
|
||||
nvidia-smi # or rocm-smi
|
||||
|
||||
# Set in environment
|
||||
export CUDA_VISIBLE_DEVICES=0
|
||||
```
|
||||
|
||||
### 2. Optimize Workers
|
||||
|
||||
```python
|
||||
# For CPU-heavy workloads
|
||||
workers = cpu_count()
|
||||
threads = 1
|
||||
|
||||
# For I/O-heavy workloads
|
||||
workers = cpu_count() * 2
|
||||
threads = 4
|
||||
```
|
||||
|
||||
### 3. Enable Caching
|
||||
|
||||
Use Redis for caching translations:
|
||||
|
||||
```python
|
||||
CACHE_TYPE = 'redis'
|
||||
CACHE_REDIS_URL = 'redis://localhost:6379/0'
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Regular Tasks
|
||||
|
||||
1. **Log Rotation**: Configured automatically
|
||||
2. **Database Cleanup**: Run weekly
|
||||
3. **Model Updates**: Check for Whisper updates
|
||||
4. **Security Updates**: Keep dependencies updated
|
||||
|
||||
### Update Procedure
|
||||
|
||||
```bash
|
||||
# Backup first
|
||||
./backup.sh
|
||||
|
||||
# Update code
|
||||
git pull
|
||||
|
||||
# Update dependencies
|
||||
sudo -u talk2me /opt/talk2me/venv/bin/pip install -r requirements-prod.txt
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart talk2me
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
If deployment fails:
|
||||
|
||||
```bash
|
||||
# Stop service
|
||||
sudo systemctl stop talk2me
|
||||
|
||||
# Restore backup
|
||||
tar -xzf talk2me-backup.tar.gz -C /
|
||||
|
||||
# Restart service
|
||||
sudo systemctl start talk2me
|
||||
```
|
||||
235
RATE_LIMITING.md
235
RATE_LIMITING.md
@@ -1,235 +0,0 @@
|
||||
# Rate Limiting Documentation
|
||||
|
||||
This document describes the rate limiting implementation in Talk2Me to protect against DoS attacks and resource exhaustion.
|
||||
|
||||
## Overview
|
||||
|
||||
Talk2Me implements a comprehensive rate limiting system with:
|
||||
- Token bucket algorithm with sliding window
|
||||
- Per-endpoint configurable limits
|
||||
- IP-based blocking (temporary and permanent)
|
||||
- Global request limits
|
||||
- Concurrent request throttling
|
||||
- Request size validation
|
||||
|
||||
## Rate Limits by Endpoint
|
||||
|
||||
### Transcription (`/transcribe`)
|
||||
- **Per Minute**: 10 requests
|
||||
- **Per Hour**: 100 requests
|
||||
- **Burst Size**: 3 requests
|
||||
- **Max Request Size**: 10MB
|
||||
- **Token Refresh**: 1 token per 6 seconds
|
||||
|
||||
### Translation (`/translate`)
|
||||
- **Per Minute**: 20 requests
|
||||
- **Per Hour**: 300 requests
|
||||
- **Burst Size**: 5 requests
|
||||
- **Max Request Size**: 100KB
|
||||
- **Token Refresh**: 1 token per 3 seconds
|
||||
|
||||
### Streaming Translation (`/translate/stream`)
|
||||
- **Per Minute**: 10 requests
|
||||
- **Per Hour**: 150 requests
|
||||
- **Burst Size**: 3 requests
|
||||
- **Max Request Size**: 100KB
|
||||
- **Token Refresh**: 1 token per 6 seconds
|
||||
|
||||
### Text-to-Speech (`/speak`)
|
||||
- **Per Minute**: 15 requests
|
||||
- **Per Hour**: 200 requests
|
||||
- **Burst Size**: 3 requests
|
||||
- **Max Request Size**: 50KB
|
||||
- **Token Refresh**: 1 token per 4 seconds
|
||||
|
||||
### API Endpoints
|
||||
- Push notifications, error logging: Various limits (see code)
|
||||
|
||||
## Global Limits
|
||||
|
||||
- **Total Requests Per Minute**: 1,000 (across all endpoints)
|
||||
- **Total Requests Per Hour**: 10,000
|
||||
- **Concurrent Requests**: 50 maximum
|
||||
|
||||
## Rate Limiting Headers
|
||||
|
||||
Successful responses include:
|
||||
```
|
||||
X-RateLimit-Limit: 20
|
||||
X-RateLimit-Remaining: 15
|
||||
X-RateLimit-Reset: 1234567890
|
||||
```
|
||||
|
||||
Rate limited responses (429) include:
|
||||
```
|
||||
X-RateLimit-Limit: 20
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1234567890
|
||||
Retry-After: 60
|
||||
```
|
||||
|
||||
## Client Identification
|
||||
|
||||
Clients are identified by:
|
||||
- IP address (including X-Forwarded-For support)
|
||||
- User-Agent string
|
||||
- Combined hash for uniqueness
|
||||
|
||||
## Automatic Blocking
|
||||
|
||||
IPs are temporarily blocked for 1 hour if:
|
||||
- They exceed 100 requests per minute
|
||||
- They repeatedly hit rate limits
|
||||
- They exhibit suspicious patterns
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# No direct environment variables for rate limiting
|
||||
# Configured in code - can be extended to use env vars
|
||||
```
|
||||
|
||||
### Programmatic Configuration
|
||||
|
||||
Rate limits can be adjusted in `rate_limiter.py`:
|
||||
|
||||
```python
|
||||
self.endpoint_limits = {
|
||||
'/transcribe': {
|
||||
'requests_per_minute': 10,
|
||||
'requests_per_hour': 100,
|
||||
'burst_size': 3,
|
||||
'token_refresh_rate': 0.167,
|
||||
'max_request_size': 10 * 1024 * 1024 # 10MB
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Endpoints
|
||||
|
||||
### Get Rate Limit Configuration
|
||||
```bash
|
||||
curl -H "X-Admin-Token: your-admin-token" \
|
||||
http://localhost:5005/admin/rate-limits
|
||||
```
|
||||
|
||||
### Get Rate Limit Statistics
|
||||
```bash
|
||||
# Global stats
|
||||
curl -H "X-Admin-Token: your-admin-token" \
|
||||
http://localhost:5005/admin/rate-limits/stats
|
||||
|
||||
# Client-specific stats
|
||||
curl -H "X-Admin-Token: your-admin-token" \
|
||||
http://localhost:5005/admin/rate-limits/stats?client_id=abc123
|
||||
```
|
||||
|
||||
### Block IP Address
|
||||
```bash
|
||||
# Temporary block (1 hour)
|
||||
curl -X POST -H "X-Admin-Token: your-admin-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ip": "192.168.1.100", "duration": 3600}' \
|
||||
http://localhost:5005/admin/block-ip
|
||||
|
||||
# Permanent block
|
||||
curl -X POST -H "X-Admin-Token: your-admin-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ip": "192.168.1.100", "permanent": true}' \
|
||||
http://localhost:5005/admin/block-ip
|
||||
```
|
||||
|
||||
## Algorithm Details
|
||||
|
||||
### Token Bucket
|
||||
- Each client gets a bucket with configurable burst size
|
||||
- Tokens regenerate at a fixed rate
|
||||
- Requests consume tokens
|
||||
- Empty bucket = request denied
|
||||
|
||||
### Sliding Window
|
||||
- Tracks requests in the last minute and hour
|
||||
- More accurate than fixed windows
|
||||
- Prevents gaming the system at window boundaries
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Users
|
||||
1. Implement exponential backoff when receiving 429 errors
|
||||
2. Check rate limit headers to avoid hitting limits
|
||||
3. Cache responses when possible
|
||||
4. Use bulk operations where available
|
||||
|
||||
### For Administrators
|
||||
1. Monitor rate limit statistics regularly
|
||||
2. Adjust limits based on usage patterns
|
||||
3. Use IP blocking sparingly
|
||||
4. Set up alerts for suspicious activity
|
||||
|
||||
## Error Responses
|
||||
|
||||
### Rate Limited (429)
|
||||
```json
|
||||
{
|
||||
"error": "Rate limit exceeded (per minute)",
|
||||
"retry_after": 60
|
||||
}
|
||||
```
|
||||
|
||||
### Request Too Large (413)
|
||||
```json
|
||||
{
|
||||
"error": "Request too large"
|
||||
}
|
||||
```
|
||||
|
||||
### IP Blocked (429)
|
||||
```json
|
||||
{
|
||||
"error": "IP temporarily blocked due to excessive requests"
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
Key metrics to monitor:
|
||||
- Rate limit hits by endpoint
|
||||
- Blocked IPs
|
||||
- Concurrent request peaks
|
||||
- Request size violations
|
||||
- Global limit approaches
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- Minimal overhead (~1-2ms per request)
|
||||
- Memory usage scales with active clients
|
||||
- Automatic cleanup of old buckets
|
||||
- Thread-safe implementation
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **DoS Protection**: Prevents resource exhaustion
|
||||
2. **Burst Control**: Limits sudden traffic spikes
|
||||
3. **Size Validation**: Prevents large payload attacks
|
||||
4. **IP Blocking**: Stops persistent attackers
|
||||
5. **Global Limits**: Protects overall system capacity
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Rate limit exceeded" errors
|
||||
- Check client request patterns
|
||||
- Verify time synchronization
|
||||
- Look for retry loops
|
||||
- Check IP blocking status
|
||||
|
||||
### Memory usage increasing
|
||||
- Verify cleanup thread is running
|
||||
- Check for client ID explosion
|
||||
- Monitor bucket count
|
||||
|
||||
### Legitimate users blocked
|
||||
- Review rate limit settings
|
||||
- Check for shared IP issues
|
||||
- Implement IP whitelisting if needed
|
||||
895
README.md
895
README.md
@@ -1,9 +1,31 @@
|
||||
# Voice Language Translator
|
||||
# Talk2Me - Real-Time Voice Language Translator
|
||||
|
||||
A mobile-friendly web application that translates spoken language between multiple languages using:
|
||||
- Gemma 3 open-source LLM via Ollama for translation
|
||||
- OpenAI Whisper for speech-to-text
|
||||
- OpenAI Edge TTS for text-to-speech
|
||||
A production-ready, mobile-friendly web application that provides real-time translation of spoken language between multiple languages.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Speech Recognition**: Powered by OpenAI Whisper with GPU acceleration
|
||||
- **Advanced Translation**: Using Gemma 3 open-source LLM via Ollama
|
||||
- **Natural Text-to-Speech**: OpenAI Edge TTS for lifelike voice output
|
||||
- **Progressive Web App**: Full offline support with service workers
|
||||
- **Multi-Speaker Support**: Track and translate conversations with multiple participants
|
||||
- **Enterprise Security**: Comprehensive rate limiting, session management, and encrypted secrets
|
||||
- **Production Ready**: Docker support, load balancing, and extensive monitoring
|
||||
- **Admin Dashboard**: Real-time analytics, performance monitoring, and system health tracking
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Supported Languages](#supported-languages)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Security Features](#security-features)
|
||||
- [Production Deployment](#production-deployment)
|
||||
- [API Documentation](#api-documentation)
|
||||
- [Development](#development)
|
||||
- [Monitoring & Operations](#monitoring--operations)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## Supported Languages
|
||||
|
||||
@@ -22,68 +44,148 @@ A mobile-friendly web application that translates spoken language between multip
|
||||
- Turkish
|
||||
- Uzbek
|
||||
|
||||
## Setup Instructions
|
||||
## Quick Start
|
||||
|
||||
1. Install the required Python packages:
|
||||
```
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/talk2me.git
|
||||
cd talk2me
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
npm install
|
||||
|
||||
# Initialize secure configuration
|
||||
python manage_secrets.py init
|
||||
python manage_secrets.py set TTS_API_KEY your-api-key-here
|
||||
|
||||
# Ensure Ollama is running with Gemma
|
||||
ollama pull gemma2:9b
|
||||
ollama pull gemma3:27b
|
||||
|
||||
# Start the application
|
||||
python app.py
|
||||
```
|
||||
|
||||
Open your browser and navigate to `http://localhost:5005`
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.8+
|
||||
- Node.js 14+
|
||||
- Ollama (for LLM translation)
|
||||
- OpenAI Edge TTS server
|
||||
- Optional: NVIDIA GPU with CUDA, AMD GPU with ROCm, or Apple Silicon
|
||||
|
||||
### Detailed Setup
|
||||
|
||||
1. **Install Python dependencies**:
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Configure secrets and environment:
|
||||
2. **Install Node.js dependencies**:
|
||||
```bash
|
||||
# Initialize secure secrets management
|
||||
python manage_secrets.py init
|
||||
npm install
|
||||
npm run build # Build TypeScript files
|
||||
```
|
||||
|
||||
3. **Configure GPU Support** (Optional):
|
||||
```bash
|
||||
# For NVIDIA GPUs
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||
|
||||
# Set required secrets
|
||||
python manage_secrets.py set TTS_API_KEY
|
||||
# For AMD GPUs (ROCm)
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm5.4.2
|
||||
|
||||
# Or use traditional .env file
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
# For Apple Silicon
|
||||
pip install torch torchvision torchaudio
|
||||
```
|
||||
|
||||
4. **Set up Ollama**:
|
||||
```bash
|
||||
# Install Ollama (https://ollama.ai)
|
||||
curl -fsSL https://ollama.ai/install.sh | sh
|
||||
|
||||
**⚠️ Security Note**: Talk2Me includes encrypted secrets management. See [SECURITY.md](SECURITY.md) and [SECRETS_MANAGEMENT.md](SECRETS_MANAGEMENT.md) for details.
|
||||
|
||||
3. Make sure you have Ollama installed and the Gemma 3 model loaded:
|
||||
```
|
||||
ollama pull gemma3
|
||||
# Pull required models
|
||||
ollama pull gemma2:9b # Faster, for streaming
|
||||
ollama pull gemma3:27b # Better quality
|
||||
```
|
||||
|
||||
4. Ensure your OpenAI Edge TTS server is running on port 5050.
|
||||
5. **Configure TTS Server**:
|
||||
Ensure your OpenAI Edge TTS server is running. Default expected at `http://localhost:5050`
|
||||
|
||||
5. Run the application:
|
||||
```
|
||||
python app.py
|
||||
```
|
||||
## Configuration
|
||||
|
||||
6. Open your browser and navigate to:
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
### Environment Variables
|
||||
|
||||
## Usage
|
||||
Talk2Me uses encrypted secrets management for sensitive configuration. You can use either the secure secrets system or traditional environment variables.
|
||||
|
||||
1. Select your source language from the dropdown menu
|
||||
2. Press the microphone button and speak
|
||||
3. Press the button again to stop recording
|
||||
4. Wait for the transcription to complete
|
||||
5. Select your target language
|
||||
6. Press the "Translate" button
|
||||
7. Use the play buttons to hear the original or translated text
|
||||
#### Using Secure Secrets Management (Recommended)
|
||||
|
||||
## Technical Details
|
||||
```bash
|
||||
# Initialize the secrets system
|
||||
python manage_secrets.py init
|
||||
|
||||
- The app uses Flask for the web server
|
||||
- Audio is processed client-side using the MediaRecorder API
|
||||
- Whisper for speech recognition with language hints
|
||||
- Ollama provides access to the Gemma 3 model for translation
|
||||
- OpenAI Edge TTS delivers natural-sounding speech output
|
||||
# Set required secrets
|
||||
python manage_secrets.py set TTS_API_KEY
|
||||
python manage_secrets.py set TTS_SERVER_URL
|
||||
python manage_secrets.py set ADMIN_TOKEN
|
||||
|
||||
## CORS Configuration
|
||||
# List all secrets
|
||||
python manage_secrets.py list
|
||||
|
||||
The application supports Cross-Origin Resource Sharing (CORS) for secure cross-origin usage. See [CORS_CONFIG.md](CORS_CONFIG.md) for detailed configuration instructions.
|
||||
# Rotate encryption keys
|
||||
python manage_secrets.py rotate
|
||||
```
|
||||
|
||||
#### Using Environment Variables
|
||||
|
||||
Create a `.env` file by copying `.env.example`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Key environment variables:
|
||||
|
||||
```env
|
||||
# Core Configuration (REQUIRED for production)
|
||||
FLASK_SECRET_KEY=your-secret-key-here-change-in-production # IMPORTANT: Set this for production!
|
||||
TTS_API_KEY=your-api-key-here
|
||||
TTS_SERVER_URL=http://localhost:5050/v1/audio/speech
|
||||
ADMIN_TOKEN=your-secure-admin-token
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=https://yourdomain.com,https://app.yourdomain.com
|
||||
ADMIN_CORS_ORIGINS=https://admin.yourdomain.com
|
||||
|
||||
# Security Settings
|
||||
SECRET_KEY=your-secret-key-here
|
||||
MAX_CONTENT_LENGTH=52428800 # 50MB
|
||||
SESSION_LIFETIME=3600 # 1 hour
|
||||
RATE_LIMIT_STORAGE_URL=redis://localhost:6379/0
|
||||
|
||||
# Performance Tuning
|
||||
WHISPER_MODEL_SIZE=base
|
||||
GPU_MEMORY_THRESHOLD_MB=2048
|
||||
MEMORY_CLEANUP_INTERVAL=30
|
||||
```
|
||||
|
||||
**Important**: Always set `FLASK_SECRET_KEY` to a secure, random value in production. You can generate one using:
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
#### CORS Settings
|
||||
|
||||
Quick setup:
|
||||
```bash
|
||||
# Development (allow all origins)
|
||||
export CORS_ORIGINS="*"
|
||||
@@ -93,88 +195,679 @@ export CORS_ORIGINS="https://yourdomain.com,https://app.yourdomain.com"
|
||||
export ADMIN_CORS_ORIGINS="https://admin.yourdomain.com"
|
||||
```
|
||||
|
||||
## Connection Retry & Offline Support
|
||||
#### Rate Limiting
|
||||
|
||||
Talk2Me handles network interruptions gracefully with automatic retry logic:
|
||||
- Automatic request queuing during connection loss
|
||||
- Exponential backoff retry with configurable parameters
|
||||
- Visual connection status indicators
|
||||
- Priority-based request processing
|
||||
Configure per-endpoint rate limits:
|
||||
|
||||
See [CONNECTION_RETRY.md](CONNECTION_RETRY.md) for detailed documentation.
|
||||
```python
|
||||
# In your config or via admin API
|
||||
RATE_LIMITS = {
|
||||
'default': {'requests_per_minute': 30, 'requests_per_hour': 500},
|
||||
'transcribe': {'requests_per_minute': 10, 'requests_per_hour': 100},
|
||||
'translate': {'requests_per_minute': 20, 'requests_per_hour': 300}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
#### Session Management
|
||||
|
||||
Comprehensive rate limiting protects against DoS attacks and resource exhaustion:
|
||||
```python
|
||||
SESSION_CONFIG = {
|
||||
'max_file_size_mb': 100,
|
||||
'max_files_per_session': 100,
|
||||
'idle_timeout_minutes': 15,
|
||||
'max_lifetime_minutes': 60
|
||||
}
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
### 1. Rate Limiting
|
||||
|
||||
Comprehensive DoS protection with:
|
||||
- Token bucket algorithm with sliding window
|
||||
- Per-endpoint configurable limits
|
||||
- Automatic IP blocking for abusive clients
|
||||
- Global request limits and concurrent request throttling
|
||||
- Request size validation
|
||||
|
||||
See [RATE_LIMITING.md](RATE_LIMITING.md) for detailed documentation.
|
||||
```bash
|
||||
# Check rate limit status
|
||||
curl -H "X-Admin-Token: $ADMIN_TOKEN" http://localhost:5005/admin/rate-limits
|
||||
|
||||
## Session Management
|
||||
# Block an IP
|
||||
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ip": "192.168.1.100", "duration": 3600}' \
|
||||
http://localhost:5005/admin/block-ip
|
||||
```
|
||||
|
||||
Advanced session management prevents resource leaks from abandoned sessions:
|
||||
- Automatic tracking of all session resources (audio files, temp files)
|
||||
- Per-session resource limits (100 files, 100MB)
|
||||
- Automatic cleanup of idle sessions (15 minutes) and expired sessions (1 hour)
|
||||
- Real-time monitoring and metrics
|
||||
- Manual cleanup capabilities for administrators
|
||||
### 2. Secrets Management
|
||||
|
||||
See [SESSION_MANAGEMENT.md](SESSION_MANAGEMENT.md) for detailed documentation.
|
||||
- AES-128 encryption for sensitive data
|
||||
- Automatic key rotation
|
||||
- Audit logging
|
||||
- Platform-specific secure storage
|
||||
|
||||
## Request Size Limits
|
||||
```bash
|
||||
# View audit log
|
||||
python manage_secrets.py audit
|
||||
|
||||
Comprehensive request size limiting prevents memory exhaustion:
|
||||
- Global limit: 50MB for any request
|
||||
- Audio files: 25MB maximum
|
||||
- JSON payloads: 1MB maximum
|
||||
- File type detection and enforcement
|
||||
- Dynamic configuration via admin API
|
||||
# Backup secrets
|
||||
python manage_secrets.py export --output backup.enc
|
||||
|
||||
See [REQUEST_SIZE_LIMITS.md](REQUEST_SIZE_LIMITS.md) for detailed documentation.
|
||||
# Restore from backup
|
||||
python manage_secrets.py import --input backup.enc
|
||||
```
|
||||
|
||||
## Error Logging
|
||||
### 3. Session Management
|
||||
|
||||
Production-ready error logging system for debugging and monitoring:
|
||||
- Structured JSON logs for easy parsing
|
||||
- Multiple log streams (app, errors, access, security, performance)
|
||||
- Automatic log rotation to prevent disk exhaustion
|
||||
- Request tracing with unique IDs
|
||||
- Performance metrics and slow request tracking
|
||||
- Admin endpoints for log analysis
|
||||
- Automatic resource tracking
|
||||
- Per-session limits (100 files, 100MB)
|
||||
- Idle session cleanup (15 minutes)
|
||||
- Real-time monitoring
|
||||
|
||||
See [ERROR_LOGGING.md](ERROR_LOGGING.md) for detailed documentation.
|
||||
```bash
|
||||
# View active sessions
|
||||
curl -H "X-Admin-Token: $ADMIN_TOKEN" http://localhost:5005/admin/sessions
|
||||
|
||||
## Memory Management
|
||||
# Clean up specific session
|
||||
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" \
|
||||
http://localhost:5005/admin/sessions/SESSION_ID/cleanup
|
||||
```
|
||||
|
||||
Comprehensive memory leak prevention for extended use:
|
||||
- GPU memory management with automatic cleanup
|
||||
- Whisper model reloading to prevent fragmentation
|
||||
- Frontend resource tracking (audio blobs, contexts, streams)
|
||||
- Automatic cleanup of temporary files
|
||||
- Memory monitoring and manual cleanup endpoints
|
||||
### 4. Request Size Limits
|
||||
|
||||
See [MEMORY_MANAGEMENT.md](MEMORY_MANAGEMENT.md) for detailed documentation.
|
||||
- Global limit: 50MB
|
||||
- Audio files: 25MB
|
||||
- JSON payloads: 1MB
|
||||
- Dynamic configuration
|
||||
|
||||
```bash
|
||||
# Update size limits
|
||||
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"max_audio_size": "30MB"}' \
|
||||
http://localhost:5005/admin/size-limits
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production use, deploy with a proper WSGI server:
|
||||
- Gunicorn with optimized worker configuration
|
||||
- Nginx reverse proxy with caching
|
||||
- Docker/Docker Compose support
|
||||
- Systemd service management
|
||||
- Comprehensive security hardening
|
||||
### Docker Deployment
|
||||
|
||||
Quick start:
|
||||
```bash
|
||||
# Build and run with Docker Compose (CPU only)
|
||||
docker-compose up -d
|
||||
|
||||
# With NVIDIA GPU support
|
||||
docker-compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
|
||||
|
||||
# With AMD GPU support (ROCm)
|
||||
docker-compose -f docker-compose.yml -f docker-compose.amd.yml up -d
|
||||
|
||||
# With Apple Silicon support
|
||||
docker-compose -f docker-compose.yml -f docker-compose.apple.yml up -d
|
||||
|
||||
# Scale web workers
|
||||
docker-compose up -d --scale talk2me=4
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f talk2me
|
||||
```
|
||||
|
||||
See [PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md) for detailed deployment instructions.
|
||||
### Docker Compose Configuration
|
||||
|
||||
## Mobile Support
|
||||
Choose the appropriate configuration based on your GPU:
|
||||
|
||||
The interface is fully responsive and designed to work well on mobile devices.
|
||||
#### NVIDIA GPU Configuration
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "5005:5005"
|
||||
environment:
|
||||
- GUNICORN_WORKERS=4
|
||||
- GUNICORN_THREADS=2
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- whisper-cache:/root/.cache/whisper
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
```
|
||||
|
||||
#### AMD GPU Configuration (ROCm)
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "5005:5005"
|
||||
environment:
|
||||
- GUNICORN_WORKERS=4
|
||||
- GUNICORN_THREADS=2
|
||||
- HSA_OVERRIDE_GFX_VERSION=10.3.0 # Adjust for your GPU
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- whisper-cache:/root/.cache/whisper
|
||||
- /dev/kfd:/dev/kfd # ROCm KFD interface
|
||||
- /dev/dri:/dev/dri # Direct Rendering Interface
|
||||
devices:
|
||||
- /dev/kfd
|
||||
- /dev/dri
|
||||
group_add:
|
||||
- video
|
||||
- render
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
```
|
||||
|
||||
#### Apple Silicon Configuration
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
platform: linux/arm64/v8 # For M1/M2 Macs
|
||||
ports:
|
||||
- "5005:5005"
|
||||
environment:
|
||||
- GUNICORN_WORKERS=4
|
||||
- GUNICORN_THREADS=2
|
||||
- PYTORCH_ENABLE_MPS_FALLBACK=1 # Enable MPS fallback
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- whisper-cache:/root/.cache/whisper
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
```
|
||||
|
||||
#### CPU-Only Configuration
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "5005:5005"
|
||||
environment:
|
||||
- GUNICORN_WORKERS=4
|
||||
- GUNICORN_THREADS=2
|
||||
- OMP_NUM_THREADS=4 # OpenMP threads for CPU
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- whisper-cache:/root/.cache/whisper
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
cpus: '4.0'
|
||||
```
|
||||
|
||||
### Nginx Configuration
|
||||
|
||||
```nginx
|
||||
upstream talk2me {
|
||||
least_conn;
|
||||
server web1:5005 weight=1 max_fails=3 fail_timeout=30s;
|
||||
server web2:5005 weight=1 max_fails=3 fail_timeout=30s;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name talk2me.yourdomain.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/talk2me.crt;
|
||||
ssl_certificate_key /etc/ssl/private/talk2me.key;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://talk2me;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location /static/ {
|
||||
alias /app/static/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Talk2Me Translation Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
User=talk2me
|
||||
Group=talk2me
|
||||
WorkingDirectory=/opt/talk2me
|
||||
Environment="PATH=/opt/talk2me/venv/bin"
|
||||
ExecStart=/opt/talk2me/venv/bin/gunicorn \
|
||||
--config gunicorn_config.py \
|
||||
--bind 0.0.0.0:5005 \
|
||||
app:app
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Core Endpoints
|
||||
|
||||
#### Transcribe Audio
|
||||
```http
|
||||
POST /transcribe
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
audio: (binary)
|
||||
source_lang: auto|language_code
|
||||
```
|
||||
|
||||
#### Translate Text
|
||||
```http
|
||||
POST /translate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"text": "Hello world",
|
||||
"source_lang": "English",
|
||||
"target_lang": "Spanish"
|
||||
}
|
||||
```
|
||||
|
||||
#### Streaming Translation
|
||||
```http
|
||||
POST /translate/stream
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"text": "Long text to translate",
|
||||
"source_lang": "auto",
|
||||
"target_lang": "French"
|
||||
}
|
||||
|
||||
Response: Server-Sent Events stream
|
||||
```
|
||||
|
||||
#### Text-to-Speech
|
||||
```http
|
||||
POST /speak
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"text": "Hola mundo",
|
||||
"language": "Spanish"
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
All admin endpoints require `X-Admin-Token` header.
|
||||
|
||||
#### Health & Monitoring
|
||||
- `GET /health` - Basic health check
|
||||
- `GET /health/detailed` - Component status
|
||||
- `GET /metrics` - Prometheus metrics
|
||||
- `GET /admin/memory` - Memory usage stats
|
||||
|
||||
#### Session Management
|
||||
- `GET /admin/sessions` - List active sessions
|
||||
- `GET /admin/sessions/:id` - Session details
|
||||
- `POST /admin/sessions/:id/cleanup` - Manual cleanup
|
||||
|
||||
#### Security Controls
|
||||
- `GET /admin/rate-limits` - View rate limits
|
||||
- `POST /admin/block-ip` - Block IP address
|
||||
- `GET /admin/logs/security` - Security events
|
||||
|
||||
## Admin Dashboard
|
||||
|
||||
Talk2Me includes a comprehensive admin analytics dashboard for monitoring and managing the application.
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time Analytics**: Monitor requests, active sessions, and error rates
|
||||
- **Performance Metrics**: Track response times, throughput, and resource usage
|
||||
- **System Health**: Monitor Redis, PostgreSQL, and ML services status
|
||||
- **Language Analytics**: View popular language pairs and usage patterns
|
||||
- **Error Analysis**: Detailed error tracking with types and trends
|
||||
- **Data Export**: Download analytics data in JSON format
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Initialize Database**:
|
||||
```bash
|
||||
python init_analytics_db.py
|
||||
```
|
||||
|
||||
2. **Configure Admin Token**:
|
||||
```bash
|
||||
export ADMIN_TOKEN="your-secure-admin-token"
|
||||
```
|
||||
|
||||
3. **Access Dashboard**:
|
||||
- Navigate to `https://yourdomain.com/admin`
|
||||
- Enter your admin token
|
||||
- View real-time analytics
|
||||
|
||||
### Dashboard Sections
|
||||
|
||||
- **Overview Cards**: Key metrics at a glance
|
||||
- **Request Volume**: Visualize traffic patterns
|
||||
- **Operations**: Translation and transcription statistics
|
||||
- **Performance**: Response time percentiles (P95, P99)
|
||||
- **Error Tracking**: Error types and recent issues
|
||||
- **System Health**: Component status monitoring
|
||||
|
||||
For detailed admin documentation, see [ADMIN_DASHBOARD.md](ADMIN_DASHBOARD.md).
|
||||
|
||||
## Development
|
||||
|
||||
### TypeScript Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Development mode with auto-compilation
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Type checking
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
talk2me/
|
||||
├── app.py # Main Flask application
|
||||
├── config.py # Configuration management
|
||||
├── requirements.txt # Python dependencies
|
||||
├── package.json # Node.js dependencies
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── gunicorn_config.py # Production server config
|
||||
├── docker-compose.yml # Container orchestration
|
||||
├── static/
|
||||
│ ├── js/
|
||||
│ │ ├── src/ # TypeScript source files
|
||||
│ │ └── dist/ # Compiled JavaScript
|
||||
│ ├── css/ # Stylesheets
|
||||
│ └── icons/ # PWA icons
|
||||
├── templates/ # HTML templates
|
||||
├── logs/ # Application logs
|
||||
└── tests/ # Test suite
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Connection Management** (`connectionManager.ts`)
|
||||
- Automatic retry with exponential backoff
|
||||
- Request queuing during offline periods
|
||||
- Connection status monitoring
|
||||
|
||||
2. **Translation Cache** (`translationCache.ts`)
|
||||
- IndexedDB for offline support
|
||||
- LRU eviction policy
|
||||
- Automatic cache size management
|
||||
|
||||
3. **Speaker Management** (`speakerManager.ts`)
|
||||
- Multi-speaker conversation tracking
|
||||
- Speaker-specific audio handling
|
||||
- Conversation export functionality
|
||||
|
||||
4. **Error Handling** (`errorBoundary.ts`)
|
||||
- Global error catching
|
||||
- Automatic error reporting
|
||||
- User-friendly error messages
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Python tests
|
||||
pytest tests/ -v
|
||||
|
||||
# TypeScript tests
|
||||
npm test
|
||||
|
||||
# Integration tests
|
||||
python test_integration.py
|
||||
```
|
||||
|
||||
## Monitoring & Operations
|
||||
|
||||
### Logging System
|
||||
|
||||
Talk2Me uses structured JSON logging with multiple streams:
|
||||
|
||||
```bash
|
||||
logs/
|
||||
├── talk2me.log # General application log
|
||||
├── errors.log # Error-specific log
|
||||
├── access.log # HTTP access log
|
||||
├── security.log # Security events
|
||||
└── performance.log # Performance metrics
|
||||
```
|
||||
|
||||
View logs:
|
||||
```bash
|
||||
# Recent errors
|
||||
tail -f logs/errors.log | jq '.'
|
||||
|
||||
# Security events
|
||||
grep "rate_limit_exceeded" logs/security.log | jq '.'
|
||||
|
||||
# Slow requests
|
||||
jq 'select(.extra_fields.duration_ms > 1000)' logs/performance.log
|
||||
```
|
||||
|
||||
### Memory Management
|
||||
|
||||
Talk2Me includes comprehensive memory leak prevention:
|
||||
|
||||
1. **Backend Memory Management**
|
||||
- GPU memory monitoring
|
||||
- Automatic model reloading
|
||||
- Temporary file cleanup
|
||||
|
||||
2. **Frontend Memory Management**
|
||||
- Audio blob cleanup
|
||||
- WebRTC resource management
|
||||
- Event listener cleanup
|
||||
|
||||
Monitor memory:
|
||||
```bash
|
||||
# Check memory stats
|
||||
curl -H "X-Admin-Token: $ADMIN_TOKEN" http://localhost:5005/admin/memory
|
||||
|
||||
# Trigger manual cleanup
|
||||
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" \
|
||||
http://localhost:5005/admin/memory/cleanup
|
||||
```
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
#### GPU Optimization
|
||||
|
||||
```python
|
||||
# config.py or environment
|
||||
GPU_OPTIMIZATIONS = {
|
||||
'enabled': True,
|
||||
'fp16': True, # Half precision for 2x speedup
|
||||
'batch_size': 1, # Adjust based on GPU memory
|
||||
'num_workers': 2, # Parallel data loading
|
||||
'pin_memory': True # Faster GPU transfer
|
||||
}
|
||||
```
|
||||
|
||||
#### Whisper Optimization
|
||||
|
||||
```python
|
||||
TRANSCRIBE_OPTIONS = {
|
||||
'beam_size': 1, # Faster inference
|
||||
'best_of': 1, # Disable multiple attempts
|
||||
'temperature': 0, # Deterministic output
|
||||
'compression_ratio_threshold': 2.4,
|
||||
'logprob_threshold': -1.0,
|
||||
'no_speech_threshold': 0.6
|
||||
}
|
||||
```
|
||||
|
||||
### Scaling Considerations
|
||||
|
||||
1. **Horizontal Scaling**
|
||||
- Use Redis for shared rate limiting
|
||||
- Configure sticky sessions for WebSocket
|
||||
- Share audio files via object storage
|
||||
|
||||
2. **Vertical Scaling**
|
||||
- Increase worker processes
|
||||
- Tune thread pool size
|
||||
- Allocate more GPU memory
|
||||
|
||||
3. **Caching Strategy**
|
||||
- Cache translations in Redis
|
||||
- Use CDN for static assets
|
||||
- Enable HTTP caching headers
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### GPU Not Detected
|
||||
|
||||
```bash
|
||||
# Check CUDA availability
|
||||
python -c "import torch; print(torch.cuda.is_available())"
|
||||
|
||||
# Check GPU memory
|
||||
nvidia-smi
|
||||
|
||||
# For AMD GPUs
|
||||
rocm-smi
|
||||
|
||||
# For Apple Silicon
|
||||
python -c "import torch; print(torch.backends.mps.is_available())"
|
||||
```
|
||||
|
||||
#### High Memory Usage
|
||||
|
||||
```bash
|
||||
# Check for memory leaks
|
||||
curl -H "X-Admin-Token: $ADMIN_TOKEN" http://localhost:5005/health/storage
|
||||
|
||||
# Manual cleanup
|
||||
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" \
|
||||
http://localhost:5005/admin/cleanup
|
||||
```
|
||||
|
||||
#### CORS Issues
|
||||
|
||||
```bash
|
||||
# Test CORS configuration
|
||||
curl -X OPTIONS http://localhost:5005/api/transcribe \
|
||||
-H "Origin: https://yourdomain.com" \
|
||||
-H "Access-Control-Request-Method: POST"
|
||||
```
|
||||
|
||||
#### TTS Server Connection
|
||||
|
||||
```bash
|
||||
# Check TTS server status
|
||||
curl http://localhost:5005/check_tts_server
|
||||
|
||||
# Update TTS configuration
|
||||
curl -X POST http://localhost:5005/update_tts_config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"server_url": "http://localhost:5050/v1/audio/speech", "api_key": "new-key"}'
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
```bash
|
||||
export FLASK_ENV=development
|
||||
export LOG_LEVEL=DEBUG
|
||||
python app.py
|
||||
```
|
||||
|
||||
### Performance Profiling
|
||||
|
||||
```bash
|
||||
# Enable performance logging
|
||||
export ENABLE_PROFILING=true
|
||||
|
||||
# View slow requests
|
||||
jq 'select(.duration_ms > 1000)' logs/performance.log
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details.
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes
|
||||
4. Run tests (`pytest && npm test`)
|
||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
|
||||
### Code Style
|
||||
|
||||
- Python: Follow PEP 8
|
||||
- TypeScript: Use ESLint configuration
|
||||
- Commit messages: Use conventional commits
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- OpenAI Whisper team for the amazing speech recognition model
|
||||
- Ollama team for making LLMs accessible
|
||||
- All contributors who have helped improve Talk2Me
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: Full docs at [docs.talk2me.app](https://docs.talk2me.app)
|
||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/talk2me/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/talk2me/discussions)
|
||||
- **Security**: Please report security vulnerabilities to security@talk2me.app
|
||||
@@ -1,54 +0,0 @@
|
||||
# TypeScript Setup for Talk2Me
|
||||
|
||||
This project now includes TypeScript support for better type safety and developer experience.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install Node.js dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Build TypeScript files:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
For development with automatic recompilation:
|
||||
```bash
|
||||
npm run watch
|
||||
# or
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `/static/js/src/` - TypeScript source files
|
||||
- `app.ts` - Main application logic
|
||||
- `types.ts` - Type definitions
|
||||
- `/static/js/dist/` - Compiled JavaScript files (git-ignored)
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
- `package.json` - Node.js dependencies and scripts
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run build` - Compile TypeScript to JavaScript
|
||||
- `npm run watch` - Watch for changes and recompile
|
||||
- `npm run dev` - Same as watch
|
||||
- `npm run clean` - Remove compiled files
|
||||
- `npm run type-check` - Type-check without compiling
|
||||
|
||||
## Type Safety Benefits
|
||||
|
||||
The TypeScript implementation provides:
|
||||
- Compile-time type checking
|
||||
- Better IDE support with autocomplete
|
||||
- Explicit interface definitions for API responses
|
||||
- Safer refactoring
|
||||
- Self-documenting code
|
||||
|
||||
## Next Steps
|
||||
|
||||
After building, the compiled JavaScript will be in `/static/js/dist/app.js` and will be automatically loaded by the HTML template.
|
||||
@@ -1,332 +0,0 @@
|
||||
# Request Size Limits Documentation
|
||||
|
||||
This document describes the request size limiting system implemented in Talk2Me to prevent memory exhaustion from large uploads.
|
||||
|
||||
## Overview
|
||||
|
||||
Talk2Me implements comprehensive request size limiting to protect against:
|
||||
- Memory exhaustion from large file uploads
|
||||
- Denial of Service (DoS) attacks using oversized requests
|
||||
- Buffer overflow attempts
|
||||
- Resource starvation from unbounded requests
|
||||
|
||||
## Default Limits
|
||||
|
||||
### Global Limits
|
||||
- **Maximum Content Length**: 50MB - Absolute maximum for any request
|
||||
- **Maximum Audio File Size**: 25MB - For audio uploads (transcription)
|
||||
- **Maximum JSON Payload**: 1MB - For API requests
|
||||
- **Maximum Image Size**: 10MB - For future image processing features
|
||||
- **Maximum Chunk Size**: 1MB - For streaming uploads
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Multi-Layer Protection
|
||||
|
||||
The system implements multiple layers of size checking:
|
||||
- Flask's built-in `MAX_CONTENT_LENGTH` configuration
|
||||
- Pre-request validation before data is loaded into memory
|
||||
- File-type specific limits
|
||||
- Endpoint-specific limits
|
||||
- Streaming request monitoring
|
||||
|
||||
### 2. File Type Detection
|
||||
|
||||
Automatic detection and enforcement based on file extensions:
|
||||
- Audio files: `.wav`, `.mp3`, `.ogg`, `.webm`, `.m4a`, `.flac`, `.aac`
|
||||
- Image files: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp`
|
||||
- JSON payloads: Content-Type header detection
|
||||
|
||||
### 3. Graceful Error Handling
|
||||
|
||||
When limits are exceeded:
|
||||
- Returns 413 (Request Entity Too Large) status code
|
||||
- Provides clear error messages with size information
|
||||
- Includes both actual and allowed sizes
|
||||
- Human-readable size formatting
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Set limits via environment variables (in bytes)
|
||||
export MAX_CONTENT_LENGTH=52428800 # 50MB
|
||||
export MAX_AUDIO_SIZE=26214400 # 25MB
|
||||
export MAX_JSON_SIZE=1048576 # 1MB
|
||||
export MAX_IMAGE_SIZE=10485760 # 10MB
|
||||
```
|
||||
|
||||
### Flask Configuration
|
||||
|
||||
```python
|
||||
# In config.py or app.py
|
||||
app.config.update({
|
||||
'MAX_CONTENT_LENGTH': 50 * 1024 * 1024, # 50MB
|
||||
'MAX_AUDIO_SIZE': 25 * 1024 * 1024, # 25MB
|
||||
'MAX_JSON_SIZE': 1 * 1024 * 1024, # 1MB
|
||||
'MAX_IMAGE_SIZE': 10 * 1024 * 1024 # 10MB
|
||||
})
|
||||
```
|
||||
|
||||
### Dynamic Configuration
|
||||
|
||||
Size limits can be updated at runtime via admin API.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /admin/size-limits
|
||||
Get current size limits.
|
||||
|
||||
```bash
|
||||
curl -H "X-Admin-Token: your-token" http://localhost:5005/admin/size-limits
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"limits": {
|
||||
"max_content_length": 52428800,
|
||||
"max_audio_size": 26214400,
|
||||
"max_json_size": 1048576,
|
||||
"max_image_size": 10485760
|
||||
},
|
||||
"limits_human": {
|
||||
"max_content_length": "50.0MB",
|
||||
"max_audio_size": "25.0MB",
|
||||
"max_json_size": "1.0MB",
|
||||
"max_image_size": "10.0MB"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /admin/size-limits
|
||||
Update size limits dynamically.
|
||||
|
||||
```bash
|
||||
curl -X POST -H "X-Admin-Token: your-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"max_audio_size": "30MB", "max_json_size": 2097152}' \
|
||||
http://localhost:5005/admin/size-limits
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"old_limits": {...},
|
||||
"new_limits": {...},
|
||||
"new_limits_human": {
|
||||
"max_audio_size": "30.0MB",
|
||||
"max_json_size": "2.0MB"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Endpoint-Specific Limits
|
||||
|
||||
```python
|
||||
@app.route('/upload')
|
||||
@limit_request_size(max_size=10*1024*1024) # 10MB limit
|
||||
def upload():
|
||||
# Handle upload
|
||||
pass
|
||||
|
||||
@app.route('/upload-audio')
|
||||
@limit_request_size(max_audio_size=30*1024*1024) # 30MB for audio
|
||||
def upload_audio():
|
||||
# Handle audio upload
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. Client-Side Validation
|
||||
|
||||
```javascript
|
||||
// Check file size before upload
|
||||
const MAX_AUDIO_SIZE = 25 * 1024 * 1024; // 25MB
|
||||
|
||||
function validateAudioFile(file) {
|
||||
if (file.size > MAX_AUDIO_SIZE) {
|
||||
alert(`Audio file too large. Maximum size is ${MAX_AUDIO_SIZE / 1024 / 1024}MB`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Chunked Uploads (Future Enhancement)
|
||||
|
||||
```javascript
|
||||
// For files larger than limits, use chunked upload
|
||||
async function uploadLargeFile(file, chunkSize = 1024 * 1024) {
|
||||
const chunks = Math.ceil(file.size / chunkSize);
|
||||
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
await uploadChunk(chunk, i, chunks);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
### 413 Request Entity Too Large
|
||||
|
||||
When a request exceeds size limits:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Request too large",
|
||||
"max_size": 52428800,
|
||||
"your_size": 75000000,
|
||||
"max_size_mb": 50.0
|
||||
}
|
||||
```
|
||||
|
||||
### File-Specific Errors
|
||||
|
||||
For audio files:
|
||||
```json
|
||||
{
|
||||
"error": "Audio file too large",
|
||||
"max_size": 26214400,
|
||||
"your_size": 35000000,
|
||||
"max_size_mb": 25.0
|
||||
}
|
||||
```
|
||||
|
||||
For JSON payloads:
|
||||
```json
|
||||
{
|
||||
"error": "JSON payload too large",
|
||||
"max_size": 1048576,
|
||||
"your_size": 2000000,
|
||||
"max_size_kb": 1024.0
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Client-Side Validation
|
||||
|
||||
Always validate file sizes on the client side:
|
||||
```javascript
|
||||
// Add to static/js/app.js
|
||||
const SIZE_LIMITS = {
|
||||
audio: 25 * 1024 * 1024, // 25MB
|
||||
json: 1 * 1024 * 1024, // 1MB
|
||||
};
|
||||
|
||||
function checkFileSize(file, type) {
|
||||
const limit = SIZE_LIMITS[type];
|
||||
if (file.size > limit) {
|
||||
showError(`File too large. Maximum size: ${formatSize(limit)}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Progressive Enhancement
|
||||
|
||||
For better UX with large files:
|
||||
- Show upload progress
|
||||
- Implement resumable uploads
|
||||
- Compress audio client-side when possible
|
||||
- Use appropriate audio formats (WebM/Opus for smaller sizes)
|
||||
|
||||
### 3. Server Configuration
|
||||
|
||||
Configure your web server (Nginx/Apache) to also enforce limits:
|
||||
|
||||
**Nginx:**
|
||||
```nginx
|
||||
client_max_body_size 50M;
|
||||
client_body_buffer_size 1M;
|
||||
```
|
||||
|
||||
**Apache:**
|
||||
```apache
|
||||
LimitRequestBody 52428800
|
||||
```
|
||||
|
||||
### 4. Monitoring
|
||||
|
||||
Monitor size limit violations:
|
||||
- Track 413 errors in logs
|
||||
- Alert on repeated violations from same IP
|
||||
- Adjust limits based on usage patterns
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Memory Protection**: Pre-flight size checks prevent loading large files into memory
|
||||
2. **DoS Prevention**: Limits prevent attackers from exhausting server resources
|
||||
3. **Bandwidth Protection**: Prevents bandwidth exhaustion from large uploads
|
||||
4. **Storage Protection**: Works with session management to limit total storage per user
|
||||
|
||||
## Integration with Other Systems
|
||||
|
||||
### Rate Limiting
|
||||
Size limits work in conjunction with rate limiting:
|
||||
- Large requests count more against rate limits
|
||||
- Repeated size violations can trigger IP blocking
|
||||
|
||||
### Session Management
|
||||
Size limits are enforced per session:
|
||||
- Total storage per session is limited
|
||||
- Large files count against session resource limits
|
||||
|
||||
### Monitoring
|
||||
Size limit violations are tracked in:
|
||||
- Application logs
|
||||
- Health check endpoints
|
||||
- Admin monitoring dashboards
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Legitimate Large Files Rejected
|
||||
|
||||
If users need to upload larger files:
|
||||
```bash
|
||||
# Increase limit for audio files to 50MB
|
||||
curl -X POST -H "X-Admin-Token: token" \
|
||||
-d '{"max_audio_size": "50MB"}' \
|
||||
http://localhost:5005/admin/size-limits
|
||||
```
|
||||
|
||||
#### 2. Chunked Transfer Encoding
|
||||
|
||||
For requests without Content-Length header:
|
||||
- The system monitors the stream
|
||||
- Terminates connection if size exceeded
|
||||
- May require special handling for some clients
|
||||
|
||||
#### 3. Load Balancer Limits
|
||||
|
||||
Ensure your load balancer also enforces appropriate limits:
|
||||
- AWS ALB: Configure request size limits
|
||||
- Cloudflare: Set upload size limits
|
||||
- Nginx: Configure client_max_body_size
|
||||
|
||||
## Performance Impact
|
||||
|
||||
The size limiting system has minimal performance impact:
|
||||
- Pre-flight checks are O(1) operations
|
||||
- No buffering of large requests
|
||||
- Early termination of oversized requests
|
||||
- Efficient memory usage
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Chunked Upload Support**: Native support for resumable uploads
|
||||
2. **Compression Detection**: Automatic handling of compressed uploads
|
||||
3. **Dynamic Limits**: Per-user or per-tier size limits
|
||||
4. **Bandwidth Throttling**: Rate limit large uploads
|
||||
5. **Storage Quotas**: Long-term storage limits per user
|
||||
155
REVERSE_PROXY.md
Normal file
155
REVERSE_PROXY.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Nginx Reverse Proxy Configuration for Talk2Me
|
||||
|
||||
## Nginx Configuration
|
||||
|
||||
Add the following to your Nginx configuration for the domain `talk2me.dr74.net`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name talk2me.dr74.net;
|
||||
|
||||
# SSL configuration
|
||||
ssl_certificate /path/to/ssl/cert.pem;
|
||||
ssl_certificate_key /path/to/ssl/key.pem;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade";
|
||||
|
||||
# Proxy settings
|
||||
location / {
|
||||
proxy_pass http://localhost:5000; # Adjust port as needed
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Important headers for PWA and WebSocket support
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# WebSocket support
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Timeouts for long-running requests
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Buffer settings
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
proxy_busy_buffers_size 8k;
|
||||
|
||||
# Disable cache for dynamic content
|
||||
proxy_cache_bypass 1;
|
||||
proxy_no_cache 1;
|
||||
}
|
||||
|
||||
# Static files with caching
|
||||
location /static/ {
|
||||
proxy_pass http://localhost:5000/static/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Cache static files
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Service worker needs special handling
|
||||
location /service-worker.js {
|
||||
proxy_pass http://localhost:5000/service-worker.js;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# No cache for service worker
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# Manifest file
|
||||
location /static/manifest.json {
|
||||
proxy_pass http://localhost:5000/static/manifest.json;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Allow manifest to be cached briefly
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
add_header Content-Type "application/manifest+json";
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name talk2me.dr74.net;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
## Flask Application Configuration
|
||||
|
||||
Set these environment variables for the Talk2Me application:
|
||||
|
||||
```bash
|
||||
# Add to .env file or set as environment variables
|
||||
FLASK_ENV=production
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SESSION_COOKIE_SAMESITE=Lax
|
||||
PREFERRED_URL_SCHEME=https
|
||||
|
||||
# If using a non-standard port
|
||||
# SERVER_NAME=talk2me.dr74.net
|
||||
```
|
||||
|
||||
## Testing the Configuration
|
||||
|
||||
1. **Check SSL Certificate**:
|
||||
```bash
|
||||
curl -I https://talk2me.dr74.net
|
||||
```
|
||||
|
||||
2. **Verify Service Worker**:
|
||||
```bash
|
||||
curl https://talk2me.dr74.net/service-worker.js
|
||||
```
|
||||
|
||||
3. **Check Manifest**:
|
||||
```bash
|
||||
curl https://talk2me.dr74.net/static/manifest.json
|
||||
```
|
||||
|
||||
4. **Test PWA Installation**:
|
||||
- Visit https://talk2me.dr74.net in Chrome
|
||||
- Open Developer Tools (F12)
|
||||
- Go to Application tab
|
||||
- Check "Manifest" section for any errors
|
||||
- Check "Service Workers" section
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Icons not loading
|
||||
- Ensure static files are being served correctly
|
||||
- Check Nginx error logs: `tail -f /var/log/nginx/error.log`
|
||||
|
||||
### Issue: Service Worker not registering
|
||||
- Verify HTTPS is working correctly
|
||||
- Check browser console for errors
|
||||
- Ensure service worker scope is correct
|
||||
|
||||
### Issue: "Add to Home Screen" not appearing
|
||||
- Clear browser cache and data
|
||||
- Ensure all manifest requirements are met
|
||||
- Check Chrome's PWA criteria in DevTools Lighthouse
|
||||
|
||||
### Issue: WebSocket connections failing
|
||||
- Verify Nginx has WebSocket headers configured
|
||||
- Check if firewall allows WebSocket connections
|
||||
@@ -1,411 +0,0 @@
|
||||
# Secrets Management Documentation
|
||||
|
||||
This document describes the secure secrets management system implemented in Talk2Me.
|
||||
|
||||
## Overview
|
||||
|
||||
Talk2Me uses a comprehensive secrets management system that provides:
|
||||
- Encrypted storage of sensitive configuration
|
||||
- Secret rotation capabilities
|
||||
- Audit logging
|
||||
- Integrity verification
|
||||
- CLI management tools
|
||||
- Environment variable integration
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **SecretsManager** (`secrets_manager.py`)
|
||||
- Handles encryption/decryption using Fernet (AES-128)
|
||||
- Manages secret lifecycle (create, read, update, delete)
|
||||
- Provides audit logging
|
||||
- Supports secret rotation
|
||||
|
||||
2. **Configuration System** (`config.py`)
|
||||
- Integrates secrets with Flask configuration
|
||||
- Environment-specific configurations
|
||||
- Validation and sanitization
|
||||
|
||||
3. **CLI Tool** (`manage_secrets.py`)
|
||||
- Command-line interface for secret management
|
||||
- Interactive and scriptable
|
||||
|
||||
### Security Features
|
||||
|
||||
- **Encryption**: AES-128 encryption using cryptography.fernet
|
||||
- **Key Derivation**: PBKDF2 with SHA256 (100,000 iterations)
|
||||
- **Master Key**: Stored separately with restricted permissions
|
||||
- **Audit Trail**: All access and modifications logged
|
||||
- **Integrity Checks**: Verify secrets haven't been tampered with
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Initialize Secrets
|
||||
|
||||
```bash
|
||||
python manage_secrets.py init
|
||||
```
|
||||
|
||||
This will:
|
||||
- Generate a master encryption key
|
||||
- Create initial secrets (Flask secret key, admin token)
|
||||
- Prompt for required secrets (TTS API key)
|
||||
|
||||
### 2. Set a Secret
|
||||
|
||||
```bash
|
||||
# Interactive (hidden input)
|
||||
python manage_secrets.py set TTS_API_KEY
|
||||
|
||||
# Direct (be careful with shell history)
|
||||
python manage_secrets.py set TTS_API_KEY --value "your-api-key"
|
||||
|
||||
# With metadata
|
||||
python manage_secrets.py set API_KEY --value "key" --metadata '{"service": "external-api"}'
|
||||
```
|
||||
|
||||
### 3. List Secrets
|
||||
|
||||
```bash
|
||||
python manage_secrets.py list
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Key Created Last Rotated Has Value
|
||||
-------------------------------------------------------------------------------------
|
||||
FLASK_SECRET_KEY 2024-01-15 2024-01-20 ✓
|
||||
TTS_API_KEY 2024-01-15 Never ✓
|
||||
ADMIN_TOKEN 2024-01-15 2024-01-18 ✓
|
||||
```
|
||||
|
||||
### 4. Rotate Secrets
|
||||
|
||||
```bash
|
||||
# Rotate a specific secret
|
||||
python manage_secrets.py rotate ADMIN_TOKEN
|
||||
|
||||
# Check which secrets need rotation
|
||||
python manage_secrets.py check-rotation
|
||||
|
||||
# Schedule automatic rotation
|
||||
python manage_secrets.py schedule-rotation API_KEY 30 # Every 30 days
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The secrets manager checks these locations in order:
|
||||
1. Encrypted secrets storage (`.secrets.json`)
|
||||
2. `SECRET_<KEY>` environment variable
|
||||
3. `<KEY>` environment variable
|
||||
4. Default value
|
||||
|
||||
### Master Key
|
||||
|
||||
The master encryption key is loaded from:
|
||||
1. `MASTER_KEY` environment variable
|
||||
2. `.master_key` file (default)
|
||||
3. Auto-generated if neither exists
|
||||
|
||||
**Important**: Protect the master key!
|
||||
- Set file permissions: `chmod 600 .master_key`
|
||||
- Back it up securely
|
||||
- Never commit to version control
|
||||
|
||||
### Flask Integration
|
||||
|
||||
Secrets are automatically loaded into Flask configuration:
|
||||
|
||||
```python
|
||||
# In app.py
|
||||
from config import init_app as init_config
|
||||
from secrets_manager import init_app as init_secrets
|
||||
|
||||
app = Flask(__name__)
|
||||
init_config(app)
|
||||
init_secrets(app)
|
||||
|
||||
# Access secrets
|
||||
api_key = app.config['TTS_API_KEY']
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Basic Operations
|
||||
|
||||
```bash
|
||||
# List all secrets
|
||||
python manage_secrets.py list
|
||||
|
||||
# Get a secret value (requires confirmation)
|
||||
python manage_secrets.py get TTS_API_KEY
|
||||
|
||||
# Set a secret
|
||||
python manage_secrets.py set DATABASE_URL
|
||||
|
||||
# Delete a secret
|
||||
python manage_secrets.py delete OLD_API_KEY
|
||||
|
||||
# Rotate a secret
|
||||
python manage_secrets.py rotate ADMIN_TOKEN
|
||||
```
|
||||
|
||||
### Advanced Operations
|
||||
|
||||
```bash
|
||||
# Verify integrity of all secrets
|
||||
python manage_secrets.py verify
|
||||
|
||||
# Migrate from environment variables
|
||||
python manage_secrets.py migrate
|
||||
|
||||
# View audit log
|
||||
python manage_secrets.py audit
|
||||
python manage_secrets.py audit TTS_API_KEY --limit 50
|
||||
|
||||
# Schedule rotation
|
||||
python manage_secrets.py schedule-rotation API_KEY 90
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. File Permissions
|
||||
|
||||
```bash
|
||||
# Secure the secrets files
|
||||
chmod 600 .secrets.json
|
||||
chmod 600 .master_key
|
||||
```
|
||||
|
||||
### 2. Backup Strategy
|
||||
|
||||
- Back up `.master_key` separately from `.secrets.json`
|
||||
- Store backups in different secure locations
|
||||
- Test restore procedures regularly
|
||||
|
||||
### 3. Rotation Policy
|
||||
|
||||
Recommended rotation intervals:
|
||||
- API Keys: 90 days
|
||||
- Admin Tokens: 30 days
|
||||
- Database Passwords: 180 days
|
||||
- Encryption Keys: 365 days
|
||||
|
||||
### 4. Access Control
|
||||
|
||||
- Use environment-specific secrets
|
||||
- Implement least privilege access
|
||||
- Audit secret access regularly
|
||||
|
||||
### 5. Git Security
|
||||
|
||||
Ensure these files are in `.gitignore`:
|
||||
```
|
||||
.secrets.json
|
||||
.master_key
|
||||
secrets.db
|
||||
*.key
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Use .env file for convenience
|
||||
cp .env.example .env
|
||||
# Edit .env with development values
|
||||
|
||||
# Initialize secrets
|
||||
python manage_secrets.py init
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Set master key via environment
|
||||
export MASTER_KEY="your-production-master-key"
|
||||
|
||||
# Or use a key management service
|
||||
export MASTER_KEY_FILE="/secure/path/to/master.key"
|
||||
|
||||
# Load secrets from secure storage
|
||||
python manage_secrets.py set TTS_API_KEY --value "$TTS_API_KEY"
|
||||
python manage_secrets.py set ADMIN_TOKEN --value "$ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM python:3.9
|
||||
|
||||
# Copy encrypted secrets (not the master key!)
|
||||
COPY .secrets.json /app/.secrets.json
|
||||
|
||||
# Master key provided at runtime
|
||||
ENV MASTER_KEY=""
|
||||
|
||||
# Run with:
|
||||
# docker run -e MASTER_KEY="$MASTER_KEY" myapp
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
```yaml
|
||||
# secret.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: talk2me-master-key
|
||||
type: Opaque
|
||||
stringData:
|
||||
master-key: "your-master-key"
|
||||
|
||||
---
|
||||
# deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: talk2me
|
||||
env:
|
||||
- name: MASTER_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: talk2me-master-key
|
||||
key: master-key
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Lost Master Key
|
||||
|
||||
If you lose the master key:
|
||||
1. You'll need to recreate all secrets
|
||||
2. Generate new master key: `python manage_secrets.py init`
|
||||
3. Re-enter all secret values
|
||||
|
||||
### Corrupted Secrets File
|
||||
|
||||
```bash
|
||||
# Check integrity
|
||||
python manage_secrets.py verify
|
||||
|
||||
# If corrupted, restore from backup or reinitialize
|
||||
```
|
||||
|
||||
### Permission Errors
|
||||
|
||||
```bash
|
||||
# Fix file permissions
|
||||
chmod 600 .secrets.json .master_key
|
||||
chown $USER:$USER .secrets.json .master_key
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Audit Logs
|
||||
|
||||
Review secret access patterns:
|
||||
```bash
|
||||
# View all audit entries
|
||||
python manage_secrets.py audit
|
||||
|
||||
# Check specific secret
|
||||
python manage_secrets.py audit TTS_API_KEY
|
||||
|
||||
# Export for analysis
|
||||
python manage_secrets.py audit > audit.log
|
||||
```
|
||||
|
||||
### Rotation Monitoring
|
||||
|
||||
```bash
|
||||
# Check rotation status
|
||||
python manage_secrets.py check-rotation
|
||||
|
||||
# Set up cron job for automatic checks
|
||||
0 0 * * * /path/to/python /path/to/manage_secrets.py check-rotation
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Environment Variables
|
||||
|
||||
```bash
|
||||
# Automatic migration
|
||||
python manage_secrets.py migrate
|
||||
|
||||
# Manual migration
|
||||
export OLD_API_KEY="your-key"
|
||||
python manage_secrets.py set API_KEY --value "$OLD_API_KEY"
|
||||
unset OLD_API_KEY
|
||||
```
|
||||
|
||||
### From .env Files
|
||||
|
||||
```python
|
||||
# migrate_env.py
|
||||
from dotenv import dotenv_values
|
||||
from secrets_manager import get_secrets_manager
|
||||
|
||||
env_values = dotenv_values('.env')
|
||||
manager = get_secrets_manager()
|
||||
|
||||
for key, value in env_values.items():
|
||||
if key.endswith('_KEY') or key.endswith('_TOKEN'):
|
||||
manager.set(key, value, {'migrated_from': '.env'})
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Python API
|
||||
|
||||
```python
|
||||
from secrets_manager import get_secret, set_secret
|
||||
|
||||
# Get a secret
|
||||
api_key = get_secret('TTS_API_KEY', default='')
|
||||
|
||||
# Set a secret
|
||||
set_secret('NEW_API_KEY', 'value', metadata={'service': 'external'})
|
||||
|
||||
# Advanced usage
|
||||
from secrets_manager import get_secrets_manager
|
||||
|
||||
manager = get_secrets_manager()
|
||||
manager.rotate('API_KEY')
|
||||
manager.schedule_rotation('TOKEN', days=30)
|
||||
```
|
||||
|
||||
### Flask CLI
|
||||
|
||||
```bash
|
||||
# Via Flask CLI
|
||||
flask secrets-list
|
||||
flask secrets-set
|
||||
flask secrets-rotate
|
||||
flask secrets-check-rotation
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Never log secret values**
|
||||
2. **Use secure random generation for new secrets**
|
||||
3. **Implement proper access controls**
|
||||
4. **Regular security audits**
|
||||
5. **Incident response plan for compromised secrets**
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Integration with cloud KMS (AWS, Azure, GCP)
|
||||
- Hardware security module (HSM) support
|
||||
- Secret sharing (Shamir's Secret Sharing)
|
||||
- Time-based access controls
|
||||
- Automated compliance reporting
|
||||
173
SECURITY.md
173
SECURITY.md
@@ -1,173 +0,0 @@
|
||||
# Security Configuration Guide
|
||||
|
||||
This document outlines security best practices for deploying Talk2Me.
|
||||
|
||||
## Secrets Management
|
||||
|
||||
Talk2Me includes a comprehensive secrets management system with encryption, rotation, and audit logging.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Initialize secrets management
|
||||
python manage_secrets.py init
|
||||
|
||||
# Set a secret
|
||||
python manage_secrets.py set TTS_API_KEY
|
||||
|
||||
# List secrets
|
||||
python manage_secrets.py list
|
||||
|
||||
# Rotate secrets
|
||||
python manage_secrets.py rotate ADMIN_TOKEN
|
||||
```
|
||||
|
||||
See [SECRETS_MANAGEMENT.md](SECRETS_MANAGEMENT.md) for detailed documentation.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
**NEVER commit sensitive information like API keys, passwords, or secrets to version control.**
|
||||
|
||||
### Required Security Configuration
|
||||
|
||||
1. **TTS_API_KEY**
|
||||
- Required for TTS server authentication
|
||||
- Set via environment variable: `export TTS_API_KEY="your-api-key"`
|
||||
- Or use a `.env` file (see `.env.example`)
|
||||
|
||||
2. **SECRET_KEY**
|
||||
- Required for Flask session security
|
||||
- Generate a secure key: `python -c "import secrets; print(secrets.token_hex(32))"`
|
||||
- Set via: `export SECRET_KEY="your-generated-key"`
|
||||
|
||||
3. **ADMIN_TOKEN**
|
||||
- Required for admin endpoints
|
||||
- Generate a secure token: `python -c "import secrets; print(secrets.token_urlsafe(32))"`
|
||||
- Set via: `export ADMIN_TOKEN="your-admin-token"`
|
||||
|
||||
### Using a .env File (Recommended)
|
||||
|
||||
1. Copy the example file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` with your actual values:
|
||||
```bash
|
||||
nano .env # or your preferred editor
|
||||
```
|
||||
|
||||
3. Load environment variables:
|
||||
```bash
|
||||
# Using python-dotenv (add to requirements.txt)
|
||||
pip install python-dotenv
|
||||
|
||||
# Or source manually
|
||||
source .env
|
||||
```
|
||||
|
||||
### Python-dotenv Integration
|
||||
|
||||
To automatically load `.env` files, add this to the top of `app.py`:
|
||||
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv() # Load .env file if it exists
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For production deployments:
|
||||
|
||||
1. **Use a secrets management service**:
|
||||
- AWS Secrets Manager
|
||||
- HashiCorp Vault
|
||||
- Azure Key Vault
|
||||
- Google Secret Manager
|
||||
|
||||
2. **Set environment variables securely**:
|
||||
- Use your platform's environment configuration
|
||||
- Never expose secrets in logs or error messages
|
||||
- Rotate keys regularly
|
||||
|
||||
3. **Additional security measures**:
|
||||
- Use HTTPS only
|
||||
- Enable CORS restrictions
|
||||
- Implement rate limiting
|
||||
- Monitor for suspicious activity
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
When using Docker:
|
||||
|
||||
```dockerfile
|
||||
# Use build arguments for non-sensitive config
|
||||
ARG TTS_SERVER_URL=http://localhost:5050/v1/audio/speech
|
||||
|
||||
# Use runtime environment for secrets
|
||||
ENV TTS_API_KEY=""
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
docker run -e TTS_API_KEY="your-key" -e SECRET_KEY="your-secret" talk2me
|
||||
```
|
||||
|
||||
### Kubernetes Deployment
|
||||
|
||||
Use Kubernetes secrets:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: talk2me-secrets
|
||||
type: Opaque
|
||||
stringData:
|
||||
tts-api-key: "your-api-key"
|
||||
flask-secret-key: "your-secret-key"
|
||||
admin-token: "your-admin-token"
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Talk2Me implements comprehensive rate limiting to prevent abuse:
|
||||
|
||||
1. **Per-Endpoint Limits**:
|
||||
- Transcription: 10/min, 100/hour
|
||||
- Translation: 20/min, 300/hour
|
||||
- TTS: 15/min, 200/hour
|
||||
|
||||
2. **Global Limits**:
|
||||
- 1,000 requests/minute total
|
||||
- 50 concurrent requests maximum
|
||||
|
||||
3. **Automatic Protection**:
|
||||
- IP blocking for excessive requests
|
||||
- Request size validation
|
||||
- Burst control
|
||||
|
||||
See [RATE_LIMITING.md](RATE_LIMITING.md) for configuration details.
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- [ ] All API keys removed from source code
|
||||
- [ ] Environment variables configured
|
||||
- [ ] `.env` file added to `.gitignore`
|
||||
- [ ] Secrets rotated after any potential exposure
|
||||
- [ ] HTTPS enabled in production
|
||||
- [ ] CORS properly configured
|
||||
- [ ] Rate limiting enabled and configured
|
||||
- [ ] Admin endpoints protected with authentication
|
||||
- [ ] Error messages don't expose sensitive info
|
||||
- [ ] Logs sanitized of sensitive data
|
||||
- [ ] Request size limits enforced
|
||||
- [ ] IP blocking configured for abuse prevention
|
||||
|
||||
### Reporting Security Issues
|
||||
|
||||
If you discover a security vulnerability, please report it to:
|
||||
- Create a private security advisory on GitHub
|
||||
- Or email: security@yourdomain.com
|
||||
|
||||
Do not create public issues for security vulnerabilities.
|
||||
@@ -1,366 +0,0 @@
|
||||
# Session Management Documentation
|
||||
|
||||
This document describes the session management system implemented in Talk2Me to prevent resource leaks from abandoned sessions.
|
||||
|
||||
## Overview
|
||||
|
||||
Talk2Me implements a comprehensive session management system that tracks user sessions and associated resources (audio files, temporary files, streams) to ensure proper cleanup and prevent resource exhaustion.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Automatic Resource Tracking
|
||||
|
||||
All resources created during a user session are automatically tracked:
|
||||
- Audio files (uploads and generated)
|
||||
- Temporary files
|
||||
- Active streams
|
||||
- Resource metadata (size, creation time, purpose)
|
||||
|
||||
### 2. Resource Limits
|
||||
|
||||
Per-session limits prevent resource exhaustion:
|
||||
- Maximum resources per session: 100
|
||||
- Maximum storage per session: 100MB
|
||||
- Automatic cleanup of oldest resources when limits are reached
|
||||
|
||||
### 3. Session Lifecycle Management
|
||||
|
||||
Sessions are automatically managed:
|
||||
- Created on first request
|
||||
- Updated on each request
|
||||
- Cleaned up when idle (15 minutes)
|
||||
- Removed when expired (1 hour)
|
||||
|
||||
### 4. Automatic Cleanup
|
||||
|
||||
Background cleanup processes run automatically:
|
||||
- Idle session cleanup (every minute)
|
||||
- Expired session cleanup (every minute)
|
||||
- Orphaned file cleanup (every minute)
|
||||
|
||||
## Configuration
|
||||
|
||||
Session management can be configured via environment variables or Flask config:
|
||||
|
||||
```python
|
||||
# app.py or config.py
|
||||
app.config.update({
|
||||
'MAX_SESSION_DURATION': 3600, # 1 hour
|
||||
'MAX_SESSION_IDLE_TIME': 900, # 15 minutes
|
||||
'MAX_RESOURCES_PER_SESSION': 100,
|
||||
'MAX_BYTES_PER_SESSION': 104857600, # 100MB
|
||||
'SESSION_CLEANUP_INTERVAL': 60, # 1 minute
|
||||
'SESSION_STORAGE_PATH': '/path/to/sessions'
|
||||
})
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
All admin endpoints require authentication via `X-Admin-Token` header.
|
||||
|
||||
#### GET /admin/sessions
|
||||
Get information about all active sessions.
|
||||
|
||||
```bash
|
||||
curl -H "X-Admin-Token: your-token" http://localhost:5005/admin/sessions
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"user_id": null,
|
||||
"ip_address": "192.168.1.1",
|
||||
"created_at": "2024-01-15T10:00:00",
|
||||
"last_activity": "2024-01-15T10:05:00",
|
||||
"duration_seconds": 300,
|
||||
"idle_seconds": 0,
|
||||
"request_count": 5,
|
||||
"resource_count": 3,
|
||||
"total_bytes_used": 1048576,
|
||||
"resources": [...]
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"total_sessions_created": 100,
|
||||
"total_sessions_cleaned": 50,
|
||||
"active_sessions": 5,
|
||||
"avg_session_duration": 600,
|
||||
"avg_resources_per_session": 4.2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /admin/sessions/{session_id}
|
||||
Get detailed information about a specific session.
|
||||
|
||||
```bash
|
||||
curl -H "X-Admin-Token: your-token" http://localhost:5005/admin/sessions/abc123
|
||||
```
|
||||
|
||||
#### POST /admin/sessions/{session_id}/cleanup
|
||||
Manually cleanup a specific session.
|
||||
|
||||
```bash
|
||||
curl -X POST -H "X-Admin-Token: your-token" \
|
||||
http://localhost:5005/admin/sessions/abc123/cleanup
|
||||
```
|
||||
|
||||
#### GET /admin/sessions/metrics
|
||||
Get session management metrics for monitoring.
|
||||
|
||||
```bash
|
||||
curl -H "X-Admin-Token: your-token" http://localhost:5005/admin/sessions/metrics
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"sessions": {
|
||||
"active": 5,
|
||||
"total_created": 100,
|
||||
"total_cleaned": 95
|
||||
},
|
||||
"resources": {
|
||||
"active": 20,
|
||||
"total_cleaned": 380,
|
||||
"active_bytes": 10485760,
|
||||
"total_bytes_cleaned": 1073741824
|
||||
},
|
||||
"limits": {
|
||||
"max_session_duration": 3600,
|
||||
"max_idle_time": 900,
|
||||
"max_resources_per_session": 100,
|
||||
"max_bytes_per_session": 104857600
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
Session management can be controlled via Flask CLI commands:
|
||||
|
||||
```bash
|
||||
# List all active sessions
|
||||
flask sessions-list
|
||||
|
||||
# Manual cleanup
|
||||
flask sessions-cleanup
|
||||
|
||||
# Show statistics
|
||||
flask sessions-stats
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Monitor Active Sessions
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
headers = {'X-Admin-Token': 'your-admin-token'}
|
||||
response = requests.get('http://localhost:5005/admin/sessions', headers=headers)
|
||||
sessions = response.json()
|
||||
|
||||
for session in sessions['sessions']:
|
||||
print(f"Session {session['session_id']}:")
|
||||
print(f" IP: {session['ip_address']}")
|
||||
print(f" Resources: {session['resource_count']}")
|
||||
print(f" Storage: {session['total_bytes_used'] / 1024 / 1024:.2f} MB")
|
||||
```
|
||||
|
||||
### 2. Cleanup Idle Sessions
|
||||
|
||||
```python
|
||||
# Get all sessions
|
||||
response = requests.get('http://localhost:5005/admin/sessions', headers=headers)
|
||||
sessions = response.json()['sessions']
|
||||
|
||||
# Find idle sessions
|
||||
idle_threshold = 300 # 5 minutes
|
||||
for session in sessions:
|
||||
if session['idle_seconds'] > idle_threshold:
|
||||
# Cleanup idle session
|
||||
cleanup_url = f'http://localhost:5005/admin/sessions/{session["session_id"]}/cleanup'
|
||||
requests.post(cleanup_url, headers=headers)
|
||||
print(f"Cleaned up idle session {session['session_id']}")
|
||||
```
|
||||
|
||||
### 3. Monitor Resource Usage
|
||||
|
||||
```python
|
||||
# Get metrics
|
||||
response = requests.get('http://localhost:5005/admin/sessions/metrics', headers=headers)
|
||||
metrics = response.json()
|
||||
|
||||
print(f"Active sessions: {metrics['sessions']['active']}")
|
||||
print(f"Active resources: {metrics['resources']['active']}")
|
||||
print(f"Storage used: {metrics['resources']['active_bytes'] / 1024 / 1024:.2f} MB")
|
||||
print(f"Total cleaned: {metrics['resources']['total_bytes_cleaned'] / 1024 / 1024 / 1024:.2f} GB")
|
||||
```
|
||||
|
||||
## Resource Types
|
||||
|
||||
The session manager tracks different types of resources:
|
||||
|
||||
### 1. Audio Files
|
||||
- Uploaded audio files for transcription
|
||||
- Generated audio files from TTS
|
||||
- Automatically cleaned up after session ends
|
||||
|
||||
### 2. Temporary Files
|
||||
- Processing intermediates
|
||||
- Cache files
|
||||
- Automatically cleaned up after use
|
||||
|
||||
### 3. Streams
|
||||
- WebSocket connections
|
||||
- Server-sent event streams
|
||||
- Closed when session ends
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Session Configuration
|
||||
|
||||
```python
|
||||
# Development
|
||||
app.config.update({
|
||||
'MAX_SESSION_DURATION': 7200, # 2 hours
|
||||
'MAX_SESSION_IDLE_TIME': 1800, # 30 minutes
|
||||
'MAX_RESOURCES_PER_SESSION': 200,
|
||||
'MAX_BYTES_PER_SESSION': 209715200 # 200MB
|
||||
})
|
||||
|
||||
# Production
|
||||
app.config.update({
|
||||
'MAX_SESSION_DURATION': 3600, # 1 hour
|
||||
'MAX_SESSION_IDLE_TIME': 900, # 15 minutes
|
||||
'MAX_RESOURCES_PER_SESSION': 100,
|
||||
'MAX_BYTES_PER_SESSION': 104857600 # 100MB
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Monitoring
|
||||
|
||||
Set up monitoring for:
|
||||
- Number of active sessions
|
||||
- Resource usage per session
|
||||
- Cleanup frequency
|
||||
- Failed cleanup attempts
|
||||
|
||||
### 3. Alerting
|
||||
|
||||
Configure alerts for:
|
||||
- High number of active sessions (>1000)
|
||||
- High resource usage (>80% of limits)
|
||||
- Failed cleanup operations
|
||||
- Orphaned files detected
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Sessions Not Being Cleaned Up
|
||||
|
||||
Check cleanup thread status:
|
||||
```bash
|
||||
flask sessions-stats
|
||||
```
|
||||
|
||||
Manual cleanup:
|
||||
```bash
|
||||
flask sessions-cleanup
|
||||
```
|
||||
|
||||
#### 2. Resource Limits Reached
|
||||
|
||||
Check session details:
|
||||
```bash
|
||||
curl -H "X-Admin-Token: token" http://localhost:5005/admin/sessions/SESSION_ID
|
||||
```
|
||||
|
||||
Increase limits if needed:
|
||||
```python
|
||||
app.config['MAX_RESOURCES_PER_SESSION'] = 200
|
||||
app.config['MAX_BYTES_PER_SESSION'] = 209715200 # 200MB
|
||||
```
|
||||
|
||||
#### 3. Orphaned Files
|
||||
|
||||
Check for orphaned files:
|
||||
```bash
|
||||
ls -la /path/to/session/storage/
|
||||
```
|
||||
|
||||
Clean orphaned files:
|
||||
```bash
|
||||
flask sessions-cleanup
|
||||
```
|
||||
|
||||
### Debug Logging
|
||||
|
||||
Enable debug logging for session management:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
# Enable session manager debug logs
|
||||
logging.getLogger('session_manager').setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Session Hijacking**: Sessions are tied to IP addresses and user agents
|
||||
2. **Resource Exhaustion**: Strict per-session limits prevent DoS attacks
|
||||
3. **File System Access**: Session storage uses secure paths and permissions
|
||||
4. **Admin Access**: All admin endpoints require authentication
|
||||
|
||||
## Performance Impact
|
||||
|
||||
The session management system has minimal performance impact:
|
||||
- Memory: ~1KB per session + resource metadata
|
||||
- CPU: Background cleanup runs every minute
|
||||
- Disk I/O: Cleanup operations are batched
|
||||
- Network: No external dependencies
|
||||
|
||||
## Integration with Other Systems
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Session management integrates with rate limiting:
|
||||
```python
|
||||
# Sessions are automatically tracked per IP
|
||||
# Rate limits apply per session
|
||||
```
|
||||
|
||||
### Secrets Management
|
||||
|
||||
Session tokens can be encrypted:
|
||||
```python
|
||||
from secrets_manager import encrypt_value
|
||||
encrypted_session = encrypt_value(session_id)
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
Export metrics to monitoring systems:
|
||||
```python
|
||||
# Prometheus format
|
||||
@app.route('/metrics')
|
||||
def prometheus_metrics():
|
||||
metrics = app.session_manager.export_metrics()
|
||||
# Format as Prometheus metrics
|
||||
return format_prometheus(metrics)
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Session Persistence**: Store sessions in Redis/database
|
||||
2. **Distributed Sessions**: Support for multi-server deployments
|
||||
3. **Session Analytics**: Track usage patterns and trends
|
||||
4. **Resource Quotas**: Per-user resource quotas
|
||||
5. **Session Replay**: Debug issues by replaying sessions
|
||||
667
admin/__init__.py
Normal file
667
admin/__init__.py
Normal file
@@ -0,0 +1,667 @@
|
||||
from flask import Blueprint, request, jsonify, render_template, redirect, url_for, session, current_app
|
||||
from functools import wraps
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
import redis
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create admin blueprint
|
||||
admin_bp = Blueprint('admin', __name__,
|
||||
template_folder='templates',
|
||||
static_folder='static',
|
||||
static_url_path='/admin/static')
|
||||
|
||||
# Initialize Redis and PostgreSQL connections
|
||||
redis_client = None
|
||||
pg_conn = None
|
||||
|
||||
def init_admin(app):
|
||||
"""Initialize admin module with app configuration"""
|
||||
global redis_client, pg_conn
|
||||
|
||||
try:
|
||||
# Initialize Redis
|
||||
redis_client = redis.from_url(
|
||||
app.config.get('REDIS_URL', 'redis://localhost:6379/0'),
|
||||
decode_responses=True
|
||||
)
|
||||
redis_client.ping()
|
||||
logger.info("Redis connection established for admin dashboard")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis: {e}")
|
||||
redis_client = None
|
||||
|
||||
try:
|
||||
# Initialize PostgreSQL
|
||||
pg_conn = psycopg2.connect(
|
||||
app.config.get('DATABASE_URL', 'postgresql://localhost/talk2me')
|
||||
)
|
||||
logger.info("PostgreSQL connection established for admin dashboard")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to PostgreSQL: {e}")
|
||||
pg_conn = None
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin authentication"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if user is logged in with admin role (from unified login)
|
||||
user_role = session.get('user_role')
|
||||
if user_role == 'admin':
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Also support the old admin token for backward compatibility
|
||||
auth_token = request.headers.get('X-Admin-Token')
|
||||
session_token = session.get('admin_token')
|
||||
expected_token = os.environ.get('ADMIN_TOKEN', 'default-admin-token')
|
||||
|
||||
if auth_token == expected_token or session_token == expected_token:
|
||||
if auth_token == expected_token:
|
||||
session['admin_token'] = expected_token
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# For API endpoints, return JSON error
|
||||
if request.path.startswith('/admin/api/'):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
# For web pages, redirect to unified login
|
||||
return redirect(url_for('login', next=request.url))
|
||||
|
||||
return decorated_function
|
||||
|
||||
@admin_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""Admin login - redirect to main login page"""
|
||||
# Redirect to the unified login page
|
||||
next_url = request.args.get('next', url_for('admin.dashboard'))
|
||||
return redirect(url_for('login', next=next_url))
|
||||
|
||||
@admin_bp.route('/logout')
|
||||
def logout():
|
||||
"""Admin logout - redirect to main logout"""
|
||||
# Clear all session data
|
||||
session.clear()
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@admin_bp.route('/')
|
||||
@admin_bp.route('/dashboard')
|
||||
@admin_required
|
||||
def dashboard():
|
||||
"""Main admin dashboard"""
|
||||
return render_template('dashboard.html')
|
||||
|
||||
@admin_bp.route('/users')
|
||||
@admin_required
|
||||
def users():
|
||||
"""User management page"""
|
||||
# The template is in the main templates folder, not admin/templates
|
||||
return render_template('admin_users.html')
|
||||
|
||||
# Analytics API endpoints
|
||||
@admin_bp.route('/api/stats/overview')
|
||||
@admin_required
|
||||
def get_overview_stats():
|
||||
"""Get overview statistics"""
|
||||
try:
|
||||
stats = {
|
||||
'requests': {'total': 0, 'today': 0, 'hour': 0},
|
||||
'translations': {'total': 0, 'today': 0},
|
||||
'transcriptions': {'total': 0, 'today': 0},
|
||||
'active_sessions': 0,
|
||||
'error_rate': 0,
|
||||
'cache_hit_rate': 0,
|
||||
'system_health': check_system_health()
|
||||
}
|
||||
|
||||
# Get data from Redis
|
||||
if redis_client:
|
||||
try:
|
||||
# Request counts
|
||||
stats['requests']['total'] = int(redis_client.get('stats:requests:total') or 0)
|
||||
stats['requests']['today'] = int(redis_client.get(f'stats:requests:daily:{datetime.now().strftime("%Y-%m-%d")}') or 0)
|
||||
stats['requests']['hour'] = int(redis_client.get(f'stats:requests:hourly:{datetime.now().strftime("%Y-%m-%d-%H")}') or 0)
|
||||
|
||||
# Operation counts
|
||||
stats['translations']['total'] = int(redis_client.get('stats:translations:total') or 0)
|
||||
stats['translations']['today'] = int(redis_client.get(f'stats:translations:daily:{datetime.now().strftime("%Y-%m-%d")}') or 0)
|
||||
stats['transcriptions']['total'] = int(redis_client.get('stats:transcriptions:total') or 0)
|
||||
stats['transcriptions']['today'] = int(redis_client.get(f'stats:transcriptions:daily:{datetime.now().strftime("%Y-%m-%d")}') or 0)
|
||||
|
||||
# Active sessions
|
||||
stats['active_sessions'] = len(redis_client.keys('session:*'))
|
||||
|
||||
# Cache stats
|
||||
cache_hits = int(redis_client.get('stats:cache:hits') or 0)
|
||||
cache_misses = int(redis_client.get('stats:cache:misses') or 0)
|
||||
if cache_hits + cache_misses > 0:
|
||||
stats['cache_hit_rate'] = round((cache_hits / (cache_hits + cache_misses)) * 100, 2)
|
||||
|
||||
# Error rate
|
||||
total_requests = stats['requests']['today']
|
||||
errors_today = int(redis_client.get(f'stats:errors:daily:{datetime.now().strftime("%Y-%m-%d")}') or 0)
|
||||
if total_requests > 0:
|
||||
stats['error_rate'] = round((errors_today / total_requests) * 100, 2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Redis stats: {e}")
|
||||
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_overview_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/stats/requests/<timeframe>')
|
||||
@admin_required
|
||||
def get_request_stats(timeframe):
|
||||
"""Get request statistics for different timeframes"""
|
||||
try:
|
||||
if timeframe not in ['minute', 'hour', 'day']:
|
||||
return jsonify({'error': 'Invalid timeframe'}), 400
|
||||
|
||||
data = []
|
||||
labels = []
|
||||
|
||||
if redis_client:
|
||||
now = datetime.now()
|
||||
|
||||
if timeframe == 'minute':
|
||||
# Last 60 minutes
|
||||
for i in range(59, -1, -1):
|
||||
time_key = (now - timedelta(minutes=i)).strftime('%Y-%m-%d-%H-%M')
|
||||
count = int(redis_client.get(f'stats:requests:minute:{time_key}') or 0)
|
||||
data.append(count)
|
||||
labels.append((now - timedelta(minutes=i)).strftime('%H:%M'))
|
||||
|
||||
elif timeframe == 'hour':
|
||||
# Last 24 hours
|
||||
for i in range(23, -1, -1):
|
||||
time_key = (now - timedelta(hours=i)).strftime('%Y-%m-%d-%H')
|
||||
count = int(redis_client.get(f'stats:requests:hourly:{time_key}') or 0)
|
||||
data.append(count)
|
||||
labels.append((now - timedelta(hours=i)).strftime('%H:00'))
|
||||
|
||||
elif timeframe == 'day':
|
||||
# Last 30 days
|
||||
for i in range(29, -1, -1):
|
||||
time_key = (now - timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
count = int(redis_client.get(f'stats:requests:daily:{time_key}') or 0)
|
||||
data.append(count)
|
||||
labels.append((now - timedelta(days=i)).strftime('%m/%d'))
|
||||
|
||||
return jsonify({
|
||||
'labels': labels,
|
||||
'data': data,
|
||||
'timeframe': timeframe
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_request_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/stats/operations')
|
||||
@admin_required
|
||||
def get_operation_stats():
|
||||
"""Get translation and transcription statistics"""
|
||||
try:
|
||||
stats = {
|
||||
'translations': {'data': [], 'labels': []},
|
||||
'transcriptions': {'data': [], 'labels': []},
|
||||
'language_pairs': {},
|
||||
'response_times': {'translation': [], 'transcription': []}
|
||||
}
|
||||
|
||||
if redis_client:
|
||||
now = datetime.now()
|
||||
|
||||
# Get daily stats for last 7 days
|
||||
for i in range(6, -1, -1):
|
||||
date_key = (now - timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
date_label = (now - timedelta(days=i)).strftime('%m/%d')
|
||||
|
||||
# Translation counts
|
||||
trans_count = int(redis_client.get(f'stats:translations:daily:{date_key}') or 0)
|
||||
stats['translations']['data'].append(trans_count)
|
||||
stats['translations']['labels'].append(date_label)
|
||||
|
||||
# Transcription counts
|
||||
transcr_count = int(redis_client.get(f'stats:transcriptions:daily:{date_key}') or 0)
|
||||
stats['transcriptions']['data'].append(transcr_count)
|
||||
stats['transcriptions']['labels'].append(date_label)
|
||||
|
||||
# Get language pair statistics
|
||||
lang_pairs = redis_client.hgetall('stats:language_pairs') or {}
|
||||
stats['language_pairs'] = {k: int(v) for k, v in lang_pairs.items()}
|
||||
|
||||
# Get response times (last 100 operations)
|
||||
trans_times = redis_client.lrange('stats:response_times:translation', 0, 99)
|
||||
transcr_times = redis_client.lrange('stats:response_times:transcription', 0, 99)
|
||||
|
||||
stats['response_times']['translation'] = [float(t) for t in trans_times[:20]]
|
||||
stats['response_times']['transcription'] = [float(t) for t in transcr_times[:20]]
|
||||
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_operation_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/stats/errors')
|
||||
@admin_required
|
||||
def get_error_stats():
|
||||
"""Get error statistics"""
|
||||
try:
|
||||
stats = {
|
||||
'error_types': {},
|
||||
'error_timeline': {'data': [], 'labels': []},
|
||||
'recent_errors': []
|
||||
}
|
||||
|
||||
if pg_conn:
|
||||
try:
|
||||
with pg_conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
# Get error types distribution
|
||||
cursor.execute("""
|
||||
SELECT error_type, COUNT(*) as count
|
||||
FROM error_logs
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY error_type
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
error_types = cursor.fetchall()
|
||||
stats['error_types'] = {row['error_type']: row['count'] for row in error_types}
|
||||
|
||||
# Get error timeline (hourly for last 24 hours)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
DATE_TRUNC('hour', created_at) as hour,
|
||||
COUNT(*) as count
|
||||
FROM error_logs
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
""")
|
||||
timeline = cursor.fetchall()
|
||||
|
||||
for row in timeline:
|
||||
stats['error_timeline']['labels'].append(row['hour'].strftime('%H:00'))
|
||||
stats['error_timeline']['data'].append(row['count'])
|
||||
|
||||
# Get recent errors
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
error_type,
|
||||
error_message,
|
||||
endpoint,
|
||||
created_at
|
||||
FROM error_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
recent = cursor.fetchall()
|
||||
stats['recent_errors'] = [
|
||||
{
|
||||
'type': row['error_type'],
|
||||
'message': row['error_message'][:100],
|
||||
'endpoint': row['endpoint'],
|
||||
'time': row['created_at'].isoformat()
|
||||
}
|
||||
for row in recent
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying PostgreSQL: {e}")
|
||||
|
||||
# Fallback to Redis if PostgreSQL fails
|
||||
if not stats['error_types'] and redis_client:
|
||||
error_types = redis_client.hgetall('stats:error_types') or {}
|
||||
stats['error_types'] = {k: int(v) for k, v in error_types.items()}
|
||||
|
||||
# Get hourly error counts
|
||||
now = datetime.now()
|
||||
for i in range(23, -1, -1):
|
||||
hour_key = (now - timedelta(hours=i)).strftime('%Y-%m-%d-%H')
|
||||
count = int(redis_client.get(f'stats:errors:hourly:{hour_key}') or 0)
|
||||
stats['error_timeline']['data'].append(count)
|
||||
stats['error_timeline']['labels'].append((now - timedelta(hours=i)).strftime('%H:00'))
|
||||
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_error_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/stats/performance')
|
||||
@admin_required
|
||||
def get_performance_stats():
|
||||
"""Get performance metrics"""
|
||||
try:
|
||||
stats = {
|
||||
'response_times': {
|
||||
'translation': {'avg': 0, 'p95': 0, 'p99': 0},
|
||||
'transcription': {'avg': 0, 'p95': 0, 'p99': 0},
|
||||
'tts': {'avg': 0, 'p95': 0, 'p99': 0}
|
||||
},
|
||||
'throughput': {'data': [], 'labels': []},
|
||||
'slow_requests': []
|
||||
}
|
||||
|
||||
if redis_client:
|
||||
# Calculate response time percentiles
|
||||
for operation in ['translation', 'transcription', 'tts']:
|
||||
times = redis_client.lrange(f'stats:response_times:{operation}', 0, -1)
|
||||
if times:
|
||||
times = sorted([float(t) for t in times])
|
||||
stats['response_times'][operation]['avg'] = round(sum(times) / len(times), 2)
|
||||
stats['response_times'][operation]['p95'] = round(times[int(len(times) * 0.95)], 2)
|
||||
stats['response_times'][operation]['p99'] = round(times[int(len(times) * 0.99)], 2)
|
||||
|
||||
# Get throughput (requests per minute for last hour)
|
||||
now = datetime.now()
|
||||
for i in range(59, -1, -1):
|
||||
time_key = (now - timedelta(minutes=i)).strftime('%Y-%m-%d-%H-%M')
|
||||
count = int(redis_client.get(f'stats:requests:minute:{time_key}') or 0)
|
||||
stats['throughput']['data'].append(count)
|
||||
stats['throughput']['labels'].append((now - timedelta(minutes=i)).strftime('%H:%M'))
|
||||
|
||||
# Get slow requests
|
||||
slow_requests = redis_client.lrange('stats:slow_requests', 0, 9)
|
||||
stats['slow_requests'] = [json.loads(req) for req in slow_requests if req]
|
||||
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_performance_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/export/<data_type>')
|
||||
@admin_required
|
||||
def export_data(data_type):
|
||||
"""Export analytics data"""
|
||||
try:
|
||||
if data_type not in ['requests', 'errors', 'performance', 'all']:
|
||||
return jsonify({'error': 'Invalid data type'}), 400
|
||||
|
||||
export_data = {
|
||||
'export_time': datetime.now().isoformat(),
|
||||
'data_type': data_type
|
||||
}
|
||||
|
||||
if data_type in ['requests', 'all']:
|
||||
# Export request data
|
||||
request_data = []
|
||||
if redis_client:
|
||||
# Get daily stats for last 30 days
|
||||
now = datetime.now()
|
||||
for i in range(29, -1, -1):
|
||||
date_key = (now - timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
request_data.append({
|
||||
'date': date_key,
|
||||
'requests': int(redis_client.get(f'stats:requests:daily:{date_key}') or 0),
|
||||
'translations': int(redis_client.get(f'stats:translations:daily:{date_key}') or 0),
|
||||
'transcriptions': int(redis_client.get(f'stats:transcriptions:daily:{date_key}') or 0),
|
||||
'errors': int(redis_client.get(f'stats:errors:daily:{date_key}') or 0)
|
||||
})
|
||||
export_data['requests'] = request_data
|
||||
|
||||
if data_type in ['errors', 'all']:
|
||||
# Export error data from PostgreSQL
|
||||
error_data = []
|
||||
if pg_conn:
|
||||
try:
|
||||
with pg_conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute("""
|
||||
SELECT * FROM error_logs
|
||||
WHERE created_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
errors = cursor.fetchall()
|
||||
error_data = [dict(row) for row in errors]
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting from PostgreSQL: {e}")
|
||||
export_data['errors'] = error_data
|
||||
|
||||
if data_type in ['performance', 'all']:
|
||||
# Export performance data
|
||||
perf_data = {
|
||||
'response_times': {},
|
||||
'slow_requests': []
|
||||
}
|
||||
if redis_client:
|
||||
for op in ['translation', 'transcription', 'tts']:
|
||||
times = redis_client.lrange(f'stats:response_times:{op}', 0, -1)
|
||||
perf_data['response_times'][op] = [float(t) for t in times]
|
||||
|
||||
slow_reqs = redis_client.lrange('stats:slow_requests', 0, -1)
|
||||
perf_data['slow_requests'] = [json.loads(req) for req in slow_reqs if req]
|
||||
|
||||
export_data['performance'] = perf_data
|
||||
|
||||
# Return as downloadable JSON
|
||||
response = jsonify(export_data)
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=talk2me_analytics_{data_type}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in export_data: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
def check_system_health():
|
||||
"""Check health of system components"""
|
||||
health = {
|
||||
'redis': 'unknown',
|
||||
'postgresql': 'unknown',
|
||||
'tts': 'unknown',
|
||||
'overall': 'healthy'
|
||||
}
|
||||
|
||||
# Check Redis
|
||||
if redis_client:
|
||||
try:
|
||||
redis_client.ping()
|
||||
health['redis'] = 'healthy'
|
||||
except:
|
||||
health['redis'] = 'unhealthy'
|
||||
health['overall'] = 'degraded'
|
||||
else:
|
||||
health['redis'] = 'not_configured'
|
||||
health['overall'] = 'degraded'
|
||||
|
||||
# Check PostgreSQL
|
||||
if pg_conn:
|
||||
try:
|
||||
with pg_conn.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
health['postgresql'] = 'healthy'
|
||||
except:
|
||||
health['postgresql'] = 'unhealthy'
|
||||
health['overall'] = 'degraded'
|
||||
else:
|
||||
health['postgresql'] = 'not_configured'
|
||||
health['overall'] = 'degraded'
|
||||
|
||||
# Check TTS Server
|
||||
tts_server_url = current_app.config.get('TTS_SERVER_URL')
|
||||
if tts_server_url:
|
||||
try:
|
||||
import requests
|
||||
# Extract base URL from the speech endpoint
|
||||
base_url = tts_server_url.rsplit('/v1/audio/speech', 1)[0] if '/v1/audio/speech' in tts_server_url else tts_server_url
|
||||
health_url = f"{base_url}/health" if not tts_server_url.endswith('/health') else tts_server_url
|
||||
response = requests.get(health_url, timeout=2)
|
||||
if response.status_code == 200:
|
||||
health['tts'] = 'healthy'
|
||||
health['tts_details'] = response.json() if response.headers.get('content-type') == 'application/json' else {}
|
||||
elif response.status_code == 404:
|
||||
# Try voices endpoint as fallback
|
||||
voices_url = f"{base_url}/voices" if base_url else f"{tts_server_url.rsplit('/speech', 1)[0]}/voices"
|
||||
voices_response = requests.get(voices_url, timeout=2)
|
||||
if voices_response.status_code == 200:
|
||||
health['tts'] = 'healthy'
|
||||
else:
|
||||
health['tts'] = 'unhealthy'
|
||||
health['overall'] = 'degraded'
|
||||
else:
|
||||
health['tts'] = 'unhealthy'
|
||||
health['overall'] = 'degraded'
|
||||
except requests.exceptions.RequestException:
|
||||
health['tts'] = 'unreachable'
|
||||
health['overall'] = 'degraded'
|
||||
except Exception as e:
|
||||
health['tts'] = 'error'
|
||||
health['overall'] = 'degraded'
|
||||
logger.error(f"TTS health check error: {e}")
|
||||
else:
|
||||
health['tts'] = 'not_configured'
|
||||
# TTS is optional, so don't degrade overall health
|
||||
|
||||
return health
|
||||
|
||||
# TTS Server Status endpoint
|
||||
@admin_bp.route('/api/tts/status')
|
||||
@admin_required
|
||||
def get_tts_status():
|
||||
"""Get detailed TTS server status"""
|
||||
try:
|
||||
tts_info = {
|
||||
'configured': False,
|
||||
'status': 'not_configured',
|
||||
'server_url': None,
|
||||
'api_key_configured': False,
|
||||
'details': {}
|
||||
}
|
||||
|
||||
# Check configuration
|
||||
tts_server_url = current_app.config.get('TTS_SERVER_URL')
|
||||
tts_api_key = current_app.config.get('TTS_API_KEY')
|
||||
|
||||
if tts_server_url:
|
||||
tts_info['configured'] = True
|
||||
tts_info['server_url'] = tts_server_url
|
||||
tts_info['api_key_configured'] = bool(tts_api_key)
|
||||
|
||||
# Try to get detailed status
|
||||
try:
|
||||
import requests
|
||||
headers = {}
|
||||
if tts_api_key:
|
||||
headers['Authorization'] = f'Bearer {tts_api_key}'
|
||||
|
||||
# Check health endpoint
|
||||
# Extract base URL from the speech endpoint
|
||||
base_url = tts_server_url.rsplit('/v1/audio/speech', 1)[0] if '/v1/audio/speech' in tts_server_url else tts_server_url
|
||||
health_url = f"{base_url}/health" if not tts_server_url.endswith('/health') else tts_server_url
|
||||
|
||||
response = requests.get(health_url, headers=headers, timeout=3)
|
||||
if response.status_code == 200:
|
||||
tts_info['status'] = 'healthy'
|
||||
if response.headers.get('content-type') == 'application/json':
|
||||
tts_info['details'] = response.json()
|
||||
else:
|
||||
tts_info['status'] = 'unhealthy'
|
||||
tts_info['details']['error'] = f'Health check returned status {response.status_code}'
|
||||
|
||||
# Try to get voice list
|
||||
try:
|
||||
voices_url = f"{base_url}/voices" if base_url else f"{tts_server_url.rsplit('/speech', 1)[0]}/voices"
|
||||
voices_response = requests.get(voices_url, headers=headers, timeout=3)
|
||||
if voices_response.status_code == 200 and voices_response.headers.get('content-type') == 'application/json':
|
||||
voices_data = voices_response.json()
|
||||
tts_info['details']['available_voices'] = voices_data.get('voices', [])
|
||||
tts_info['details']['voice_count'] = len(voices_data.get('voices', []))
|
||||
# If we can get voices, consider the server healthy even if health endpoint doesn't exist
|
||||
if tts_info['status'] == 'unhealthy' and response.status_code == 404:
|
||||
tts_info['status'] = 'healthy'
|
||||
tts_info['details'].pop('error', None)
|
||||
except:
|
||||
pass
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
tts_info['status'] = 'unreachable'
|
||||
tts_info['details']['error'] = 'Cannot connect to TTS server'
|
||||
except requests.exceptions.Timeout:
|
||||
tts_info['status'] = 'timeout'
|
||||
tts_info['details']['error'] = 'TTS server request timed out'
|
||||
except Exception as e:
|
||||
tts_info['status'] = 'error'
|
||||
tts_info['details']['error'] = str(e)
|
||||
|
||||
# Get recent TTS usage stats from Redis
|
||||
if redis_client:
|
||||
try:
|
||||
now = datetime.now()
|
||||
tts_info['usage'] = {
|
||||
'total': int(redis_client.get('stats:tts:total') or 0),
|
||||
'today': int(redis_client.get(f'stats:tts:daily:{now.strftime("%Y-%m-%d")}') or 0),
|
||||
'this_hour': int(redis_client.get(f'stats:tts:hourly:{now.strftime("%Y-%m-%d-%H")}') or 0)
|
||||
}
|
||||
|
||||
# Get recent response times
|
||||
response_times = redis_client.lrange('stats:response_times:tts', -100, -1)
|
||||
if response_times:
|
||||
times = [float(t) for t in response_times]
|
||||
tts_info['performance'] = {
|
||||
'avg_response_time': round(sum(times) / len(times), 2),
|
||||
'min_response_time': round(min(times), 2),
|
||||
'max_response_time': round(max(times), 2)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting TTS stats from Redis: {e}")
|
||||
|
||||
return jsonify(tts_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_tts_status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# WebSocket support for real-time updates (using Server-Sent Events as fallback)
|
||||
@admin_bp.route('/api/stream/updates')
|
||||
@admin_required
|
||||
def stream_updates():
|
||||
"""Stream real-time updates using Server-Sent Events"""
|
||||
def generate():
|
||||
last_update = time.time()
|
||||
|
||||
while True:
|
||||
# Send update every 5 seconds
|
||||
if time.time() - last_update > 5:
|
||||
try:
|
||||
# Get current stats
|
||||
stats = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'requests_per_minute': 0,
|
||||
'active_sessions': 0,
|
||||
'recent_errors': 0
|
||||
}
|
||||
|
||||
if redis_client:
|
||||
# Current requests per minute
|
||||
current_minute = datetime.now().strftime('%Y-%m-%d-%H-%M')
|
||||
stats['requests_per_minute'] = int(redis_client.get(f'stats:requests:minute:{current_minute}') or 0)
|
||||
|
||||
# Active sessions
|
||||
stats['active_sessions'] = len(redis_client.keys('session:*'))
|
||||
|
||||
# Recent errors
|
||||
current_hour = datetime.now().strftime('%Y-%m-%d-%H')
|
||||
stats['recent_errors'] = int(redis_client.get(f'stats:errors:hourly:{current_hour}') or 0)
|
||||
|
||||
yield f"data: {json.dumps(stats)}\n\n"
|
||||
last_update = time.time()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in stream_updates: {e}")
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
return current_app.response_class(
|
||||
generate(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
)
|
||||
192
admin/static/css/admin.css
Normal file
192
admin/static/css/admin.css
Normal file
@@ -0,0 +1,192 @@
|
||||
/* Admin Dashboard Styles */
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
padding-top: 56px; /* For fixed navbar */
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e3e6f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.badge {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.badge.bg-success {
|
||||
background-color: #1cc88a !important;
|
||||
}
|
||||
|
||||
.badge.bg-warning {
|
||||
background-color: #f6c23e !important;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.badge.bg-danger {
|
||||
background-color: #e74a3b !important;
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Login Page */
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.card-body h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
border-radius: 0.25rem !important;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinners */
|
||||
.spinner-border-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* Error list */
|
||||
.error-item {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.error-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.error-type {
|
||||
font-weight: 600;
|
||||
color: #e74a3b;
|
||||
}
|
||||
|
||||
.error-time {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast {
|
||||
background-color: white;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.updating {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #2a2a2a;
|
||||
border-bottom-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-of-type(odd) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: #3a3a3a;
|
||||
border-color: #4a4a4a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance optimization */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 40vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
261
admin/static/js/admin.js
Normal file
261
admin/static/js/admin.js
Normal file
@@ -0,0 +1,261 @@
|
||||
// Admin Dashboard JavaScript
|
||||
$(document).ready(function() {
|
||||
// Load initial data
|
||||
loadOverviewStats();
|
||||
loadSystemHealth();
|
||||
loadTTSStatus();
|
||||
loadRequestChart('hour');
|
||||
loadOperationStats();
|
||||
loadLanguagePairs();
|
||||
loadRecentErrors();
|
||||
loadActiveSessions();
|
||||
|
||||
// Set up auto-refresh
|
||||
setInterval(loadOverviewStats, 30000); // Every 30 seconds
|
||||
setInterval(loadSystemHealth, 60000); // Every minute
|
||||
setInterval(loadTTSStatus, 60000); // Every minute
|
||||
|
||||
// Set up real-time updates if available
|
||||
initializeEventStream();
|
||||
});
|
||||
|
||||
// Charts
|
||||
let charts = {
|
||||
request: null,
|
||||
operations: null,
|
||||
language: null,
|
||||
performance: null,
|
||||
errors: null
|
||||
};
|
||||
|
||||
// Load overview statistics
|
||||
function loadOverviewStats() {
|
||||
$.ajax({
|
||||
url: '/admin/api/stats/overview',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update request stats
|
||||
$('#total-requests').text(data.requests.total.toLocaleString());
|
||||
$('#today-requests').text(data.requests.today.toLocaleString());
|
||||
$('#hourly-requests').text(data.requests.hour.toLocaleString());
|
||||
|
||||
// Update operation stats
|
||||
$('#total-translations').text(data.translations.total.toLocaleString());
|
||||
$('#today-translations').text(data.translations.today.toLocaleString());
|
||||
|
||||
$('#total-transcriptions').text(data.transcriptions.total.toLocaleString());
|
||||
$('#today-transcriptions').text(data.transcriptions.today.toLocaleString());
|
||||
|
||||
// Update other metrics
|
||||
$('#active-sessions').text(data.active_sessions.toLocaleString());
|
||||
$('#error-rate').text(data.error_rate.toFixed(2) + '%');
|
||||
$('#cache-hit-rate').text(data.cache_hit_rate.toFixed(2) + '%');
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load overview stats:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load system health status
|
||||
function loadSystemHealth() {
|
||||
$.ajax({
|
||||
url: '/admin/api/health',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update overall status
|
||||
const overallStatus = $('#overall-status');
|
||||
overallStatus.removeClass('text-success text-warning text-danger');
|
||||
|
||||
if (data.status === 'healthy') {
|
||||
overallStatus.addClass('text-success').html('<i class="fas fa-check-circle"></i> All Systems Operational');
|
||||
} else if (data.status === 'degraded') {
|
||||
overallStatus.addClass('text-warning').html('<i class="fas fa-exclamation-triangle"></i> Degraded Performance');
|
||||
} else {
|
||||
overallStatus.addClass('text-danger').html('<i class="fas fa-times-circle"></i> System Issues');
|
||||
}
|
||||
|
||||
// Update component statuses
|
||||
updateComponentStatus('redis', data.components.redis);
|
||||
updateComponentStatus('postgresql', data.components.postgresql);
|
||||
updateComponentStatus('ml', data.components.tts || { status: 'healthy' });
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load system health:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update component status badge
|
||||
function updateComponentStatus(component, data) {
|
||||
const badge = $(`#${component}-status`);
|
||||
badge.removeClass('bg-success bg-warning bg-danger bg-secondary');
|
||||
|
||||
if (data.status === 'healthy') {
|
||||
badge.addClass('bg-success').text('Healthy');
|
||||
} else if (data.status === 'not_configured') {
|
||||
badge.addClass('bg-secondary').text('Not Configured');
|
||||
} else if (data.status === 'unreachable') {
|
||||
badge.addClass('bg-warning').text('Unreachable');
|
||||
} else {
|
||||
badge.addClass('bg-danger').text('Unhealthy');
|
||||
}
|
||||
|
||||
// Update TTS details if applicable
|
||||
if (component === 'ml' && data.status) {
|
||||
const details = $('#tts-details');
|
||||
if (data.status === 'healthy') {
|
||||
details.text('TTS Server Connected');
|
||||
} else if (data.status === 'not_configured') {
|
||||
details.text('No TTS Server');
|
||||
} else if (data.status === 'unreachable') {
|
||||
details.text('Cannot reach TTS server');
|
||||
} else {
|
||||
details.text('TTS Server Error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load detailed TTS status
|
||||
function loadTTSStatus() {
|
||||
$.ajax({
|
||||
url: '/admin/api/tts/status',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Configuration status
|
||||
if (data.configured) {
|
||||
$('#tts-config-status').removeClass().addClass('badge bg-success').text('Configured');
|
||||
$('#tts-server-url').text(data.server_url || '-');
|
||||
$('#tts-api-key-status').text(data.api_key_configured ? 'Configured' : 'Not Set');
|
||||
} else {
|
||||
$('#tts-config-status').removeClass().addClass('badge bg-secondary').text('Not Configured');
|
||||
$('#tts-server-url').text('-');
|
||||
$('#tts-api-key-status').text('-');
|
||||
}
|
||||
|
||||
// Health status
|
||||
const healthBadge = $('#tts-health-status');
|
||||
healthBadge.removeClass();
|
||||
|
||||
if (data.status === 'healthy') {
|
||||
healthBadge.addClass('badge bg-success').text('Healthy');
|
||||
$('#tts-error-message').text('-');
|
||||
} else if (data.status === 'unreachable') {
|
||||
healthBadge.addClass('badge bg-warning').text('Unreachable');
|
||||
$('#tts-error-message').text(data.details.error || 'Cannot connect');
|
||||
} else if (data.status === 'not_configured') {
|
||||
healthBadge.addClass('badge bg-secondary').text('Not Configured');
|
||||
$('#tts-error-message').text('-');
|
||||
} else {
|
||||
healthBadge.addClass('badge bg-danger').text('Error');
|
||||
$('#tts-error-message').text(data.details.error || 'Unknown error');
|
||||
}
|
||||
|
||||
// Voice count and list
|
||||
if (data.details && data.details.voice_count !== undefined) {
|
||||
$('#tts-voice-count').text(data.details.voice_count);
|
||||
|
||||
// Show voice list if available
|
||||
if (data.details.available_voices && data.details.available_voices.length > 0) {
|
||||
$('#tts-voices-container').show();
|
||||
const voicesList = $('#tts-voices-list');
|
||||
voicesList.empty();
|
||||
|
||||
data.details.available_voices.forEach(function(voice) {
|
||||
voicesList.append(`<span class="badge bg-primary">${voice}</span>`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
$('#tts-voice-count').text('-');
|
||||
$('#tts-voices-container').hide();
|
||||
}
|
||||
|
||||
// Usage statistics
|
||||
if (data.usage) {
|
||||
$('#tts-usage-today').text(data.usage.today.toLocaleString());
|
||||
$('#tts-usage-total').text(data.usage.total.toLocaleString());
|
||||
} else {
|
||||
$('#tts-usage-today').text('-');
|
||||
$('#tts-usage-total').text('-');
|
||||
}
|
||||
|
||||
// Performance metrics
|
||||
if (data.performance) {
|
||||
$('#tts-avg-response').text(data.performance.avg_response_time + ' ms');
|
||||
} else {
|
||||
$('#tts-avg-response').text('-');
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load TTS status:', error);
|
||||
$('#tts-config-status').removeClass().addClass('badge bg-danger').text('Error');
|
||||
$('#tts-health-status').removeClass().addClass('badge bg-danger').text('Error');
|
||||
$('#tts-error-message').text('Failed to load status');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load request chart
|
||||
function loadRequestChart(timeframe) {
|
||||
// Implementation would go here
|
||||
console.log('Loading request chart for timeframe:', timeframe);
|
||||
}
|
||||
|
||||
// Load operation statistics
|
||||
function loadOperationStats() {
|
||||
// Implementation would go here
|
||||
console.log('Loading operation stats');
|
||||
}
|
||||
|
||||
// Load language pairs
|
||||
function loadLanguagePairs() {
|
||||
// Implementation would go here
|
||||
console.log('Loading language pairs');
|
||||
}
|
||||
|
||||
// Load recent errors
|
||||
function loadRecentErrors() {
|
||||
// Implementation would go here
|
||||
console.log('Loading recent errors');
|
||||
}
|
||||
|
||||
// Load active sessions
|
||||
function loadActiveSessions() {
|
||||
// Implementation would go here
|
||||
console.log('Loading active sessions');
|
||||
}
|
||||
|
||||
// Initialize event stream for real-time updates
|
||||
function initializeEventStream() {
|
||||
if (typeof(EventSource) === "undefined") {
|
||||
console.log("Server-sent events not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const source = new EventSource('/admin/api/stream/updates');
|
||||
|
||||
source.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Update real-time metrics
|
||||
if (data.requests_per_minute !== undefined) {
|
||||
$('#realtime-rpm').text(data.requests_per_minute);
|
||||
}
|
||||
if (data.active_sessions !== undefined) {
|
||||
$('#active-sessions').text(data.active_sessions);
|
||||
}
|
||||
if (data.recent_errors !== undefined) {
|
||||
$('#recent-errors-count').text(data.recent_errors);
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = function(error) {
|
||||
console.error('EventSource error:', error);
|
||||
};
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
function showToast(message, type = 'info') {
|
||||
// Implementation would go here
|
||||
console.log(`Toast [${type}]: ${message}`);
|
||||
}
|
||||
75
admin/templates/base.html
Normal file
75
admin/templates/base.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Talk2Me Admin Dashboard{% endblock %}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('admin.static', filename='css/admin.css') }}">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="fas fa-language"></i> Talk2Me Admin
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.users') }}">
|
||||
<i class="fas fa-users"></i> Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="exportData('all')">
|
||||
<i class="fas fa-download"></i> Export Data
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.logout') }}">
|
||||
<i class="fas fa-sign-out-alt"></i> Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container-fluid mt-5 pt-3">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- jQuery (for AJAX) -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('admin.static', filename='js/admin.js') }}"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
326
admin/templates/dashboard.html
Normal file
326
admin/templates/dashboard.html
Normal file
@@ -0,0 +1,326 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Talk2Me Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Quick Actions -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Quick Actions</h5>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-primary">
|
||||
<i class="fas fa-users"></i> Manage Users
|
||||
</a>
|
||||
<button onclick="exportData('all')" class="btn btn-secondary">
|
||||
<i class="fas fa-download"></i> Export Data
|
||||
</button>
|
||||
<button onclick="clearCache()" class="btn btn-warning">
|
||||
<i class="fas fa-trash"></i> Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-primary">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Total Requests</h5>
|
||||
<h2 class="card-text" id="total-requests">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</h2>
|
||||
<p class="card-text"><small>Today: <span id="today-requests">-</span></small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-success">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Active Sessions</h5>
|
||||
<h2 class="card-text" id="active-sessions">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</h2>
|
||||
<p class="card-text"><small>Live users</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-warning">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Error Rate</h5>
|
||||
<h2 class="card-text" id="error-rate">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</h2>
|
||||
<p class="card-text"><small>Last 24 hours</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-info">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Cache Hit Rate</h5>
|
||||
<h2 class="card-text" id="cache-hit-rate">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</h2>
|
||||
<p class="card-text"><small>Performance metric</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health Status -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-heartbeat"></i> System Health</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-database fa-2x me-3"></i>
|
||||
<div>
|
||||
<h6 class="mb-0">Redis</h6>
|
||||
<span class="badge" id="redis-status">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-server fa-2x me-3"></i>
|
||||
<div>
|
||||
<h6 class="mb-0">PostgreSQL</h6>
|
||||
<span class="badge" id="postgresql-status">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-microphone fa-2x me-3"></i>
|
||||
<div>
|
||||
<h6 class="mb-0">Whisper/TTS</h6>
|
||||
<span class="badge" id="ml-status">Checking...</span>
|
||||
<small class="d-block text-muted" id="tts-details"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS Server Status Card -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">TTS Server Status</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadTTSStatus()">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row" id="tts-status-container">
|
||||
<div class="col-md-4">
|
||||
<h6>Configuration</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><strong>Status:</strong> <span id="tts-config-status" class="badge">Loading...</span></li>
|
||||
<li><strong>Server URL:</strong> <span id="tts-server-url">-</span></li>
|
||||
<li><strong>API Key:</strong> <span id="tts-api-key-status">-</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6>Server Health</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><strong>Health:</strong> <span id="tts-health-status" class="badge">Loading...</span></li>
|
||||
<li><strong>Available Voices:</strong> <span id="tts-voice-count">-</span></li>
|
||||
<li><strong>Error:</strong> <span id="tts-error-message" class="text-danger">-</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6>Usage & Performance</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><strong>Today's Requests:</strong> <span id="tts-usage-today">-</span></li>
|
||||
<li><strong>Avg Response Time:</strong> <span id="tts-avg-response">-</span></li>
|
||||
<li><strong>Total Requests:</strong> <span id="tts-usage-total">-</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3" id="tts-voices-container" style="display: none;">
|
||||
<div class="col-md-12">
|
||||
<h6>Available Voices</h6>
|
||||
<div id="tts-voices-list" class="d-flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Request Volume</h5>
|
||||
<div class="btn-group btn-group-sm float-end" role="group">
|
||||
<button type="button" class="btn btn-outline-primary active" onclick="updateRequestChart('minute')">Minute</button>
|
||||
<button type="button" class="btn btn-outline-primary" onclick="updateRequestChart('hour')">Hour</button>
|
||||
<button type="button" class="btn btn-outline-primary" onclick="updateRequestChart('day')">Day</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="requestChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Language Pairs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="languageChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Operations</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="operationsChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Response Times (ms)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="responseTimeChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Analysis -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Error Types</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="errorTypeChart" height="150"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Recent Errors</h5>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
||||
<div id="recent-errors-list">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Metrics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Performance Metrics</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Operation</th>
|
||||
<th>Average (ms)</th>
|
||||
<th>95th Percentile (ms)</th>
|
||||
<th>99th Percentile (ms)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="performance-table">
|
||||
<tr>
|
||||
<td colspan="4" class="text-center">
|
||||
<div class="spinner-border" role="status"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Real-time Updates Status -->
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1000">
|
||||
<div class="toast" id="update-toast" role="alert">
|
||||
<div class="toast-header">
|
||||
<i class="fas fa-sync-alt me-2"></i>
|
||||
<strong class="me-auto">Real-time Updates</strong>
|
||||
<small id="last-update">Just now</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<span id="update-status">Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Initialize dashboard
|
||||
$(document).ready(function() {
|
||||
initializeDashboard();
|
||||
|
||||
// Start real-time updates
|
||||
startRealtimeUpdates();
|
||||
|
||||
// Load initial data
|
||||
loadOverviewStats();
|
||||
loadRequestChart('minute');
|
||||
loadOperationStats();
|
||||
loadErrorStats();
|
||||
loadPerformanceStats();
|
||||
|
||||
// Refresh data periodically
|
||||
setInterval(loadOverviewStats, 10000); // Every 10 seconds
|
||||
setInterval(function() {
|
||||
loadRequestChart(currentTimeframe);
|
||||
}, 30000); // Every 30 seconds
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
120
admin/templates/dashboard_simple.html
Normal file
120
admin/templates/dashboard_simple.html
Normal file
@@ -0,0 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Talk2Me Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Simple Mode Notice -->
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h4 class="alert-heading">Simple Mode Active</h4>
|
||||
<p>The admin dashboard is running in simple mode because Redis and PostgreSQL services are not available.</p>
|
||||
<hr>
|
||||
<p class="mb-0">To enable full analytics and monitoring features, please ensure Redis and PostgreSQL are running.</p>
|
||||
</div>
|
||||
|
||||
<!-- Basic Info Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">System Status</h5>
|
||||
<p class="card-text">
|
||||
<span class="badge badge-success">Online</span>
|
||||
</p>
|
||||
<small class="text-muted">Talk2Me API is running</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Admin Access</h5>
|
||||
<p class="card-text">
|
||||
<span class="badge badge-primary">Authenticated</span>
|
||||
</p>
|
||||
<small class="text-muted">You are logged in as admin</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Services</h5>
|
||||
<p class="card-text">
|
||||
Redis: <span class="badge badge-secondary">Not configured</span><br>
|
||||
PostgreSQL: <span class="badge badge-secondary">Not configured</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Actions -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Available Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>In simple mode, you can:</p>
|
||||
<ul>
|
||||
<li>Access the Talk2Me API with admin privileges</li>
|
||||
<li>View system health status</li>
|
||||
<li>Logout from the admin session</li>
|
||||
</ul>
|
||||
|
||||
<p class="mt-3">To enable full features, set up the following services:</p>
|
||||
<ol>
|
||||
<li><strong>Redis</strong>: For caching, rate limiting, and session management</li>
|
||||
<li><strong>PostgreSQL</strong>: For persistent storage of analytics and user data</li>
|
||||
</ol>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/admin/logout" class="btn btn-secondary">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Instructions -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Quick Setup Guide</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>1. Install Redis:</h6>
|
||||
<pre class="bg-light p-2"><code># Ubuntu/Debian
|
||||
sudo apt-get install redis-server
|
||||
sudo systemctl start redis
|
||||
|
||||
# macOS
|
||||
brew install redis
|
||||
brew services start redis</code></pre>
|
||||
|
||||
<h6>2. Install PostgreSQL:</h6>
|
||||
<pre class="bg-light p-2"><code># Ubuntu/Debian
|
||||
sudo apt-get install postgresql
|
||||
sudo systemctl start postgresql
|
||||
|
||||
# macOS
|
||||
brew install postgresql
|
||||
brew services start postgresql</code></pre>
|
||||
|
||||
<h6>3. Configure Environment:</h6>
|
||||
<pre class="bg-light p-2"><code># Add to .env file
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
DATABASE_URL=postgresql://user:pass@localhost/talk2me</code></pre>
|
||||
|
||||
<h6>4. Initialize Database:</h6>
|
||||
<pre class="bg-light p-2"><code>python init_auth_db.py</code></pre>
|
||||
|
||||
<p class="mt-3">After completing these steps, restart the Talk2Me server to enable full admin features.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Simple mode - no need for real-time updates or API calls
|
||||
console.log('Admin dashboard loaded in simple mode');
|
||||
</script>
|
||||
{% endblock %}
|
||||
35
admin/templates/login.html
Normal file
35
admin/templates/login.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Login - Talk2Me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-center mb-4">
|
||||
<i class="fas fa-lock"></i> Admin Login
|
||||
</h3>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="token" class="form-label">Admin Token</label>
|
||||
<input type="password" class="form-control" id="token" name="token" required autofocus>
|
||||
<div class="form-text">Enter your admin access token</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-sign-in-alt"></i> Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
47
admin_loader.py
Normal file
47
admin_loader.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Dynamic admin module loader that chooses between full and simple admin based on service availability
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def load_admin_module():
|
||||
"""
|
||||
Dynamically load admin module based on service availability
|
||||
Returns (admin_bp, init_admin) tuple
|
||||
"""
|
||||
# Check if we should force simple mode
|
||||
if os.environ.get('ADMIN_SIMPLE_MODE', '').lower() in ('1', 'true', 'yes'):
|
||||
logger.info("Simple admin mode forced by environment variable")
|
||||
from admin_simple import admin_bp, init_admin
|
||||
return admin_bp, init_admin
|
||||
|
||||
# Try to import full admin module
|
||||
try:
|
||||
# Quick check for Redis
|
||||
import redis
|
||||
r = redis.Redis.from_url(os.environ.get('REDIS_URL', 'redis://localhost:6379/0'))
|
||||
r.ping()
|
||||
|
||||
# Quick check for PostgreSQL
|
||||
from sqlalchemy import create_engine, text
|
||||
db_url = os.environ.get('DATABASE_URL', 'postgresql://localhost/talk2me')
|
||||
engine = create_engine(db_url, pool_pre_ping=True)
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
|
||||
# If we get here, both services are available
|
||||
from admin import admin_bp, init_admin
|
||||
logger.info("Using full admin module with Redis and PostgreSQL support")
|
||||
return admin_bp, init_admin
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Cannot use full admin module: {e}")
|
||||
logger.info("Falling back to simple admin module")
|
||||
from admin_simple import admin_bp, init_admin
|
||||
return admin_bp, init_admin
|
||||
77
admin_simple.py
Normal file
77
admin_simple.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Simple admin blueprint that works without Redis/PostgreSQL
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, render_template, redirect, url_for, session
|
||||
from functools import wraps
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create admin blueprint
|
||||
admin_bp = Blueprint('admin', __name__,
|
||||
template_folder='admin/templates',
|
||||
static_folder='admin/static',
|
||||
static_url_path='/admin/static')
|
||||
|
||||
def init_admin(app):
|
||||
"""Initialize admin module with app configuration"""
|
||||
logger.info("Admin dashboard initialized (simple mode)")
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin authentication"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if user is logged in as admin
|
||||
if not session.get('admin_logged_in'):
|
||||
# Check for admin token in headers (for API access)
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header[7:]
|
||||
expected_token = os.environ.get('ADMIN_TOKEN', 'default-admin-token')
|
||||
if token == expected_token:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Redirect to login for web access
|
||||
return redirect(url_for('admin.login', next=request.url))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@admin_bp.route('/')
|
||||
@admin_required
|
||||
def dashboard():
|
||||
"""Main admin dashboard"""
|
||||
# Use simple dashboard template
|
||||
return render_template('dashboard_simple.html')
|
||||
|
||||
@admin_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""Admin login page"""
|
||||
if request.method == 'POST':
|
||||
token = request.form.get('token', '')
|
||||
expected_token = os.environ.get('ADMIN_TOKEN', 'default-admin-token')
|
||||
|
||||
if token == expected_token:
|
||||
session['admin_logged_in'] = True
|
||||
next_page = request.args.get('next', url_for('admin.dashboard'))
|
||||
return redirect(next_page)
|
||||
else:
|
||||
return render_template('login.html', error='Invalid admin token')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@admin_bp.route('/logout')
|
||||
def logout():
|
||||
"""Admin logout"""
|
||||
session.pop('admin_logged_in', None)
|
||||
return redirect(url_for('admin.login'))
|
||||
|
||||
@admin_bp.route('/health')
|
||||
def health():
|
||||
"""Check admin dashboard health"""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'mode': 'simple',
|
||||
'redis': 'not configured',
|
||||
'postgresql': 'not configured'
|
||||
})
|
||||
426
analytics_middleware.py
Normal file
426
analytics_middleware.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""Analytics middleware for tracking requests and operations"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import request, g
|
||||
import redis
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
import threading
|
||||
from queue import Queue
|
||||
from functools import wraps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AnalyticsTracker:
|
||||
"""Track and store analytics data"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
self.app = app
|
||||
self.redis_client = None
|
||||
self.pg_conn = None
|
||||
self.write_queue = Queue()
|
||||
self.writer_thread = None
|
||||
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Initialize analytics with Flask app"""
|
||||
self.app = app
|
||||
|
||||
# Initialize Redis connection
|
||||
try:
|
||||
self.redis_client = redis.from_url(
|
||||
app.config.get('REDIS_URL', 'redis://localhost:6379/0'),
|
||||
decode_responses=True
|
||||
)
|
||||
self.redis_client.ping()
|
||||
logger.info("Analytics Redis connection established")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis for analytics: {e}")
|
||||
self.redis_client = None
|
||||
|
||||
# Initialize PostgreSQL connection
|
||||
try:
|
||||
self.pg_conn = psycopg2.connect(
|
||||
app.config.get('DATABASE_URL', 'postgresql://localhost/talk2me')
|
||||
)
|
||||
self.pg_conn.autocommit = True
|
||||
logger.info("Analytics PostgreSQL connection established")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to PostgreSQL for analytics: {e}")
|
||||
self.pg_conn = None
|
||||
|
||||
# Start background writer thread
|
||||
self.writer_thread = threading.Thread(target=self._write_worker, daemon=True)
|
||||
self.writer_thread.start()
|
||||
|
||||
# Register before/after request handlers
|
||||
app.before_request(self.before_request)
|
||||
app.after_request(self.after_request)
|
||||
|
||||
def before_request(self):
|
||||
"""Track request start time"""
|
||||
g.start_time = time.time()
|
||||
g.request_size = request.content_length or 0
|
||||
|
||||
def after_request(self, response):
|
||||
"""Track request completion and metrics"""
|
||||
try:
|
||||
# Skip if analytics is disabled
|
||||
if not self.enabled:
|
||||
return response
|
||||
|
||||
# Calculate response time
|
||||
response_time = int((time.time() - g.start_time) * 1000) # in ms
|
||||
|
||||
# Track in Redis for real-time stats
|
||||
if self.redis_client:
|
||||
self._track_redis_stats(request, response, response_time)
|
||||
|
||||
# Queue for PostgreSQL logging
|
||||
if self.pg_conn and request.endpoint not in ['static', 'admin.static']:
|
||||
self._queue_request_log(request, response, response_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in analytics after_request: {e}")
|
||||
|
||||
return response
|
||||
|
||||
def _track_redis_stats(self, request, response, response_time):
|
||||
"""Track statistics in Redis"""
|
||||
try:
|
||||
now = datetime.now()
|
||||
|
||||
# Increment request counters
|
||||
pipe = self.redis_client.pipeline()
|
||||
|
||||
# Total requests
|
||||
pipe.incr('stats:requests:total')
|
||||
|
||||
# Time-based counters
|
||||
pipe.incr(f'stats:requests:minute:{now.strftime("%Y-%m-%d-%H-%M")}')
|
||||
pipe.expire(f'stats:requests:minute:{now.strftime("%Y-%m-%d-%H-%M")}', 3600) # 1 hour
|
||||
|
||||
pipe.incr(f'stats:requests:hourly:{now.strftime("%Y-%m-%d-%H")}')
|
||||
pipe.expire(f'stats:requests:hourly:{now.strftime("%Y-%m-%d-%H")}', 86400) # 24 hours
|
||||
|
||||
pipe.incr(f'stats:requests:daily:{now.strftime("%Y-%m-%d")}')
|
||||
pipe.expire(f'stats:requests:daily:{now.strftime("%Y-%m-%d")}', 604800) # 7 days
|
||||
|
||||
# Track errors
|
||||
if response.status_code >= 400:
|
||||
pipe.incr(f'stats:errors:daily:{now.strftime("%Y-%m-%d")}')
|
||||
pipe.incr(f'stats:errors:hourly:{now.strftime("%Y-%m-%d-%H")}')
|
||||
pipe.expire(f'stats:errors:hourly:{now.strftime("%Y-%m-%d-%H")}', 86400)
|
||||
|
||||
# Track response times
|
||||
endpoint_key = request.endpoint or 'unknown'
|
||||
pipe.lpush(f'stats:response_times:{endpoint_key}', response_time)
|
||||
pipe.ltrim(f'stats:response_times:{endpoint_key}', 0, 999) # Keep last 1000
|
||||
|
||||
# Track slow requests
|
||||
if response_time > 1000: # Over 1 second
|
||||
slow_request = {
|
||||
'endpoint': request.endpoint,
|
||||
'method': request.method,
|
||||
'response_time': response_time,
|
||||
'timestamp': now.isoformat()
|
||||
}
|
||||
pipe.lpush('stats:slow_requests', json.dumps(slow_request))
|
||||
pipe.ltrim('stats:slow_requests', 0, 99) # Keep last 100
|
||||
|
||||
pipe.execute()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error tracking Redis stats: {e}")
|
||||
|
||||
def _queue_request_log(self, request, response, response_time):
|
||||
"""Queue request log for PostgreSQL"""
|
||||
try:
|
||||
log_entry = {
|
||||
'endpoint': request.endpoint,
|
||||
'method': request.method,
|
||||
'status_code': response.status_code,
|
||||
'response_time_ms': response_time,
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent', '')[:500],
|
||||
'request_size_bytes': g.get('request_size', 0),
|
||||
'response_size_bytes': len(response.get_data()),
|
||||
'session_id': g.get('session_id'),
|
||||
'created_at': datetime.now()
|
||||
}
|
||||
|
||||
self.write_queue.put(('request_log', log_entry))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error queuing request log: {e}")
|
||||
|
||||
def track_operation(self, operation_type, **kwargs):
|
||||
"""Track specific operations (translation, transcription, etc.)"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **inner_kwargs):
|
||||
start_time = time.time()
|
||||
success = True
|
||||
error_message = None
|
||||
result = None
|
||||
|
||||
try:
|
||||
result = f(*args, **inner_kwargs)
|
||||
return result
|
||||
except Exception as e:
|
||||
success = False
|
||||
error_message = str(e)
|
||||
raise
|
||||
finally:
|
||||
# Track operation
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
self._track_operation_complete(
|
||||
operation_type, response_time, success,
|
||||
error_message, kwargs, result
|
||||
)
|
||||
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
def _track_operation_complete(self, operation_type, response_time, success,
|
||||
error_message, metadata, result):
|
||||
"""Track operation completion"""
|
||||
try:
|
||||
now = datetime.now()
|
||||
|
||||
# Update Redis counters
|
||||
if self.redis_client:
|
||||
pipe = self.redis_client.pipeline()
|
||||
|
||||
# Operation counters
|
||||
pipe.incr(f'stats:{operation_type}:total')
|
||||
pipe.incr(f'stats:{operation_type}:daily:{now.strftime("%Y-%m-%d")}')
|
||||
pipe.expire(f'stats:{operation_type}:daily:{now.strftime("%Y-%m-%d")}', 604800)
|
||||
|
||||
# Response times
|
||||
pipe.lpush(f'stats:response_times:{operation_type}', response_time)
|
||||
pipe.ltrim(f'stats:response_times:{operation_type}', 0, 999)
|
||||
|
||||
# Language pairs for translations
|
||||
if operation_type == 'translations' and 'source_lang' in metadata:
|
||||
lang_pair = f"{metadata.get('source_lang')} -> {metadata.get('target_lang')}"
|
||||
pipe.hincrby('stats:language_pairs', lang_pair, 1)
|
||||
|
||||
# Error tracking
|
||||
if not success:
|
||||
pipe.hincrby('stats:error_types', error_message[:100], 1)
|
||||
|
||||
pipe.execute()
|
||||
|
||||
# Queue for PostgreSQL
|
||||
if self.pg_conn:
|
||||
log_entry = {
|
||||
'operation_type': operation_type,
|
||||
'response_time_ms': response_time,
|
||||
'success': success,
|
||||
'error_message': error_message,
|
||||
'metadata': metadata,
|
||||
'result': result,
|
||||
'session_id': g.get('session_id'),
|
||||
'created_at': now
|
||||
}
|
||||
|
||||
self.write_queue.put((operation_type, log_entry))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error tracking operation: {e}")
|
||||
|
||||
def _write_worker(self):
|
||||
"""Background worker to write logs to PostgreSQL"""
|
||||
while True:
|
||||
try:
|
||||
# Get items from queue (blocking)
|
||||
operation_type, log_entry = self.write_queue.get()
|
||||
|
||||
if operation_type == 'request_log':
|
||||
self._write_request_log(log_entry)
|
||||
elif operation_type == 'translations':
|
||||
self._write_translation_log(log_entry)
|
||||
elif operation_type == 'transcriptions':
|
||||
self._write_transcription_log(log_entry)
|
||||
elif operation_type == 'tts':
|
||||
self._write_tts_log(log_entry)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in analytics write worker: {e}")
|
||||
|
||||
def _write_request_log(self, log_entry):
|
||||
"""Write request log to PostgreSQL"""
|
||||
try:
|
||||
with self.pg_conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
INSERT INTO request_logs
|
||||
(endpoint, method, status_code, response_time_ms,
|
||||
ip_address, user_agent, request_size_bytes,
|
||||
response_size_bytes, session_id, created_at)
|
||||
VALUES (%(endpoint)s, %(method)s, %(status_code)s,
|
||||
%(response_time_ms)s, %(ip_address)s, %(user_agent)s,
|
||||
%(request_size_bytes)s, %(response_size_bytes)s,
|
||||
%(session_id)s, %(created_at)s)
|
||||
""", log_entry)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if 'relation "request_logs" does not exist' in error_msg:
|
||||
logger.warning("Analytics tables not found. Run init_analytics_db.py to create them.")
|
||||
# Disable analytics to prevent repeated errors
|
||||
self.enabled = False
|
||||
else:
|
||||
logger.error(f"Error writing request log: {e}")
|
||||
|
||||
def _write_translation_log(self, log_entry):
|
||||
"""Write translation log to PostgreSQL"""
|
||||
try:
|
||||
metadata = log_entry.get('metadata', {})
|
||||
|
||||
with self.pg_conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
INSERT INTO translation_logs
|
||||
(source_language, target_language, text_length,
|
||||
response_time_ms, success, error_message,
|
||||
session_id, created_at)
|
||||
VALUES (%(source_language)s, %(target_language)s,
|
||||
%(text_length)s, %(response_time_ms)s,
|
||||
%(success)s, %(error_message)s,
|
||||
%(session_id)s, %(created_at)s)
|
||||
""", {
|
||||
'source_language': metadata.get('source_lang'),
|
||||
'target_language': metadata.get('target_lang'),
|
||||
'text_length': metadata.get('text_length', 0),
|
||||
'response_time_ms': log_entry['response_time_ms'],
|
||||
'success': log_entry['success'],
|
||||
'error_message': log_entry['error_message'],
|
||||
'session_id': log_entry['session_id'],
|
||||
'created_at': log_entry['created_at']
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing translation log: {e}")
|
||||
|
||||
def _write_transcription_log(self, log_entry):
|
||||
"""Write transcription log to PostgreSQL"""
|
||||
try:
|
||||
metadata = log_entry.get('metadata', {})
|
||||
result = log_entry.get('result', {})
|
||||
|
||||
with self.pg_conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
INSERT INTO transcription_logs
|
||||
(detected_language, audio_duration_seconds,
|
||||
file_size_bytes, response_time_ms, success,
|
||||
error_message, session_id, created_at)
|
||||
VALUES (%(detected_language)s, %(audio_duration_seconds)s,
|
||||
%(file_size_bytes)s, %(response_time_ms)s,
|
||||
%(success)s, %(error_message)s,
|
||||
%(session_id)s, %(created_at)s)
|
||||
""", {
|
||||
'detected_language': result.get('detected_language') if isinstance(result, dict) else None,
|
||||
'audio_duration_seconds': metadata.get('audio_duration', 0),
|
||||
'file_size_bytes': metadata.get('file_size', 0),
|
||||
'response_time_ms': log_entry['response_time_ms'],
|
||||
'success': log_entry['success'],
|
||||
'error_message': log_entry['error_message'],
|
||||
'session_id': log_entry['session_id'],
|
||||
'created_at': log_entry['created_at']
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing transcription log: {e}")
|
||||
|
||||
def _write_tts_log(self, log_entry):
|
||||
"""Write TTS log to PostgreSQL"""
|
||||
try:
|
||||
metadata = log_entry.get('metadata', {})
|
||||
|
||||
with self.pg_conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
INSERT INTO tts_logs
|
||||
(language, text_length, voice, response_time_ms,
|
||||
success, error_message, session_id, created_at)
|
||||
VALUES (%(language)s, %(text_length)s, %(voice)s,
|
||||
%(response_time_ms)s, %(success)s,
|
||||
%(error_message)s, %(session_id)s, %(created_at)s)
|
||||
""", {
|
||||
'language': metadata.get('language'),
|
||||
'text_length': metadata.get('text_length', 0),
|
||||
'voice': metadata.get('voice'),
|
||||
'response_time_ms': log_entry['response_time_ms'],
|
||||
'success': log_entry['success'],
|
||||
'error_message': log_entry['error_message'],
|
||||
'session_id': log_entry['session_id'],
|
||||
'created_at': log_entry['created_at']
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing TTS log: {e}")
|
||||
|
||||
def log_error(self, error_type, error_message, **kwargs):
|
||||
"""Log error to analytics"""
|
||||
try:
|
||||
# Track in Redis
|
||||
if self.redis_client:
|
||||
pipe = self.redis_client.pipeline()
|
||||
pipe.hincrby('stats:error_types', error_type, 1)
|
||||
pipe.incr(f'stats:errors:daily:{datetime.now().strftime("%Y-%m-%d")}')
|
||||
pipe.execute()
|
||||
|
||||
# Log to PostgreSQL
|
||||
if self.pg_conn:
|
||||
with self.pg_conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
INSERT INTO error_logs
|
||||
(error_type, error_message, endpoint, method,
|
||||
status_code, ip_address, user_agent, request_id,
|
||||
stack_trace, created_at)
|
||||
VALUES (%(error_type)s, %(error_message)s,
|
||||
%(endpoint)s, %(method)s, %(status_code)s,
|
||||
%(ip_address)s, %(user_agent)s,
|
||||
%(request_id)s, %(stack_trace)s,
|
||||
%(created_at)s)
|
||||
""", {
|
||||
'error_type': error_type,
|
||||
'error_message': error_message[:1000],
|
||||
'endpoint': kwargs.get('endpoint'),
|
||||
'method': kwargs.get('method'),
|
||||
'status_code': kwargs.get('status_code'),
|
||||
'ip_address': kwargs.get('ip_address'),
|
||||
'user_agent': kwargs.get('user_agent', '')[:500],
|
||||
'request_id': kwargs.get('request_id'),
|
||||
'stack_trace': kwargs.get('stack_trace', '')[:5000],
|
||||
'created_at': datetime.now()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error logging analytics error: {e}")
|
||||
|
||||
def update_cache_stats(self, hit=True):
|
||||
"""Update cache hit/miss statistics"""
|
||||
try:
|
||||
if self.redis_client:
|
||||
if hit:
|
||||
self.redis_client.incr('stats:cache:hits')
|
||||
else:
|
||||
self.redis_client.incr('stats:cache:misses')
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating cache stats: {e}")
|
||||
|
||||
# Create global instance
|
||||
analytics_tracker = AnalyticsTracker()
|
||||
|
||||
# Convenience decorators
|
||||
def track_translation(**kwargs):
|
||||
"""Decorator to track translation operations"""
|
||||
return analytics_tracker.track_operation('translations', **kwargs)
|
||||
|
||||
def track_transcription(**kwargs):
|
||||
"""Decorator to track transcription operations"""
|
||||
return analytics_tracker.track_operation('transcriptions', **kwargs)
|
||||
|
||||
def track_tts(**kwargs):
|
||||
"""Decorator to track TTS operations"""
|
||||
return analytics_tracker.track_operation('tts', **kwargs)
|
||||
358
app.py
358
app.py
@@ -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
|
||||
@@ -32,6 +32,9 @@ load_dotenv()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import ProxyFix for reverse proxy support
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
# Import configuration and secrets management
|
||||
from config import init_app as init_config
|
||||
from secrets_manager import init_app as init_secrets
|
||||
@@ -39,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):
|
||||
@@ -82,6 +92,16 @@ def with_error_boundary(func):
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Apply ProxyFix middleware for reverse proxy support
|
||||
# This ensures the app correctly handles X-Forwarded-* headers from Nginx
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=1, # Number of reverse proxies setting X-Forwarded-For
|
||||
x_proto=1, # Number of reverse proxies setting X-Forwarded-Proto
|
||||
x_host=1, # Number of reverse proxies setting X-Forwarded-Host
|
||||
x_prefix=1 # Number of reverse proxies setting X-Forwarded-Prefix
|
||||
)
|
||||
|
||||
# Initialize configuration and secrets management
|
||||
init_config(app)
|
||||
init_secrets(app)
|
||||
@@ -153,6 +173,140 @@ 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
|
||||
|
||||
@app.route('/api/debug-users')
|
||||
def debug_users():
|
||||
"""Debug endpoint to check all users in database"""
|
||||
try:
|
||||
# Check if database tables exist
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(db.engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
# Get all users
|
||||
all_users = User.query.all()
|
||||
|
||||
# Also try a raw SQL query to double-check
|
||||
raw_result = db.session.execute('SELECT COUNT(*) FROM users').scalar()
|
||||
|
||||
# Get some raw user data
|
||||
raw_users = db.session.execute('SELECT id, username, email, role FROM users LIMIT 10').fetchall()
|
||||
|
||||
return jsonify({
|
||||
'database_tables': tables,
|
||||
'total_users': len(all_users),
|
||||
'raw_count': raw_result,
|
||||
'raw_users_sample': [{'id': str(row[0]), 'username': row[1], 'email': row[2], 'role': row[3]} for row in raw_users],
|
||||
'users': [
|
||||
{
|
||||
'id': str(user.id),
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
'is_active': user.is_active,
|
||||
'is_suspended': user.is_suspended,
|
||||
'created_at': user.created_at.isoformat()
|
||||
}
|
||||
for user in all_users
|
||||
]
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({
|
||||
'error': str(e),
|
||||
'traceback': traceback.format_exc()
|
||||
}), 500
|
||||
|
||||
# Initialize memory management
|
||||
memory_manager = MemoryManager(app, {
|
||||
'memory_threshold_mb': app.config.get('MEMORY_THRESHOLD_MB', 4096),
|
||||
@@ -348,7 +502,23 @@ def apple_touch_icon_120_precomposed():
|
||||
# Add this route to your Flask app
|
||||
@app.route('/service-worker.js')
|
||||
def service_worker():
|
||||
return app.send_static_file('service-worker.js')
|
||||
response = app.send_static_file('service-worker.js')
|
||||
response.headers['Content-Type'] = 'application/javascript'
|
||||
response.headers['Service-Worker-Allowed'] = '/'
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
return response
|
||||
|
||||
@app.route('/manifest.json')
|
||||
@app.route('/static/manifest.json')
|
||||
def manifest():
|
||||
response = app.send_static_file('manifest.json')
|
||||
response.headers['Content-Type'] = 'application/manifest+json'
|
||||
response.headers['Cache-Control'] = 'public, max-age=3600'
|
||||
return response
|
||||
|
||||
@app.route('/check-pwa-status.html')
|
||||
def check_pwa_status():
|
||||
return app.send_static_file('check-pwa-status.html')
|
||||
|
||||
# Make sure static files are served properly
|
||||
app.static_folder = 'static'
|
||||
@@ -644,14 +814,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:
|
||||
@@ -738,6 +1024,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(
|
||||
@@ -775,12 +1079,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
|
||||
@@ -827,6 +1134,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(
|
||||
@@ -845,7 +1170,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():
|
||||
@@ -943,12 +1268,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
|
||||
@@ -1037,6 +1365,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)
|
||||
|
||||
746
app_with_db.py
Normal file
746
app_with_db.py
Normal file
@@ -0,0 +1,746 @@
|
||||
# This is the updated app.py with Redis and PostgreSQL integration
|
||||
# To use this, rename it to app.py after backing up the original
|
||||
|
||||
import os
|
||||
import time
|
||||
import tempfile
|
||||
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_cors import CORS, cross_origin
|
||||
import whisper
|
||||
import torch
|
||||
import ollama
|
||||
from whisper_config import MODEL_SIZE, GPU_OPTIMIZATIONS, TRANSCRIBE_OPTIONS
|
||||
from pywebpush import webpush, WebPushException
|
||||
import base64
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
import gc
|
||||
from functools import wraps
|
||||
import traceback
|
||||
from validators import Validators
|
||||
import atexit
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Import new database and Redis components
|
||||
from database import db, init_db, Translation, Transcription, UserPreferences, UsageAnalytics
|
||||
from redis_manager import RedisManager, redis_cache
|
||||
from redis_rate_limiter import RedisRateLimiter, rate_limit
|
||||
from redis_session_manager import RedisSessionManager, init_app as init_redis_sessions
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Initialize logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import other components
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from config import init_app as init_config
|
||||
from secrets_manager import init_app as init_secrets
|
||||
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
|
||||
|
||||
# Error boundary decorator
|
||||
def with_error_boundary(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
log_exception(
|
||||
e,
|
||||
message=f"Error in {func.__name__}",
|
||||
endpoint=request.endpoint,
|
||||
method=request.method,
|
||||
path=request.path,
|
||||
ip=request.remote_addr,
|
||||
function=func.__name__,
|
||||
module=func.__module__
|
||||
)
|
||||
|
||||
if any(keyword in str(e).lower() for keyword in ['inject', 'attack', 'malicious', 'unauthorized']):
|
||||
app.error_logger.log_security(
|
||||
'suspicious_error',
|
||||
severity='warning',
|
||||
error_type=type(e).__name__,
|
||||
error_message=str(e),
|
||||
endpoint=request.endpoint,
|
||||
ip=request.remote_addr
|
||||
)
|
||||
|
||||
error_message = str(e) if app.debug else "An internal error occurred"
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': error_message,
|
||||
'component': func.__name__,
|
||||
'request_id': getattr(g, 'request_id', None)
|
||||
}), 500
|
||||
return wrapper
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Apply ProxyFix middleware
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=1,
|
||||
x_proto=1,
|
||||
x_host=1,
|
||||
x_prefix=1
|
||||
)
|
||||
|
||||
# Initialize configuration and secrets
|
||||
init_config(app)
|
||||
init_secrets(app)
|
||||
|
||||
# Initialize database
|
||||
init_db(app)
|
||||
|
||||
# Initialize Redis
|
||||
redis_manager = RedisManager(app)
|
||||
app.redis = redis_manager
|
||||
|
||||
# Initialize Redis-based rate limiter
|
||||
redis_rate_limiter = RedisRateLimiter(redis_manager)
|
||||
app.redis_rate_limiter = redis_rate_limiter
|
||||
|
||||
# Initialize Redis-based session management
|
||||
init_redis_sessions(app)
|
||||
|
||||
# Configure CORS
|
||||
cors_config = {
|
||||
"origins": app.config.get('CORS_ORIGINS', ['*']),
|
||||
"methods": ["GET", "POST", "OPTIONS"],
|
||||
"allow_headers": ["Content-Type", "Authorization", "X-Requested-With", "X-Admin-Token"],
|
||||
"expose_headers": ["Content-Range", "X-Content-Range"],
|
||||
"supports_credentials": True,
|
||||
"max_age": 3600
|
||||
}
|
||||
|
||||
CORS(app, resources={
|
||||
r"/api/*": cors_config,
|
||||
r"/transcribe": cors_config,
|
||||
r"/translate": cors_config,
|
||||
r"/translate/stream": cors_config,
|
||||
r"/speak": cors_config,
|
||||
r"/get_audio/*": cors_config,
|
||||
r"/check_tts_server": cors_config,
|
||||
r"/update_tts_config": cors_config,
|
||||
r"/health/*": cors_config,
|
||||
r"/admin/*": {
|
||||
**cors_config,
|
||||
"origins": app.config.get('ADMIN_CORS_ORIGINS', ['http://localhost:*'])
|
||||
}
|
||||
})
|
||||
|
||||
# Configure upload folder
|
||||
upload_folder = app.config.get('UPLOAD_FOLDER')
|
||||
if not upload_folder:
|
||||
upload_folder = os.path.join(tempfile.gettempdir(), 'talk2me_uploads')
|
||||
|
||||
try:
|
||||
os.makedirs(upload_folder, mode=0o755, exist_ok=True)
|
||||
logger.info(f"Using upload folder: {upload_folder}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create upload folder {upload_folder}: {str(e)}")
|
||||
upload_folder = tempfile.mkdtemp(prefix='talk2me_')
|
||||
logger.warning(f"Falling back to temporary folder: {upload_folder}")
|
||||
|
||||
app.config['UPLOAD_FOLDER'] = upload_folder
|
||||
|
||||
# Initialize request size limiter
|
||||
request_size_limiter = RequestSizeLimiter(app, {
|
||||
'max_content_length': app.config.get('MAX_CONTENT_LENGTH', 50 * 1024 * 1024),
|
||||
'max_audio_size': app.config.get('MAX_AUDIO_SIZE', 25 * 1024 * 1024),
|
||||
'max_json_size': app.config.get('MAX_JSON_SIZE', 1 * 1024 * 1024),
|
||||
'max_image_size': app.config.get('MAX_IMAGE_SIZE', 10 * 1024 * 1024),
|
||||
})
|
||||
|
||||
# Initialize error logging
|
||||
error_logger = ErrorLogger(app, {
|
||||
'log_level': app.config.get('LOG_LEVEL', 'INFO'),
|
||||
'log_file': app.config.get('LOG_FILE', 'logs/talk2me.log'),
|
||||
'error_log_file': app.config.get('ERROR_LOG_FILE', 'logs/errors.log'),
|
||||
'max_bytes': app.config.get('LOG_MAX_BYTES', 50 * 1024 * 1024),
|
||||
'backup_count': app.config.get('LOG_BACKUP_COUNT', 10)
|
||||
})
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Initialize memory management
|
||||
memory_manager = MemoryManager(app, {
|
||||
'memory_threshold_mb': app.config.get('MEMORY_THRESHOLD_MB', 4096),
|
||||
'gpu_memory_threshold_mb': app.config.get('GPU_MEMORY_THRESHOLD_MB', 2048),
|
||||
'cleanup_interval': app.config.get('MEMORY_CLEANUP_INTERVAL', 30)
|
||||
})
|
||||
|
||||
# Initialize Whisper model
|
||||
logger.info("Initializing Whisper model with GPU optimization...")
|
||||
|
||||
if torch.cuda.is_available():
|
||||
device = torch.device("cuda")
|
||||
try:
|
||||
gpu_name = torch.cuda.get_device_name(0)
|
||||
if 'AMD' in gpu_name or 'Radeon' in gpu_name:
|
||||
logger.info(f"AMD GPU detected via ROCm: {gpu_name}")
|
||||
else:
|
||||
logger.info(f"NVIDIA GPU detected: {gpu_name}")
|
||||
except:
|
||||
logger.info("GPU detected - using CUDA/ROCm acceleration")
|
||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
device = torch.device("mps")
|
||||
logger.info("Apple Silicon detected - using Metal Performance Shaders")
|
||||
else:
|
||||
device = torch.device("cpu")
|
||||
logger.info("No GPU acceleration available - using CPU")
|
||||
|
||||
logger.info(f"Using device: {device}")
|
||||
|
||||
whisper_model = whisper.load_model(MODEL_SIZE, device=device)
|
||||
|
||||
# Enable GPU optimizations
|
||||
if device.type == 'cuda':
|
||||
try:
|
||||
torch.backends.cuda.matmul.allow_tf32 = True
|
||||
torch.backends.cudnn.allow_tf32 = True
|
||||
torch.backends.cudnn.benchmark = True
|
||||
whisper_model.eval()
|
||||
whisper_model = whisper_model.half()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
logger.info("Warming up GPU with dummy inference...")
|
||||
with torch.no_grad():
|
||||
dummy_audio = torch.randn(1, 16000 * 30).to(device).half()
|
||||
_ = whisper_model.encode(whisper.pad_or_trim(dummy_audio))
|
||||
|
||||
logger.info(f"GPU memory allocated: {torch.cuda.memory_allocated() / 1024**2:.2f} MB")
|
||||
logger.info("Whisper model loaded and optimized for GPU")
|
||||
except Exception as e:
|
||||
logger.warning(f"Some GPU optimizations failed: {e}")
|
||||
elif device.type == 'mps':
|
||||
whisper_model.eval()
|
||||
logger.info("Whisper model loaded and optimized for Apple Silicon")
|
||||
else:
|
||||
whisper_model.eval()
|
||||
logger.info("Whisper model loaded (CPU mode)")
|
||||
|
||||
memory_manager.set_whisper_model(whisper_model)
|
||||
app.whisper_model = whisper_model
|
||||
|
||||
# Supported languages
|
||||
SUPPORTED_LANGUAGES = {
|
||||
"ar": "Arabic",
|
||||
"hy": "Armenian",
|
||||
"az": "Azerbaijani",
|
||||
"en": "English",
|
||||
"fr": "French",
|
||||
"ka": "Georgian",
|
||||
"kk": "Kazakh",
|
||||
"zh": "Mandarin",
|
||||
"fa": "Farsi",
|
||||
"pt": "Portuguese",
|
||||
"ru": "Russian",
|
||||
"es": "Spanish",
|
||||
"tr": "Turkish",
|
||||
"uz": "Uzbek"
|
||||
}
|
||||
|
||||
LANGUAGE_TO_CODE = {v: k for k, v in SUPPORTED_LANGUAGES.items()}
|
||||
|
||||
LANGUAGE_TO_VOICE = {
|
||||
"Arabic": "ar-EG-ShakirNeural",
|
||||
"Armenian": "echo",
|
||||
"Azerbaijani": "az-AZ-BanuNeural",
|
||||
"English": "en-GB-RyanNeural",
|
||||
"French": "fr-FR-DeniseNeural",
|
||||
"Georgian": "ka-GE-GiorgiNeural",
|
||||
"Kazakh": "kk-KZ-DauletNeural",
|
||||
"Mandarin": "zh-CN-YunjianNeural",
|
||||
"Farsi": "fa-IR-FaridNeural",
|
||||
"Portuguese": "pt-BR-ThalitaNeural",
|
||||
"Russian": "ru-RU-SvetlanaNeural",
|
||||
"Spanish": "es-CR-MariaNeural",
|
||||
"Turkish": "tr-TR-EmelNeural",
|
||||
"Uzbek": "uz-UZ-SardorNeural"
|
||||
}
|
||||
|
||||
# Generate VAPID keys for push notifications
|
||||
if not os.path.exists('vapid_private.pem'):
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
public_key = private_key.public_key()
|
||||
|
||||
with open('vapid_private.pem', 'wb') as f:
|
||||
f.write(private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
))
|
||||
|
||||
with open('vapid_public.pem', 'wb') as f:
|
||||
f.write(public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
))
|
||||
|
||||
with open('vapid_private.pem', 'rb') as f:
|
||||
vapid_private_key = f.read()
|
||||
with open('vapid_public.pem', 'rb') as f:
|
||||
vapid_public_pem = f.read()
|
||||
vapid_public_key = serialization.load_pem_public_key(
|
||||
vapid_public_pem,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
public_numbers = vapid_public_key.public_numbers()
|
||||
x = public_numbers.x.to_bytes(32, byteorder='big')
|
||||
y = public_numbers.y.to_bytes(32, byteorder='big')
|
||||
vapid_public_key_base64 = base64.urlsafe_b64encode(b'\x04' + x + y).decode('utf-8').rstrip('=')
|
||||
|
||||
# Store push subscriptions in Redis instead of memory
|
||||
# push_subscriptions = [] # Removed - now using Redis
|
||||
|
||||
# Temporary file cleanup
|
||||
TEMP_FILE_MAX_AGE = 300
|
||||
CLEANUP_INTERVAL = 60
|
||||
|
||||
def cleanup_temp_files():
|
||||
"""Clean up old temporary files"""
|
||||
try:
|
||||
current_time = datetime.now()
|
||||
|
||||
# Clean files from upload folder
|
||||
if os.path.exists(app.config['UPLOAD_FOLDER']):
|
||||
for filename in os.listdir(app.config['UPLOAD_FOLDER']):
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.isfile(filepath):
|
||||
file_age = current_time - datetime.fromtimestamp(os.path.getmtime(filepath))
|
||||
if file_age > timedelta(seconds=TEMP_FILE_MAX_AGE):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
logger.info(f"Cleaned up file: {filepath}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove file {filepath}: {str(e)}")
|
||||
|
||||
logger.debug("Cleanup completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during temp file cleanup: {str(e)}")
|
||||
|
||||
def run_cleanup_loop():
|
||||
"""Run cleanup in a separate thread"""
|
||||
while True:
|
||||
time.sleep(CLEANUP_INTERVAL)
|
||||
cleanup_temp_files()
|
||||
|
||||
# Start cleanup thread
|
||||
cleanup_thread = threading.Thread(target=run_cleanup_loop, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
# Analytics collection helper
|
||||
def collect_analytics(service: str, duration_ms: int = None, metadata: dict = None):
|
||||
"""Collect usage analytics to database"""
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
hour = datetime.utcnow().hour
|
||||
|
||||
# Get or create analytics record
|
||||
analytics = UsageAnalytics.query.filter_by(date=today, hour=hour).first()
|
||||
if not analytics:
|
||||
analytics = UsageAnalytics(date=today, hour=hour)
|
||||
db.session.add(analytics)
|
||||
|
||||
# Update counters
|
||||
analytics.total_requests += 1
|
||||
|
||||
if service == 'transcription':
|
||||
analytics.transcriptions += 1
|
||||
if duration_ms:
|
||||
if analytics.avg_transcription_time_ms:
|
||||
analytics.avg_transcription_time_ms = (
|
||||
(analytics.avg_transcription_time_ms * (analytics.transcriptions - 1) + duration_ms)
|
||||
/ analytics.transcriptions
|
||||
)
|
||||
else:
|
||||
analytics.avg_transcription_time_ms = duration_ms
|
||||
|
||||
elif service == 'translation':
|
||||
analytics.translations += 1
|
||||
if duration_ms:
|
||||
if analytics.avg_translation_time_ms:
|
||||
analytics.avg_translation_time_ms = (
|
||||
(analytics.avg_translation_time_ms * (analytics.translations - 1) + duration_ms)
|
||||
/ analytics.translations
|
||||
)
|
||||
else:
|
||||
analytics.avg_translation_time_ms = duration_ms
|
||||
|
||||
elif service == 'tts':
|
||||
analytics.tts_requests += 1
|
||||
if duration_ms:
|
||||
if analytics.avg_tts_time_ms:
|
||||
analytics.avg_tts_time_ms = (
|
||||
(analytics.avg_tts_time_ms * (analytics.tts_requests - 1) + duration_ms)
|
||||
/ analytics.tts_requests
|
||||
)
|
||||
else:
|
||||
analytics.avg_tts_time_ms = duration_ms
|
||||
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to collect analytics: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
# Routes
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html', languages=sorted(SUPPORTED_LANGUAGES.values()))
|
||||
|
||||
@app.route('/transcribe', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=10, requests_per_hour=100, check_size=True)
|
||||
@limit_request_size(max_audio_size=25 * 1024 * 1024)
|
||||
@with_error_boundary
|
||||
@log_performance('transcribe_audio')
|
||||
@with_memory_management
|
||||
def transcribe():
|
||||
start_time = time.time()
|
||||
|
||||
with AudioProcessingContext(app.memory_manager, name='transcribe') as ctx:
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({'error': 'No audio file provided'}), 400
|
||||
|
||||
audio_file = request.files['audio']
|
||||
|
||||
valid, error_msg = Validators.validate_audio_file(audio_file)
|
||||
if not valid:
|
||||
return jsonify({'error': error_msg}), 400
|
||||
|
||||
source_lang = request.form.get('source_lang', '')
|
||||
allowed_languages = set(SUPPORTED_LANGUAGES.values())
|
||||
source_lang = Validators.validate_language_code(source_lang, allowed_languages) or ''
|
||||
|
||||
temp_filename = f'input_audio_{int(time.time() * 1000)}.wav'
|
||||
temp_path = os.path.join(app.config['UPLOAD_FOLDER'], temp_filename)
|
||||
|
||||
with open(temp_path, 'wb') as f:
|
||||
audio_file.save(f)
|
||||
|
||||
ctx.add_temp_file(temp_path)
|
||||
|
||||
# Add to Redis session
|
||||
if hasattr(g, 'session_manager') and hasattr(g, 'user_session'):
|
||||
file_size = os.path.getsize(temp_path)
|
||||
g.session_manager.add_resource(
|
||||
session_id=g.user_session.session_id,
|
||||
resource_type='audio_file',
|
||||
resource_id=temp_filename,
|
||||
path=temp_path,
|
||||
size_bytes=file_size,
|
||||
metadata={'filename': temp_filename, 'purpose': 'transcription'}
|
||||
)
|
||||
|
||||
try:
|
||||
auto_detect = source_lang == 'auto' or source_lang == ''
|
||||
|
||||
transcribe_options = {
|
||||
"task": "transcribe",
|
||||
"temperature": 0,
|
||||
"best_of": 1,
|
||||
"beam_size": 1,
|
||||
"fp16": device.type == 'cuda',
|
||||
"condition_on_previous_text": False,
|
||||
"compression_ratio_threshold": 2.4,
|
||||
"logprob_threshold": -1.0,
|
||||
"no_speech_threshold": 0.6
|
||||
}
|
||||
|
||||
if not auto_detect:
|
||||
transcribe_options["language"] = LANGUAGE_TO_CODE.get(source_lang, None)
|
||||
|
||||
if device.type == 'cuda':
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
with torch.no_grad():
|
||||
result = whisper_model.transcribe(
|
||||
temp_path,
|
||||
**transcribe_options
|
||||
)
|
||||
|
||||
transcribed_text = result["text"]
|
||||
|
||||
detected_language = None
|
||||
if auto_detect and 'language' in result:
|
||||
detected_code = result['language']
|
||||
for lang_name, lang_code in LANGUAGE_TO_CODE.items():
|
||||
if lang_code == detected_code:
|
||||
detected_language = lang_name
|
||||
break
|
||||
|
||||
logger.info(f"Auto-detected language: {detected_language} ({detected_code})")
|
||||
|
||||
# Calculate duration
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
transcription = Transcription(
|
||||
session_id=g.user_session.session_id if hasattr(g, 'user_session') else None,
|
||||
user_id=g.user_session.user_id if hasattr(g, 'user_session') else None,
|
||||
transcribed_text=transcribed_text,
|
||||
detected_language=detected_language or source_lang,
|
||||
transcription_time_ms=duration_ms,
|
||||
model_used=MODEL_SIZE,
|
||||
audio_file_size=os.path.getsize(temp_path),
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get('User-Agent')
|
||||
)
|
||||
db.session.add(transcription)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save transcription to database: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
# Collect analytics
|
||||
collect_analytics('transcription', duration_ms)
|
||||
|
||||
# Send notification if push is enabled
|
||||
push_count = redis_manager.scard('push_subscriptions')
|
||||
if push_count > 0:
|
||||
send_push_notification(
|
||||
title="Transcription Complete",
|
||||
body=f"Successfully transcribed: {transcribed_text[:50]}...",
|
||||
tag="transcription-complete"
|
||||
)
|
||||
|
||||
response = {
|
||||
'success': True,
|
||||
'text': transcribed_text
|
||||
}
|
||||
|
||||
if detected_language:
|
||||
response['detected_language'] = detected_language
|
||||
|
||||
return jsonify(response)
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription error: {str(e)}")
|
||||
return jsonify({'error': f'Transcription failed: {str(e)}'}), 500
|
||||
finally:
|
||||
try:
|
||||
if 'temp_path' in locals() and os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clean up temp file: {e}")
|
||||
|
||||
if device.type == 'cuda':
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.synchronize()
|
||||
gc.collect()
|
||||
|
||||
@app.route('/translate', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=20, requests_per_hour=300, check_size=True)
|
||||
@limit_request_size(max_size=1 * 1024 * 1024)
|
||||
@with_error_boundary
|
||||
@log_performance('translate_text')
|
||||
def translate():
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if not Validators.validate_json_size(request.json, max_size_kb=100):
|
||||
return jsonify({'error': 'Request too large'}), 413
|
||||
|
||||
data = request.json
|
||||
|
||||
text = data.get('text', '')
|
||||
text = Validators.sanitize_text(text)
|
||||
if not text:
|
||||
return jsonify({'error': 'No text provided'}), 400
|
||||
|
||||
allowed_languages = set(SUPPORTED_LANGUAGES.values())
|
||||
source_lang = Validators.validate_language_code(
|
||||
data.get('source_lang', ''), allowed_languages
|
||||
) or 'auto'
|
||||
target_lang = Validators.validate_language_code(
|
||||
data.get('target_lang', ''), allowed_languages
|
||||
)
|
||||
|
||||
if not target_lang:
|
||||
return jsonify({'error': 'Invalid target language'}), 400
|
||||
|
||||
# Check cache first
|
||||
cached_translation = redis_manager.get_cached_translation(
|
||||
text, source_lang, target_lang
|
||||
)
|
||||
if cached_translation:
|
||||
logger.info("Translation served from cache")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'translation': cached_translation,
|
||||
'cached': True
|
||||
})
|
||||
|
||||
# Create prompt for translation
|
||||
prompt = f"""
|
||||
Translate the following text from {source_lang} to {target_lang}:
|
||||
|
||||
"{text}"
|
||||
|
||||
Provide only the translation without any additional text.
|
||||
"""
|
||||
|
||||
response = ollama.chat(
|
||||
model="gemma3:27b",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
translated_text = response['message']['content'].strip()
|
||||
|
||||
# Calculate duration
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# Cache the translation
|
||||
redis_manager.cache_translation(
|
||||
text, source_lang, target_lang, translated_text
|
||||
)
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
translation = Translation(
|
||||
session_id=g.user_session.session_id if hasattr(g, 'user_session') else None,
|
||||
user_id=g.user_session.user_id if hasattr(g, 'user_session') else None,
|
||||
source_text=text,
|
||||
source_language=source_lang,
|
||||
target_text=translated_text,
|
||||
target_language=target_lang,
|
||||
translation_time_ms=duration_ms,
|
||||
model_used="gemma3:27b",
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get('User-Agent')
|
||||
)
|
||||
db.session.add(translation)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save translation to database: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
# Collect analytics
|
||||
collect_analytics('translation', duration_ms)
|
||||
|
||||
# Send notification
|
||||
push_count = redis_manager.scard('push_subscriptions')
|
||||
if push_count > 0:
|
||||
send_push_notification(
|
||||
title="Translation Complete",
|
||||
body=f"Translated from {source_lang} to {target_lang}",
|
||||
tag="translation-complete",
|
||||
data={'translation': translated_text[:100]}
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'translation': translated_text
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Translation error: {str(e)}")
|
||||
return jsonify({'error': f'Translation failed: {str(e)}'}), 500
|
||||
|
||||
@app.route('/api/push-subscribe', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=10, requests_per_hour=50)
|
||||
def push_subscribe():
|
||||
try:
|
||||
subscription = request.json
|
||||
# Store subscription in Redis
|
||||
subscription_id = f"sub_{int(time.time() * 1000)}"
|
||||
redis_manager.set(f"push_subscription:{subscription_id}", subscription, expire=86400 * 30) # 30 days
|
||||
redis_manager.sadd('push_subscriptions', subscription_id)
|
||||
|
||||
logger.info(f"New push subscription registered: {subscription_id}")
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register push subscription: {str(e)}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def send_push_notification(title, body, icon='/static/icons/icon-192x192.png',
|
||||
badge='/static/icons/icon-192x192.png', tag=None, data=None):
|
||||
"""Send push notification to all subscribed clients"""
|
||||
claims = {
|
||||
"sub": "mailto:admin@talk2me.app",
|
||||
"exp": int(time.time()) + 86400
|
||||
}
|
||||
|
||||
notification_sent = 0
|
||||
|
||||
# Get all subscription IDs from Redis
|
||||
subscription_ids = redis_manager.smembers('push_subscriptions')
|
||||
|
||||
for sub_id in subscription_ids:
|
||||
subscription = redis_manager.get(f"push_subscription:{sub_id}")
|
||||
if not subscription:
|
||||
continue
|
||||
|
||||
try:
|
||||
webpush(
|
||||
subscription_info=subscription,
|
||||
data=json.dumps({
|
||||
'title': title,
|
||||
'body': body,
|
||||
'icon': icon,
|
||||
'badge': badge,
|
||||
'tag': tag or 'talk2me-notification',
|
||||
'data': data or {}
|
||||
}),
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_claims=claims
|
||||
)
|
||||
notification_sent += 1
|
||||
except WebPushException as e:
|
||||
logger.error(f"Failed to send push notification: {str(e)}")
|
||||
if e.response and e.response.status_code == 410:
|
||||
# Remove invalid subscription
|
||||
redis_manager.delete(f"push_subscription:{sub_id}")
|
||||
redis_manager.srem('push_subscriptions', sub_id)
|
||||
|
||||
logger.info(f"Sent {notification_sent} push notifications")
|
||||
return notification_sent
|
||||
|
||||
# Initialize app
|
||||
app.start_time = time.time()
|
||||
app.request_count = 0
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
app.request_count = getattr(app, 'request_count', 0) + 1
|
||||
|
||||
# Error handlers
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error):
|
||||
logger.warning(f"404 error: {request.url}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Resource not found',
|
||||
'status': 404
|
||||
}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
logger.error(f"500 error: {str(error)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Internal server error',
|
||||
'status': 500
|
||||
}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5005, debug=True)
|
||||
476
auth.py
Normal file
476
auth.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""Authentication and authorization utilities for Talk2Me"""
|
||||
import os
|
||||
import uuid
|
||||
import functools
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any, Callable, Union, List
|
||||
from flask import request, jsonify, g, current_app
|
||||
from flask_jwt_extended import (
|
||||
JWTManager, create_access_token, create_refresh_token,
|
||||
get_jwt_identity, jwt_required, get_jwt, verify_jwt_in_request
|
||||
)
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from database import db
|
||||
from auth_models import User, LoginHistory, UserSession, RevokedToken, bcrypt
|
||||
from error_logger import log_exception
|
||||
|
||||
# Initialize JWT Manager
|
||||
jwt = JWTManager()
|
||||
|
||||
|
||||
def init_auth(app):
|
||||
"""Initialize authentication system with app"""
|
||||
# Configure JWT
|
||||
app.config['JWT_SECRET_KEY'] = app.config.get('JWT_SECRET_KEY', os.environ.get('JWT_SECRET_KEY', 'your-secret-key-change-in-production'))
|
||||
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(hours=1)
|
||||
app.config['JWT_REFRESH_TOKEN_EXPIRES'] = timedelta(days=30)
|
||||
app.config['JWT_ALGORITHM'] = 'HS256'
|
||||
app.config['JWT_BLACKLIST_ENABLED'] = True
|
||||
app.config['JWT_BLACKLIST_TOKEN_CHECKS'] = ['access', 'refresh']
|
||||
|
||||
# Initialize JWT manager
|
||||
jwt.init_app(app)
|
||||
|
||||
# Initialize bcrypt
|
||||
bcrypt.init_app(app)
|
||||
|
||||
# Register JWT callbacks
|
||||
@jwt.token_in_blocklist_loader
|
||||
def check_if_token_revoked(jwt_header, jwt_payload):
|
||||
jti = jwt_payload["jti"]
|
||||
return RevokedToken.is_token_revoked(jti)
|
||||
|
||||
@jwt.expired_token_loader
|
||||
def expired_token_callback(jwt_header, jwt_payload):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Token has expired',
|
||||
'code': 'token_expired'
|
||||
}), 401
|
||||
|
||||
@jwt.invalid_token_loader
|
||||
def invalid_token_callback(error):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid token',
|
||||
'code': 'invalid_token'
|
||||
}), 401
|
||||
|
||||
@jwt.unauthorized_loader
|
||||
def missing_token_callback(error):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Authorization required',
|
||||
'code': 'authorization_required'
|
||||
}), 401
|
||||
|
||||
@jwt.revoked_token_loader
|
||||
def revoked_token_callback(jwt_header, jwt_payload):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Token has been revoked',
|
||||
'code': 'token_revoked'
|
||||
}), 401
|
||||
|
||||
|
||||
def create_user(email: str, username: str, password: str, full_name: Optional[str] = None,
|
||||
role: str = 'user', is_verified: bool = False) -> tuple[Optional[User], Optional[str]]:
|
||||
"""Create a new user account"""
|
||||
try:
|
||||
# Check if user already exists
|
||||
if User.query.filter((User.email == email) | (User.username == username)).first():
|
||||
return None, "User with this email or username already exists"
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
full_name=full_name,
|
||||
role=role,
|
||||
is_verified=is_verified
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return user, None
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return None, "User with this email or username already exists"
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_exception(e, "Failed to create user")
|
||||
return None, "Failed to create user account"
|
||||
|
||||
|
||||
def authenticate_user(username_or_email: str, password: str) -> tuple[Optional[User], Optional[str]]:
|
||||
"""Authenticate user with username/email and password"""
|
||||
# Find user by username or email
|
||||
user = User.query.filter(
|
||||
(User.username == username_or_email) | (User.email == username_or_email)
|
||||
).first()
|
||||
|
||||
if not user:
|
||||
return None, "Invalid credentials"
|
||||
|
||||
# Check if user can login
|
||||
can_login, reason = user.can_login()
|
||||
if not can_login:
|
||||
user.record_login_attempt(False)
|
||||
db.session.commit()
|
||||
return None, reason
|
||||
|
||||
# Verify password
|
||||
if not user.check_password(password):
|
||||
user.record_login_attempt(False)
|
||||
db.session.commit()
|
||||
return None, "Invalid credentials"
|
||||
|
||||
# Success
|
||||
user.record_login_attempt(True)
|
||||
db.session.commit()
|
||||
|
||||
return user, None
|
||||
|
||||
|
||||
def authenticate_api_key(api_key: str) -> tuple[Optional[User], Optional[str]]:
|
||||
"""Authenticate user with API key"""
|
||||
user = User.query.filter_by(api_key=api_key).first()
|
||||
|
||||
if not user:
|
||||
return None, "Invalid API key"
|
||||
|
||||
# Check if user can login
|
||||
can_login, reason = user.can_login()
|
||||
if not can_login:
|
||||
return None, reason
|
||||
|
||||
# Update last active
|
||||
user.last_active_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return user, None
|
||||
|
||||
|
||||
def create_tokens(user: User, session_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Create JWT tokens for user"""
|
||||
# Generate JTIs
|
||||
access_jti = str(uuid.uuid4())
|
||||
refresh_jti = str(uuid.uuid4())
|
||||
|
||||
# Create tokens with custom claims
|
||||
identity = str(user.id)
|
||||
additional_claims = {
|
||||
'username': user.username,
|
||||
'role': user.role,
|
||||
'permissions': user.permissions or [],
|
||||
'session_id': session_id
|
||||
}
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=identity,
|
||||
additional_claims=additional_claims,
|
||||
fresh=True
|
||||
)
|
||||
|
||||
refresh_token = create_refresh_token(
|
||||
identity=identity,
|
||||
additional_claims={'session_id': session_id}
|
||||
)
|
||||
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': current_app.config['JWT_ACCESS_TOKEN_EXPIRES'].total_seconds()
|
||||
}
|
||||
|
||||
|
||||
def create_user_session(user: User, request_info: Dict[str, Any]) -> UserSession:
|
||||
"""Create a new user session"""
|
||||
session = UserSession(
|
||||
session_id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
ip_address=request_info.get('ip_address'),
|
||||
user_agent=request_info.get('user_agent'),
|
||||
expires_at=datetime.utcnow() + timedelta(days=30)
|
||||
)
|
||||
|
||||
db.session.add(session)
|
||||
db.session.commit()
|
||||
|
||||
return session
|
||||
|
||||
|
||||
def log_login_attempt(user_id: Optional[uuid.UUID], success: bool, method: str,
|
||||
failure_reason: Optional[str] = None, session_id: Optional[str] = None,
|
||||
jwt_jti: Optional[str] = None) -> LoginHistory:
|
||||
"""Log a login attempt"""
|
||||
login_record = LoginHistory(
|
||||
user_id=user_id,
|
||||
login_method=method,
|
||||
success=success,
|
||||
failure_reason=failure_reason,
|
||||
session_id=session_id,
|
||||
jwt_jti=jwt_jti,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get('User-Agent')
|
||||
)
|
||||
|
||||
db.session.add(login_record)
|
||||
db.session.commit()
|
||||
|
||||
return login_record
|
||||
|
||||
|
||||
def revoke_token(jti: str, token_type: str, user_id: Optional[uuid.UUID] = None,
|
||||
reason: Optional[str] = None, expires_at: Optional[datetime] = None):
|
||||
"""Revoke a JWT token"""
|
||||
if not expires_at:
|
||||
# Default expiration based on token type
|
||||
if token_type == 'access':
|
||||
expires_at = datetime.utcnow() + current_app.config['JWT_ACCESS_TOKEN_EXPIRES']
|
||||
else:
|
||||
expires_at = datetime.utcnow() + current_app.config['JWT_REFRESH_TOKEN_EXPIRES']
|
||||
|
||||
revoked = RevokedToken(
|
||||
jti=jti,
|
||||
token_type=token_type,
|
||||
user_id=user_id,
|
||||
reason=reason,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
db.session.add(revoked)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def get_current_user() -> Optional[User]:
|
||||
"""Get current authenticated user from JWT, API key, or session"""
|
||||
# Try JWT first
|
||||
try:
|
||||
verify_jwt_in_request(optional=True)
|
||||
user_id = get_jwt_identity()
|
||||
if user_id:
|
||||
user = User.query.get(user_id)
|
||||
if user and user.is_active and not user.is_suspended_now:
|
||||
# Update last active
|
||||
user.last_active_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return user
|
||||
except:
|
||||
pass
|
||||
|
||||
# Try API key from header
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if api_key:
|
||||
user, _ = authenticate_api_key(api_key)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Try API key from query parameter
|
||||
api_key = request.args.get('api_key')
|
||||
if api_key:
|
||||
user, _ = authenticate_api_key(api_key)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Try session-based authentication (for admin panel)
|
||||
from flask import session
|
||||
if session.get('logged_in') and session.get('user_id'):
|
||||
# Check if it's the admin token user
|
||||
if session.get('user_id') == 'admin-token-user' and session.get('user_role') == 'admin':
|
||||
# Create a pseudo-admin user for session-based admin access
|
||||
admin_user = User.query.filter_by(role='admin').first()
|
||||
if admin_user:
|
||||
return admin_user
|
||||
else:
|
||||
# Create a temporary admin user object (not saved to DB)
|
||||
admin_user = User(
|
||||
id=uuid.uuid4(),
|
||||
username='admin',
|
||||
email='admin@talk2me.local',
|
||||
role='admin',
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
is_suspended=False,
|
||||
total_requests=0,
|
||||
total_translations=0,
|
||||
total_transcriptions=0,
|
||||
total_tts_requests=0
|
||||
)
|
||||
# Don't add to session, just return for authorization
|
||||
return admin_user
|
||||
else:
|
||||
# Regular user session
|
||||
user = User.query.get(session.get('user_id'))
|
||||
if user and user.is_active and not user.is_suspended_now:
|
||||
# Update last active
|
||||
user.last_active_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def require_auth(f: Callable) -> Callable:
|
||||
"""Decorator to require authentication (JWT, API key, or session)"""
|
||||
@functools.wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Authentication required',
|
||||
'code': 'auth_required'
|
||||
}), 401
|
||||
|
||||
# Store user in g for access in route
|
||||
g.current_user = user
|
||||
|
||||
# Track usage only for database-backed users
|
||||
try:
|
||||
if hasattr(user, 'id') and db.session.query(User).filter_by(id=user.id).first():
|
||||
user.total_requests += 1
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
# Ignore tracking errors for temporary users
|
||||
pass
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_admin(f: Callable) -> Callable:
|
||||
"""Decorator to require admin role"""
|
||||
@functools.wraps(f)
|
||||
@require_auth
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not g.current_user.is_admin:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Admin access required',
|
||||
'code': 'admin_required'
|
||||
}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_permission(permission: str) -> Callable:
|
||||
"""Decorator to require specific permission"""
|
||||
def decorator(f: Callable) -> Callable:
|
||||
@functools.wraps(f)
|
||||
@require_auth
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not g.current_user.has_permission(permission):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Permission required: {permission}',
|
||||
'code': 'permission_denied'
|
||||
}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_verified(f: Callable) -> Callable:
|
||||
"""Decorator to require verified email"""
|
||||
@functools.wraps(f)
|
||||
@require_auth
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not g.current_user.is_verified:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Email verification required',
|
||||
'code': 'verification_required'
|
||||
}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def get_user_rate_limits(user: User) -> Dict[str, int]:
|
||||
"""Get user-specific rate limits"""
|
||||
return {
|
||||
'per_minute': user.rate_limit_per_minute,
|
||||
'per_hour': user.rate_limit_per_hour,
|
||||
'per_day': user.rate_limit_per_day
|
||||
}
|
||||
|
||||
|
||||
def check_user_rate_limit(user: User, endpoint: str) -> tuple[bool, Optional[str]]:
|
||||
"""Check if user has exceeded rate limits"""
|
||||
# This would integrate with the existing rate limiter
|
||||
# For now, return True to allow requests
|
||||
return True, None
|
||||
|
||||
|
||||
def update_user_usage_stats(user: User, operation: str) -> None:
|
||||
"""Update user usage statistics"""
|
||||
user.total_requests += 1
|
||||
|
||||
if operation == 'translation':
|
||||
user.total_translations += 1
|
||||
elif operation == 'transcription':
|
||||
user.total_transcriptions += 1
|
||||
elif operation == 'tts':
|
||||
user.total_tts_requests += 1
|
||||
|
||||
user.last_active_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def cleanup_expired_sessions() -> int:
|
||||
"""Clean up expired user sessions"""
|
||||
deleted = UserSession.query.filter(
|
||||
UserSession.expires_at < datetime.utcnow()
|
||||
).delete()
|
||||
db.session.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
def cleanup_expired_tokens() -> int:
|
||||
"""Clean up expired revoked tokens"""
|
||||
return RevokedToken.cleanup_expired()
|
||||
|
||||
|
||||
def get_user_sessions(user_id: Union[str, uuid.UUID]) -> List[UserSession]:
|
||||
"""Get all active sessions for a user"""
|
||||
return UserSession.query.filter_by(
|
||||
user_id=user_id
|
||||
).filter(
|
||||
UserSession.expires_at > datetime.utcnow()
|
||||
).order_by(UserSession.last_active_at.desc()).all()
|
||||
|
||||
|
||||
def revoke_user_sessions(user_id: Union[str, uuid.UUID], except_session: Optional[str] = None) -> int:
|
||||
"""Revoke all sessions for a user"""
|
||||
sessions = UserSession.query.filter_by(user_id=user_id)
|
||||
|
||||
if except_session:
|
||||
sessions = sessions.filter(UserSession.session_id != except_session)
|
||||
|
||||
count = 0
|
||||
for session in sessions:
|
||||
# Revoke associated tokens
|
||||
if session.access_token_jti:
|
||||
revoke_token(session.access_token_jti, 'access', user_id, 'Session revoked')
|
||||
if session.refresh_token_jti:
|
||||
revoke_token(session.refresh_token_jti, 'refresh', user_id, 'Session revoked')
|
||||
count += 1
|
||||
|
||||
# Delete sessions
|
||||
sessions.delete()
|
||||
db.session.commit()
|
||||
|
||||
return count
|
||||
366
auth_models.py
Normal file
366
auth_models.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Authentication models for Talk2Me application"""
|
||||
import uuid
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_bcrypt import Bcrypt
|
||||
from sqlalchemy import Index, text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB, ENUM
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from database import db
|
||||
|
||||
bcrypt = Bcrypt()
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""User account model with authentication and authorization"""
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
|
||||
# User profile
|
||||
full_name = db.Column(db.String(255), nullable=True)
|
||||
avatar_url = db.Column(db.String(500), nullable=True)
|
||||
|
||||
# API Key - unique per user
|
||||
api_key = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
api_key_created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Account status
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
is_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
is_suspended = db.Column(db.Boolean, default=False, nullable=False)
|
||||
suspension_reason = db.Column(db.Text, nullable=True)
|
||||
suspended_at = db.Column(db.DateTime, nullable=True)
|
||||
suspended_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Role and permissions
|
||||
role = db.Column(db.String(20), nullable=False, default='user') # admin, user
|
||||
permissions = db.Column(JSONB, default=[], nullable=False) # Additional granular permissions
|
||||
|
||||
# Usage limits (per user)
|
||||
rate_limit_per_minute = db.Column(db.Integer, default=30, nullable=False)
|
||||
rate_limit_per_hour = db.Column(db.Integer, default=500, nullable=False)
|
||||
rate_limit_per_day = db.Column(db.Integer, default=5000, nullable=False)
|
||||
|
||||
# Usage tracking
|
||||
total_requests = db.Column(db.Integer, default=0, nullable=False)
|
||||
total_translations = db.Column(db.Integer, default=0, nullable=False)
|
||||
total_transcriptions = db.Column(db.Integer, default=0, nullable=False)
|
||||
total_tts_requests = db.Column(db.Integer, default=0, nullable=False)
|
||||
|
||||
# Timestamps
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||
last_active_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Security
|
||||
password_changed_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
failed_login_attempts = db.Column(db.Integer, default=0, nullable=False)
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Settings
|
||||
settings = db.Column(JSONB, default={}, nullable=False)
|
||||
|
||||
# Relationships
|
||||
login_history = relationship('LoginHistory', back_populates='user', cascade='all, delete-orphan')
|
||||
sessions = relationship('UserSession', back_populates='user', cascade='all, delete-orphan')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_users_email_active', 'email', 'is_active'),
|
||||
Index('idx_users_role_active', 'role', 'is_active'),
|
||||
Index('idx_users_created_at', 'created_at'),
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(User, self).__init__(**kwargs)
|
||||
if not self.api_key:
|
||||
self.api_key = self.generate_api_key()
|
||||
|
||||
@staticmethod
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a secure API key"""
|
||||
return f"tk_{secrets.token_urlsafe(32)}"
|
||||
|
||||
def regenerate_api_key(self) -> str:
|
||||
"""Regenerate user's API key"""
|
||||
self.api_key = self.generate_api_key()
|
||||
self.api_key_created_at = datetime.utcnow()
|
||||
return self.api_key
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
"""Hash and set user password"""
|
||||
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
self.password_changed_at = datetime.utcnow()
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""Check if provided password matches hash"""
|
||||
return bcrypt.check_password_hash(self.password_hash, password)
|
||||
|
||||
@hybrid_property
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user has admin role"""
|
||||
return self.role == 'admin'
|
||||
|
||||
@hybrid_property
|
||||
def is_locked(self) -> bool:
|
||||
"""Check if account is locked due to failed login attempts"""
|
||||
if self.locked_until is None:
|
||||
return False
|
||||
return datetime.utcnow() < self.locked_until
|
||||
|
||||
@hybrid_property
|
||||
def is_suspended_now(self) -> bool:
|
||||
"""Check if account is currently suspended"""
|
||||
if not self.is_suspended:
|
||||
return False
|
||||
if self.suspended_until is None:
|
||||
return True # Indefinite suspension
|
||||
return datetime.utcnow() < self.suspended_until
|
||||
|
||||
def can_login(self) -> tuple[bool, Optional[str]]:
|
||||
"""Check if user can login"""
|
||||
if not self.is_active:
|
||||
return False, "Account is deactivated"
|
||||
if self.is_locked:
|
||||
return False, "Account is locked due to failed login attempts"
|
||||
if self.is_suspended_now:
|
||||
return False, f"Account is suspended: {self.suspension_reason or 'Policy violation'}"
|
||||
return True, None
|
||||
|
||||
def record_login_attempt(self, success: bool) -> None:
|
||||
"""Record login attempt and handle lockout"""
|
||||
if success:
|
||||
self.failed_login_attempts = 0
|
||||
self.locked_until = None
|
||||
self.last_login_at = datetime.utcnow()
|
||||
else:
|
||||
self.failed_login_attempts += 1
|
||||
# Lock account after 5 failed attempts
|
||||
if self.failed_login_attempts >= 5:
|
||||
self.locked_until = datetime.utcnow() + timedelta(minutes=30)
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""Check if user has specific permission"""
|
||||
if self.is_admin:
|
||||
return True # Admins have all permissions
|
||||
return permission in (self.permissions or [])
|
||||
|
||||
def add_permission(self, permission: str) -> None:
|
||||
"""Add permission to user"""
|
||||
if self.permissions is None:
|
||||
self.permissions = []
|
||||
if permission not in self.permissions:
|
||||
self.permissions = self.permissions + [permission]
|
||||
|
||||
def remove_permission(self, permission: str) -> None:
|
||||
"""Remove permission from user"""
|
||||
if self.permissions and permission in self.permissions:
|
||||
self.permissions = [p for p in self.permissions if p != permission]
|
||||
|
||||
def suspend(self, reason: str, until: Optional[datetime] = None) -> None:
|
||||
"""Suspend user account"""
|
||||
self.is_suspended = True
|
||||
self.suspension_reason = reason
|
||||
self.suspended_at = datetime.utcnow()
|
||||
self.suspended_until = until
|
||||
|
||||
def unsuspend(self) -> None:
|
||||
"""Unsuspend user account"""
|
||||
self.is_suspended = False
|
||||
self.suspension_reason = None
|
||||
self.suspended_at = None
|
||||
self.suspended_until = None
|
||||
|
||||
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
|
||||
"""Convert user to dictionary"""
|
||||
data = {
|
||||
'id': str(self.id),
|
||||
'email': self.email,
|
||||
'username': self.username,
|
||||
'full_name': self.full_name,
|
||||
'avatar_url': self.avatar_url,
|
||||
'role': self.role,
|
||||
'is_active': self.is_active,
|
||||
'is_verified': self.is_verified,
|
||||
'is_suspended': self.is_suspended_now,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'last_login_at': self.last_login_at.isoformat() if self.last_login_at else None,
|
||||
'last_active_at': self.last_active_at.isoformat() if self.last_active_at else None,
|
||||
'total_requests': self.total_requests,
|
||||
'total_translations': self.total_translations,
|
||||
'total_transcriptions': self.total_transcriptions,
|
||||
'total_tts_requests': self.total_tts_requests,
|
||||
'settings': self.settings or {}
|
||||
}
|
||||
|
||||
if include_sensitive:
|
||||
data.update({
|
||||
'api_key': self.api_key,
|
||||
'api_key_created_at': self.api_key_created_at.isoformat(),
|
||||
'permissions': self.permissions or [],
|
||||
'rate_limit_per_minute': self.rate_limit_per_minute,
|
||||
'rate_limit_per_hour': self.rate_limit_per_hour,
|
||||
'rate_limit_per_day': self.rate_limit_per_day,
|
||||
'suspension_reason': self.suspension_reason,
|
||||
'suspended_until': self.suspended_until.isoformat() if self.suspended_until else None,
|
||||
'failed_login_attempts': self.failed_login_attempts,
|
||||
'is_locked': self.is_locked
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class LoginHistory(db.Model):
|
||||
"""Track user login history for security auditing"""
|
||||
__tablename__ = 'login_history'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# Login details
|
||||
login_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
logout_at = db.Column(db.DateTime, nullable=True)
|
||||
login_method = db.Column(db.String(20), nullable=False) # password, api_key, jwt
|
||||
success = db.Column(db.Boolean, nullable=False)
|
||||
failure_reason = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Session info
|
||||
session_id = db.Column(db.String(255), nullable=True, index=True)
|
||||
jwt_jti = db.Column(db.String(255), nullable=True, index=True) # JWT ID for revocation
|
||||
|
||||
# Client info
|
||||
ip_address = db.Column(db.String(45), nullable=False)
|
||||
user_agent = db.Column(db.String(500), nullable=True)
|
||||
device_info = db.Column(JSONB, nullable=True) # Parsed user agent info
|
||||
|
||||
# Location info (if available)
|
||||
country = db.Column(db.String(2), nullable=True)
|
||||
city = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Security flags
|
||||
is_suspicious = db.Column(db.Boolean, default=False, nullable=False)
|
||||
security_notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationship
|
||||
user = relationship('User', back_populates='login_history')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_login_history_user_time', 'user_id', 'login_at'),
|
||||
Index('idx_login_history_session', 'session_id'),
|
||||
Index('idx_login_history_ip', 'ip_address'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert login history to dictionary"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'user_id': str(self.user_id),
|
||||
'login_at': self.login_at.isoformat(),
|
||||
'logout_at': self.logout_at.isoformat() if self.logout_at else None,
|
||||
'login_method': self.login_method,
|
||||
'success': self.success,
|
||||
'failure_reason': self.failure_reason,
|
||||
'session_id': self.session_id,
|
||||
'ip_address': self.ip_address,
|
||||
'user_agent': self.user_agent,
|
||||
'device_info': self.device_info,
|
||||
'country': self.country,
|
||||
'city': self.city,
|
||||
'is_suspicious': self.is_suspicious,
|
||||
'security_notes': self.security_notes
|
||||
}
|
||||
|
||||
|
||||
class UserSession(db.Model):
|
||||
"""Active user sessions for session management"""
|
||||
__tablename__ = 'user_sessions'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# JWT tokens
|
||||
access_token_jti = db.Column(db.String(255), nullable=True, index=True)
|
||||
refresh_token_jti = db.Column(db.String(255), nullable=True, index=True)
|
||||
|
||||
# Session info
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
last_active_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
# Client info
|
||||
ip_address = db.Column(db.String(45), nullable=False)
|
||||
user_agent = db.Column(db.String(500), nullable=True)
|
||||
|
||||
# Session data
|
||||
data = db.Column(JSONB, default={}, nullable=False)
|
||||
|
||||
# Relationship
|
||||
user = relationship('User', back_populates='sessions')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_user_sessions_user_active', 'user_id', 'expires_at'),
|
||||
Index('idx_user_sessions_token', 'access_token_jti'),
|
||||
)
|
||||
|
||||
@hybrid_property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if session is expired"""
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
def refresh(self, duration_hours: int = 24) -> None:
|
||||
"""Refresh session expiration"""
|
||||
self.last_active_at = datetime.utcnow()
|
||||
self.expires_at = datetime.utcnow() + timedelta(hours=duration_hours)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert session to dictionary"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'session_id': self.session_id,
|
||||
'user_id': str(self.user_id),
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'last_active_at': self.last_active_at.isoformat(),
|
||||
'expires_at': self.expires_at.isoformat(),
|
||||
'is_expired': self.is_expired,
|
||||
'ip_address': self.ip_address,
|
||||
'user_agent': self.user_agent,
|
||||
'data': self.data or {}
|
||||
}
|
||||
|
||||
|
||||
class RevokedToken(db.Model):
|
||||
"""Store revoked JWT tokens for blacklisting"""
|
||||
__tablename__ = 'revoked_tokens'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
jti = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
token_type = db.Column(db.String(20), nullable=False) # access, refresh
|
||||
user_id = db.Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
revoked_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
expires_at = db.Column(db.DateTime, nullable=False) # When token would have expired
|
||||
reason = db.Column(db.String(255), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_revoked_tokens_expires', 'expires_at'),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_token_revoked(cls, jti: str) -> bool:
|
||||
"""Check if a token JTI is revoked"""
|
||||
return cls.query.filter_by(jti=jti).first() is not None
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls) -> int:
|
||||
"""Remove revoked tokens that have expired anyway"""
|
||||
deleted = cls.query.filter(cls.expires_at < datetime.utcnow()).delete()
|
||||
db.session.commit()
|
||||
return deleted
|
||||
930
auth_routes.py
Normal file
930
auth_routes.py
Normal file
@@ -0,0 +1,930 @@
|
||||
"""Authentication and user management routes"""
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from sqlalchemy import or_, func
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from database import db
|
||||
from auth_models import User, LoginHistory, UserSession
|
||||
from auth import (
|
||||
create_user, authenticate_user, create_tokens, create_user_session,
|
||||
revoke_token, get_current_user, require_admin,
|
||||
require_auth, revoke_user_sessions, update_user_usage_stats
|
||||
)
|
||||
from rate_limiter import rate_limit
|
||||
from validators import Validators
|
||||
from error_logger import log_exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
@rate_limit(requests_per_minute=5, requests_per_hour=30)
|
||||
def login():
|
||||
"""User login endpoint"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# Validate input
|
||||
username_or_email = data.get('username') or data.get('email')
|
||||
password = data.get('password')
|
||||
|
||||
if not username_or_email or not password:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Username/email and password required'
|
||||
}), 400
|
||||
|
||||
# Authenticate user
|
||||
user, error = authenticate_user(username_or_email, password)
|
||||
if error:
|
||||
# Log failed attempt
|
||||
login_record = LoginHistory(
|
||||
user_id=None,
|
||||
login_method='password',
|
||||
success=False,
|
||||
failure_reason=error,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get('User-Agent')
|
||||
)
|
||||
db.session.add(login_record)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': error
|
||||
}), 401
|
||||
|
||||
# Create session
|
||||
session = create_user_session(user, {
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent')
|
||||
})
|
||||
|
||||
# Create tokens
|
||||
tokens = create_tokens(user, session.session_id)
|
||||
|
||||
# Note: We can't get JWT payload here since we haven't set the JWT context yet
|
||||
# The session JTI will be updated on the next authenticated request
|
||||
db.session.commit()
|
||||
|
||||
# Log successful login with request info
|
||||
login_record = LoginHistory(
|
||||
user_id=user.id,
|
||||
login_method='password',
|
||||
success=True,
|
||||
session_id=session.session_id,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get('User-Agent')
|
||||
)
|
||||
db.session.add(login_record)
|
||||
db.session.commit()
|
||||
|
||||
# Store user info in Flask session for web access
|
||||
from flask import session as flask_session
|
||||
flask_session['user_id'] = str(user.id)
|
||||
flask_session['username'] = user.username
|
||||
flask_session['user_role'] = user.role
|
||||
flask_session['logged_in'] = True
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': user.to_dict(),
|
||||
'tokens': tokens,
|
||||
'session_id': session.session_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Login error")
|
||||
# In development, show the actual error
|
||||
import os
|
||||
if os.environ.get('FLASK_ENV') == 'development':
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Login failed: {str(e)}'
|
||||
}), 500
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Login failed'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST'])
|
||||
@jwt_required()
|
||||
def logout():
|
||||
"""User logout endpoint"""
|
||||
try:
|
||||
jti = get_jwt()["jti"]
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
# Revoke the access token
|
||||
revoke_token(jti, 'access', user_id, 'User logout')
|
||||
|
||||
# Update login history
|
||||
session_id = get_jwt().get('session_id')
|
||||
if session_id:
|
||||
login_record = LoginHistory.query.filter_by(
|
||||
session_id=session_id,
|
||||
logout_at=None
|
||||
).first()
|
||||
if login_record:
|
||||
login_record.logout_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Successfully logged out'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Logout error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Logout failed'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/refresh', methods=['POST'])
|
||||
@jwt_required(refresh=True)
|
||||
def refresh_token():
|
||||
"""Refresh access token"""
|
||||
try:
|
||||
user_id = get_jwt_identity()
|
||||
user = User.query.get(user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid user'
|
||||
}), 401
|
||||
|
||||
# Check if user can login
|
||||
can_login, reason = user.can_login()
|
||||
if not can_login:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': reason
|
||||
}), 401
|
||||
|
||||
# Create new access token
|
||||
session_id = get_jwt().get('session_id')
|
||||
tokens = create_tokens(user, session_id)
|
||||
|
||||
# Update session if exists
|
||||
if session_id:
|
||||
session = UserSession.query.filter_by(session_id=session_id).first()
|
||||
if session:
|
||||
session.refresh()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'access_token': tokens['access_token'],
|
||||
'expires_in': tokens['expires_in']
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Token refresh error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Token refresh failed'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/profile', methods=['GET'])
|
||||
@require_auth
|
||||
def get_profile():
|
||||
"""Get current user profile"""
|
||||
try:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': g.current_user.to_dict(include_sensitive=True)
|
||||
})
|
||||
except Exception as e:
|
||||
log_exception(e, "Profile fetch error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to fetch profile'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/profile', methods=['PUT'])
|
||||
@require_auth
|
||||
def update_profile():
|
||||
"""Update user profile"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
user = g.current_user
|
||||
|
||||
# Update allowed fields
|
||||
if 'full_name' in data:
|
||||
user.full_name = Validators.sanitize_text(data['full_name'], max_length=255)
|
||||
|
||||
if 'avatar_url' in data:
|
||||
validated_url = Validators.validate_url(data['avatar_url'])
|
||||
if validated_url:
|
||||
user.avatar_url = validated_url
|
||||
|
||||
if 'settings' in data and isinstance(data['settings'], dict):
|
||||
user.settings = {**user.settings, **data['settings']}
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': user.to_dict(include_sensitive=True)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Profile update error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to update profile'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/change-password', methods=['POST'])
|
||||
@require_auth
|
||||
def change_password():
|
||||
"""Change user password"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
user = g.current_user
|
||||
|
||||
current_password = data.get('current_password')
|
||||
new_password = data.get('new_password')
|
||||
|
||||
if not current_password or not new_password:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Current and new passwords required'
|
||||
}), 400
|
||||
|
||||
# Verify current password
|
||||
if not user.check_password(current_password):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid current password'
|
||||
}), 401
|
||||
|
||||
# Validate new password
|
||||
if len(new_password) < 8:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Password must be at least 8 characters'
|
||||
}), 400
|
||||
|
||||
# Update password
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
|
||||
# Revoke all sessions except current
|
||||
session_id = get_jwt().get('session_id') if hasattr(g, 'jwt_payload') else None
|
||||
revoked_count = revoke_user_sessions(user.id, except_session=session_id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Password changed successfully',
|
||||
'revoked_sessions': revoked_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Password change error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to change password'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/regenerate-api-key', methods=['POST'])
|
||||
@require_auth
|
||||
def regenerate_api_key():
|
||||
"""Regenerate user's API key"""
|
||||
try:
|
||||
user = g.current_user
|
||||
new_key = user.regenerate_api_key()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'api_key': new_key,
|
||||
'created_at': user.api_key_created_at.isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "API key regeneration error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to regenerate API key'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/sessions', methods=['GET'])
|
||||
@require_auth
|
||||
def get_user_sessions():
|
||||
"""Get user's active sessions"""
|
||||
try:
|
||||
sessions = UserSession.query.filter_by(
|
||||
user_id=g.current_user.id
|
||||
).filter(
|
||||
UserSession.expires_at > datetime.utcnow()
|
||||
).order_by(UserSession.last_active_at.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'sessions': [s.to_dict() for s in sessions]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Sessions fetch error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to fetch sessions'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/sessions/<session_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def revoke_session(session_id):
|
||||
"""Revoke a specific session"""
|
||||
try:
|
||||
session = UserSession.query.filter_by(
|
||||
session_id=session_id,
|
||||
user_id=g.current_user.id
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Session not found'
|
||||
}), 404
|
||||
|
||||
# Revoke tokens
|
||||
if session.access_token_jti:
|
||||
revoke_token(session.access_token_jti, 'access', g.current_user.id, 'Session revoked by user')
|
||||
if session.refresh_token_jti:
|
||||
revoke_token(session.refresh_token_jti, 'refresh', g.current_user.id, 'Session revoked by user')
|
||||
|
||||
# Delete session
|
||||
db.session.delete(session)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Session revoked successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Session revocation error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to revoke session'
|
||||
}), 500
|
||||
|
||||
|
||||
# Admin endpoints for user management
|
||||
|
||||
@auth_bp.route('/admin/users', methods=['GET'])
|
||||
@require_admin
|
||||
def admin_list_users():
|
||||
"""List all users (admin only)"""
|
||||
try:
|
||||
# Get query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
search = request.args.get('search', '')
|
||||
role = request.args.get('role')
|
||||
status = request.args.get('status')
|
||||
sort_by = request.args.get('sort_by', 'created_at')
|
||||
sort_order = request.args.get('sort_order', 'desc')
|
||||
|
||||
# Build query
|
||||
query = User.query
|
||||
|
||||
# Debug logging
|
||||
logger.info(f"Admin user list query parameters: page={page}, per_page={per_page}, search={search}, role={role}, status={status}, sort_by={sort_by}, sort_order={sort_order}")
|
||||
|
||||
# Search filter
|
||||
if search:
|
||||
search_term = f'%{search}%'
|
||||
query = query.filter(or_(
|
||||
User.email.ilike(search_term),
|
||||
User.username.ilike(search_term),
|
||||
User.full_name.ilike(search_term)
|
||||
))
|
||||
logger.info(f"Applied search filter: {search_term}")
|
||||
|
||||
# Role filter
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
logger.info(f"Applied role filter: {role}")
|
||||
|
||||
# Status filter
|
||||
if status == 'active':
|
||||
query = query.filter(User.is_active == True, User.is_suspended == False)
|
||||
logger.info(f"Applied status filter: active")
|
||||
elif status == 'suspended':
|
||||
query = query.filter(User.is_suspended == True)
|
||||
logger.info(f"Applied status filter: suspended")
|
||||
elif status == 'inactive':
|
||||
query = query.filter(User.is_active == False)
|
||||
logger.info(f"Applied status filter: inactive")
|
||||
|
||||
# Sorting
|
||||
order_column = getattr(User, sort_by, User.created_at)
|
||||
if sort_order == 'desc':
|
||||
query = query.order_by(order_column.desc())
|
||||
else:
|
||||
query = query.order_by(order_column.asc())
|
||||
logger.info(f"Applied sorting: {sort_by} {sort_order}")
|
||||
|
||||
# Log the SQL query being generated
|
||||
try:
|
||||
sql = str(query.statement.compile(compile_kwargs={"literal_binds": True}))
|
||||
logger.info(f"Generated SQL query: {sql}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not log SQL query: {e}")
|
||||
|
||||
# Count total results before pagination
|
||||
total_count = query.count()
|
||||
logger.info(f"Total users matching query (before pagination): {total_count}")
|
||||
|
||||
# Get all users without pagination for debugging
|
||||
all_matching_users = query.all()
|
||||
logger.info(f"All matching users: {[u.username for u in all_matching_users[:10]]}") # Log first 10 usernames
|
||||
|
||||
# Paginate
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
# Debug logging for results
|
||||
logger.info(f"Query returned {pagination.total} total users, showing {len(pagination.items)} on page {pagination.page}")
|
||||
logger.info(f"Pagination items: {[u.username for u in pagination.items]}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'users': [u.to_dict(include_sensitive=True) for u in pagination.items],
|
||||
'pagination': {
|
||||
'page': pagination.page,
|
||||
'per_page': pagination.per_page,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin user list error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to fetch users'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/admin/users', methods=['POST'])
|
||||
@require_admin
|
||||
def admin_create_user():
|
||||
"""Create a new user (admin only)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
email = data.get('email')
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
if not email or not username or not password:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Email, username, and password are required'
|
||||
}), 400
|
||||
|
||||
# Validate email
|
||||
if not Validators.validate_email(email):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid email address'
|
||||
}), 400
|
||||
|
||||
# Create user
|
||||
user, error = create_user(
|
||||
email=email,
|
||||
username=username,
|
||||
password=password,
|
||||
full_name=data.get('full_name'),
|
||||
role=data.get('role', 'user'),
|
||||
is_verified=data.get('is_verified', False)
|
||||
)
|
||||
|
||||
if error:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': error
|
||||
}), 400
|
||||
|
||||
# Set additional properties
|
||||
if 'rate_limit_per_minute' in data:
|
||||
user.rate_limit_per_minute = data['rate_limit_per_minute']
|
||||
if 'rate_limit_per_hour' in data:
|
||||
user.rate_limit_per_hour = data['rate_limit_per_hour']
|
||||
if 'rate_limit_per_day' in data:
|
||||
user.rate_limit_per_day = data['rate_limit_per_day']
|
||||
if 'permissions' in data:
|
||||
user.permissions = data['permissions']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': user.to_dict(include_sensitive=True)
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin user creation error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to create user'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/admin/users/<user_id>', methods=['GET'])
|
||||
@require_admin
|
||||
def admin_get_user(user_id):
|
||||
"""Get user details (admin only)"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'User not found'
|
||||
}), 404
|
||||
|
||||
# Get additional info
|
||||
login_history = LoginHistory.query.filter_by(
|
||||
user_id=user.id
|
||||
).order_by(LoginHistory.login_at.desc()).limit(10).all()
|
||||
|
||||
active_sessions = UserSession.query.filter_by(
|
||||
user_id=user.id
|
||||
).filter(
|
||||
UserSession.expires_at > datetime.utcnow()
|
||||
).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': user.to_dict(include_sensitive=True),
|
||||
'login_history': [l.to_dict() for l in login_history],
|
||||
'active_sessions': [s.to_dict() for s in active_sessions]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin user fetch error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to fetch user'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/admin/users/<user_id>', methods=['PUT'])
|
||||
@require_admin
|
||||
def admin_update_user(user_id):
|
||||
"""Update user (admin only)"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'User not found'
|
||||
}), 404
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
# Update allowed fields
|
||||
if 'email' in data:
|
||||
if Validators.validate_email(data['email']):
|
||||
user.email = data['email']
|
||||
|
||||
if 'username' in data:
|
||||
user.username = data['username']
|
||||
|
||||
if 'full_name' in data:
|
||||
user.full_name = data['full_name']
|
||||
|
||||
if 'role' in data and data['role'] in ['admin', 'user']:
|
||||
user.role = data['role']
|
||||
|
||||
if 'is_active' in data:
|
||||
user.is_active = data['is_active']
|
||||
|
||||
if 'is_verified' in data:
|
||||
user.is_verified = data['is_verified']
|
||||
|
||||
if 'permissions' in data:
|
||||
user.permissions = data['permissions']
|
||||
|
||||
if 'rate_limit_per_minute' in data:
|
||||
user.rate_limit_per_minute = data['rate_limit_per_minute']
|
||||
|
||||
if 'rate_limit_per_hour' in data:
|
||||
user.rate_limit_per_hour = data['rate_limit_per_hour']
|
||||
|
||||
if 'rate_limit_per_day' in data:
|
||||
user.rate_limit_per_day = data['rate_limit_per_day']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': user.to_dict(include_sensitive=True)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin user update error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to update user'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/admin/users/<user_id>', methods=['DELETE'])
|
||||
@require_admin
|
||||
def admin_delete_user(user_id):
|
||||
"""Delete user (admin only)"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'User not found'
|
||||
}), 404
|
||||
|
||||
# Don't allow deleting admin users
|
||||
if user.is_admin:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Cannot delete admin users'
|
||||
}), 403
|
||||
|
||||
# Revoke all sessions
|
||||
revoke_user_sessions(user.id)
|
||||
|
||||
# Delete user (cascades to related records)
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'User deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin user deletion error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to delete user'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/admin/users/<user_id>/suspend', methods=['POST'])
|
||||
@require_admin
|
||||
def admin_suspend_user(user_id):
|
||||
"""Suspend user account (admin only)"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'User not found'
|
||||
}), 404
|
||||
|
||||
data = request.get_json()
|
||||
reason = data.get('reason', 'Policy violation')
|
||||
until = data.get('until') # ISO datetime string or None for indefinite
|
||||
|
||||
# Parse until date if provided
|
||||
suspend_until = None
|
||||
if until:
|
||||
try:
|
||||
suspend_until = datetime.fromisoformat(until.replace('Z', '+00:00'))
|
||||
except:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid date format for until'
|
||||
}), 400
|
||||
|
||||
# Suspend user
|
||||
user.suspend(reason, suspend_until)
|
||||
|
||||
# Revoke all sessions
|
||||
revoked_count = revoke_user_sessions(user.id)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'User suspended successfully',
|
||||
'revoked_sessions': revoked_count,
|
||||
'suspended_until': suspend_until.isoformat() if suspend_until else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin user suspension error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to suspend user'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/admin/users/<user_id>/unsuspend', methods=['POST'])
|
||||
@require_admin
|
||||
def admin_unsuspend_user(user_id):
|
||||
"""Unsuspend user account (admin only)"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'User not found'
|
||||
}), 404
|
||||
|
||||
user.unsuspend()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'User unsuspended successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin user unsuspension error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to unsuspend user'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/admin/users/<user_id>/reset-password', methods=['POST'])
|
||||
@require_admin
|
||||
def admin_reset_password(user_id):
|
||||
"""Reset user password (admin only)"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'User not found'
|
||||
}), 404
|
||||
|
||||
data = request.get_json()
|
||||
new_password = data.get('password')
|
||||
|
||||
if not new_password or len(new_password) < 8:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Password must be at least 8 characters'
|
||||
}), 400
|
||||
|
||||
# Reset password
|
||||
user.set_password(new_password)
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
|
||||
# Revoke all sessions
|
||||
revoked_count = revoke_user_sessions(user.id)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Password reset successfully',
|
||||
'revoked_sessions': revoked_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin password reset error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to reset password'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/admin/users/bulk', methods=['POST'])
|
||||
@require_admin
|
||||
def admin_bulk_operation():
|
||||
"""Perform bulk operations on users (admin only)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
user_ids = data.get('user_ids', [])
|
||||
operation = data.get('operation')
|
||||
|
||||
if not user_ids or not operation:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'User IDs and operation required'
|
||||
}), 400
|
||||
|
||||
# Get users
|
||||
users = User.query.filter(User.id.in_(user_ids)).all()
|
||||
|
||||
if not users:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No users found'
|
||||
}), 404
|
||||
|
||||
results = {
|
||||
'success': 0,
|
||||
'failed': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
if operation == 'suspend':
|
||||
user.suspend(data.get('reason', 'Bulk suspension'))
|
||||
revoke_user_sessions(user.id)
|
||||
elif operation == 'unsuspend':
|
||||
user.unsuspend()
|
||||
elif operation == 'activate':
|
||||
user.is_active = True
|
||||
elif operation == 'deactivate':
|
||||
user.is_active = False
|
||||
revoke_user_sessions(user.id)
|
||||
elif operation == 'verify':
|
||||
user.is_verified = True
|
||||
elif operation == 'unverify':
|
||||
user.is_verified = False
|
||||
elif operation == 'delete':
|
||||
if not user.is_admin:
|
||||
revoke_user_sessions(user.id)
|
||||
db.session.delete(user)
|
||||
else:
|
||||
results['errors'].append(f"Cannot delete admin user {user.username}")
|
||||
results['failed'] += 1
|
||||
continue
|
||||
else:
|
||||
results['errors'].append(f"Unknown operation for user {user.username}")
|
||||
results['failed'] += 1
|
||||
continue
|
||||
|
||||
results['success'] += 1
|
||||
except Exception as e:
|
||||
results['errors'].append(f"Failed for user {user.username}: {str(e)}")
|
||||
results['failed'] += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin bulk operation error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to perform bulk operation'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/admin/stats/users', methods=['GET'])
|
||||
@require_admin
|
||||
def admin_user_stats():
|
||||
"""Get user statistics (admin only)"""
|
||||
try:
|
||||
stats = {
|
||||
'total_users': User.query.count(),
|
||||
'active_users': User.query.filter(
|
||||
User.is_active == True,
|
||||
User.is_suspended == False
|
||||
).count(),
|
||||
'suspended_users': User.query.filter(User.is_suspended == True).count(),
|
||||
'verified_users': User.query.filter(User.is_verified == True).count(),
|
||||
'admin_users': User.query.filter(User.role == 'admin').count(),
|
||||
'users_by_role': dict(
|
||||
db.session.query(User.role, func.count(User.id))
|
||||
.group_by(User.role).all()
|
||||
),
|
||||
'recent_registrations': User.query.filter(
|
||||
User.created_at >= datetime.utcnow() - timedelta(days=7)
|
||||
).count(),
|
||||
'active_sessions': UserSession.query.filter(
|
||||
UserSession.expires_at > datetime.utcnow()
|
||||
).count()
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'stats': stats
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e, "Admin stats error")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to fetch statistics'
|
||||
}), 500
|
||||
168
check-pwa-status.html
Normal file
168
check-pwa-status.html
Normal file
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PWA Installation Checker</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.check {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.pass {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.fail {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>PWA Installation Status Checker</h1>
|
||||
<div id="results"></div>
|
||||
|
||||
<script>
|
||||
const results = document.getElementById('results');
|
||||
|
||||
function addResult(message, status = 'info') {
|
||||
const div = document.createElement('div');
|
||||
div.className = `check ${status}`;
|
||||
div.innerHTML = message;
|
||||
results.appendChild(div);
|
||||
}
|
||||
|
||||
// Check HTTPS
|
||||
if (location.protocol === 'https:' || location.hostname === 'localhost') {
|
||||
addResult('✅ HTTPS/Localhost: ' + location.protocol + '//' + location.hostname, 'pass');
|
||||
} else {
|
||||
addResult('❌ Not HTTPS: PWAs require HTTPS (or localhost)', 'fail');
|
||||
}
|
||||
|
||||
// Check Service Worker support
|
||||
if ('serviceWorker' in navigator) {
|
||||
addResult('✅ Service Worker API supported', 'pass');
|
||||
|
||||
// Check registration
|
||||
navigator.serviceWorker.getRegistration().then(reg => {
|
||||
if (reg) {
|
||||
addResult('✅ Service Worker registered: ' + reg.scope, 'pass');
|
||||
addResult('Service Worker state: ' + (reg.active ? 'active' : 'not active'), 'info');
|
||||
} else {
|
||||
addResult('❌ No Service Worker registered', 'fail');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
addResult('❌ Service Worker API not supported', 'fail');
|
||||
}
|
||||
|
||||
// Check manifest
|
||||
const manifestLink = document.querySelector('link[rel="manifest"]');
|
||||
if (manifestLink) {
|
||||
addResult('✅ Manifest link found: ' + manifestLink.href, 'pass');
|
||||
|
||||
// Fetch and validate manifest
|
||||
fetch(manifestLink.href)
|
||||
.then(response => response.json())
|
||||
.then(manifest => {
|
||||
addResult('Manifest loaded successfully', 'info');
|
||||
|
||||
// Check required fields
|
||||
const required = ['name', 'short_name', 'start_url', 'display', 'icons'];
|
||||
required.forEach(field => {
|
||||
if (manifest[field]) {
|
||||
addResult(`✅ Manifest has ${field}: ${JSON.stringify(manifest[field])}`, 'pass');
|
||||
} else {
|
||||
addResult(`❌ Manifest missing ${field}`, 'fail');
|
||||
}
|
||||
});
|
||||
|
||||
// Check icons
|
||||
if (manifest.icons && manifest.icons.length > 0) {
|
||||
const has192 = manifest.icons.some(icon => icon.sizes && icon.sizes.includes('192'));
|
||||
const has512 = manifest.icons.some(icon => icon.sizes && icon.sizes.includes('512'));
|
||||
|
||||
if (has192) addResult('✅ Has 192x192 icon', 'pass');
|
||||
else addResult('❌ Missing 192x192 icon', 'fail');
|
||||
|
||||
if (has512) addResult('✅ Has 512x512 icon', 'pass');
|
||||
else addResult('⚠️ Missing 512x512 icon (recommended)', 'info');
|
||||
|
||||
// Check icon purposes
|
||||
manifest.icons.forEach((icon, i) => {
|
||||
addResult(`Icon ${i + 1}: ${icon.sizes} - purpose: ${icon.purpose || 'not specified'}`, 'info');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
addResult('❌ Failed to load manifest: ' + error.message, 'fail');
|
||||
});
|
||||
} else {
|
||||
addResult('❌ No manifest link found in HTML', 'fail');
|
||||
}
|
||||
|
||||
// Check installability
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
addResult('✅ Browser considers app installable (beforeinstallprompt fired)', 'pass');
|
||||
|
||||
// Show install criteria met
|
||||
const criteria = [
|
||||
'HTTPS or localhost',
|
||||
'Valid manifest with required fields',
|
||||
'Service Worker with fetch handler',
|
||||
'Icons (192x192 minimum)',
|
||||
'Not already installed'
|
||||
];
|
||||
|
||||
addResult('<strong>Installation criteria met:</strong><br>' + criteria.join('<br>'), 'info');
|
||||
});
|
||||
|
||||
// Check if already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
addResult('✅ App is already installed (running in standalone mode)', 'pass');
|
||||
}
|
||||
|
||||
// Additional Chrome-specific checks
|
||||
if (navigator.userAgent.includes('Chrome')) {
|
||||
addResult('Chrome browser detected - checking Chrome-specific requirements', 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
// If no beforeinstallprompt event fired after 3 seconds
|
||||
if (!window.installPromptFired) {
|
||||
addResult('⚠️ beforeinstallprompt event not fired after 3 seconds', 'info');
|
||||
addResult('Possible reasons:<br>' +
|
||||
'- App already installed<br>' +
|
||||
'- User dismissed install prompt recently<br>' +
|
||||
'- Missing PWA criteria<br>' +
|
||||
'- Chrome needs a user gesture to show prompt', 'info');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Log all checks completed
|
||||
setTimeout(() => {
|
||||
addResult('<br><strong>All checks completed</strong>', 'info');
|
||||
console.log('PWA Status Check Complete');
|
||||
}, 4000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
48
check_services.py
Normal file
48
check_services.py
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check if Redis and PostgreSQL are available
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def check_redis():
|
||||
"""Check if Redis is available"""
|
||||
try:
|
||||
import redis
|
||||
r = redis.Redis.from_url(os.environ.get('REDIS_URL', 'redis://localhost:6379/0'))
|
||||
r.ping()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def check_postgresql():
|
||||
"""Check if PostgreSQL is available"""
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
db_url = os.environ.get('DATABASE_URL', 'postgresql://localhost/talk2me')
|
||||
engine = create_engine(db_url, pool_pre_ping=True)
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT 1"))
|
||||
result.fetchone()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"PostgreSQL connection error: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
redis_ok = check_redis()
|
||||
postgres_ok = check_postgresql()
|
||||
|
||||
print(f"Redis: {'✓ Available' if redis_ok else '✗ Not available'}")
|
||||
print(f"PostgreSQL: {'✓ Available' if postgres_ok else '✗ Not available'}")
|
||||
|
||||
if redis_ok and postgres_ok:
|
||||
print("\nAll services available - use full admin module")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\nSome services missing - use simple admin module")
|
||||
sys.exit(1)
|
||||
16
config.py
16
config.py
@@ -46,13 +46,23 @@ class Config:
|
||||
self.ADMIN_TOKEN = self._get_secret('ADMIN_TOKEN',
|
||||
os.environ.get('ADMIN_TOKEN', 'default-admin-token'))
|
||||
|
||||
# Database configuration (for future use)
|
||||
# Database configuration
|
||||
self.DATABASE_URL = self._get_secret('DATABASE_URL',
|
||||
os.environ.get('DATABASE_URL', 'sqlite:///talk2me.db'))
|
||||
os.environ.get('DATABASE_URL', 'postgresql://localhost/talk2me'))
|
||||
self.SQLALCHEMY_DATABASE_URI = self.DATABASE_URL
|
||||
self.SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
self.SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_size': 10,
|
||||
'pool_recycle': 3600,
|
||||
'pool_pre_ping': True
|
||||
}
|
||||
|
||||
# Redis configuration (for future use)
|
||||
# Redis configuration
|
||||
self.REDIS_URL = self._get_secret('REDIS_URL',
|
||||
os.environ.get('REDIS_URL', 'redis://localhost:6379/0'))
|
||||
self.REDIS_DECODE_RESPONSES = False
|
||||
self.REDIS_MAX_CONNECTIONS = int(os.environ.get('REDIS_MAX_CONNECTIONS', 50))
|
||||
self.REDIS_SOCKET_TIMEOUT = int(os.environ.get('REDIS_SOCKET_TIMEOUT', 5))
|
||||
|
||||
# Whisper configuration
|
||||
self.WHISPER_MODEL_SIZE = os.environ.get('WHISPER_MODEL_SIZE', 'base')
|
||||
|
||||
273
database.py
Normal file
273
database.py
Normal file
@@ -0,0 +1,273 @@
|
||||
# Database models and configuration for Talk2Me application
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import Index, text
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
import uuid
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class Translation(db.Model):
|
||||
"""Store translation history for analytics and caching"""
|
||||
__tablename__ = 'translations'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id = db.Column(db.String(255), nullable=False, index=True)
|
||||
user_id = db.Column(db.String(255), nullable=True, index=True)
|
||||
|
||||
# Translation data
|
||||
source_text = db.Column(db.Text, nullable=False)
|
||||
source_language = db.Column(db.String(10), nullable=False)
|
||||
target_text = db.Column(db.Text, nullable=False)
|
||||
target_language = db.Column(db.String(10), nullable=False)
|
||||
|
||||
# Metadata
|
||||
translation_time_ms = db.Column(db.Integer, nullable=True)
|
||||
model_used = db.Column(db.String(50), default='gemma3:27b')
|
||||
confidence_score = db.Column(db.Float, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
accessed_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
access_count = db.Column(db.Integer, default=1)
|
||||
|
||||
# Client info
|
||||
ip_address = db.Column(db.String(45), nullable=True)
|
||||
user_agent = db.Column(db.String(500), nullable=True)
|
||||
|
||||
# Create indexes for better query performance
|
||||
__table_args__ = (
|
||||
Index('idx_translations_languages', 'source_language', 'target_language'),
|
||||
Index('idx_translations_created_at', 'created_at'),
|
||||
Index('idx_translations_session_user', 'session_id', 'user_id'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert translation to dictionary"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'session_id': self.session_id,
|
||||
'user_id': self.user_id,
|
||||
'source_text': self.source_text,
|
||||
'source_language': self.source_language,
|
||||
'target_text': self.target_text,
|
||||
'target_language': self.target_language,
|
||||
'translation_time_ms': self.translation_time_ms,
|
||||
'model_used': self.model_used,
|
||||
'confidence_score': self.confidence_score,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'accessed_at': self.accessed_at.isoformat(),
|
||||
'access_count': self.access_count
|
||||
}
|
||||
|
||||
|
||||
class Transcription(db.Model):
|
||||
"""Store transcription history"""
|
||||
__tablename__ = 'transcriptions'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id = db.Column(db.String(255), nullable=False, index=True)
|
||||
user_id = db.Column(db.String(255), nullable=True, index=True)
|
||||
|
||||
# Transcription data
|
||||
transcribed_text = db.Column(db.Text, nullable=False)
|
||||
detected_language = db.Column(db.String(10), nullable=True)
|
||||
audio_duration_seconds = db.Column(db.Float, nullable=True)
|
||||
|
||||
# Metadata
|
||||
transcription_time_ms = db.Column(db.Integer, nullable=True)
|
||||
model_used = db.Column(db.String(50), default='whisper-base')
|
||||
confidence_score = db.Column(db.Float, nullable=True)
|
||||
|
||||
# File info
|
||||
audio_file_size = db.Column(db.Integer, nullable=True)
|
||||
audio_format = db.Column(db.String(10), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Client info
|
||||
ip_address = db.Column(db.String(45), nullable=True)
|
||||
user_agent = db.Column(db.String(500), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_transcriptions_created_at', 'created_at'),
|
||||
Index('idx_transcriptions_session_user', 'session_id', 'user_id'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert transcription to dictionary"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'session_id': self.session_id,
|
||||
'user_id': self.user_id,
|
||||
'transcribed_text': self.transcribed_text,
|
||||
'detected_language': self.detected_language,
|
||||
'audio_duration_seconds': self.audio_duration_seconds,
|
||||
'transcription_time_ms': self.transcription_time_ms,
|
||||
'model_used': self.model_used,
|
||||
'confidence_score': self.confidence_score,
|
||||
'audio_file_size': self.audio_file_size,
|
||||
'audio_format': self.audio_format,
|
||||
'created_at': self.created_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
class UserPreferences(db.Model):
|
||||
"""Store user preferences and settings"""
|
||||
__tablename__ = 'user_preferences'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||
session_id = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Preferences
|
||||
preferred_source_language = db.Column(db.String(10), nullable=True)
|
||||
preferred_target_language = db.Column(db.String(10), nullable=True)
|
||||
preferred_voice = db.Column(db.String(50), nullable=True)
|
||||
speech_speed = db.Column(db.Float, default=1.0)
|
||||
|
||||
# Settings stored as JSONB for flexibility
|
||||
settings = db.Column(JSONB, default={})
|
||||
|
||||
# Usage stats
|
||||
total_translations = db.Column(db.Integer, default=0)
|
||||
total_transcriptions = db.Column(db.Integer, default=0)
|
||||
total_tts_requests = db.Column(db.Integer, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
last_active_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert preferences to dictionary"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'user_id': self.user_id,
|
||||
'preferred_source_language': self.preferred_source_language,
|
||||
'preferred_target_language': self.preferred_target_language,
|
||||
'preferred_voice': self.preferred_voice,
|
||||
'speech_speed': self.speech_speed,
|
||||
'settings': self.settings or {},
|
||||
'total_translations': self.total_translations,
|
||||
'total_transcriptions': self.total_transcriptions,
|
||||
'total_tts_requests': self.total_tts_requests,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat(),
|
||||
'last_active_at': self.last_active_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
class UsageAnalytics(db.Model):
|
||||
"""Store aggregated usage analytics"""
|
||||
__tablename__ = 'usage_analytics'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# Time period
|
||||
date = db.Column(db.Date, nullable=False, index=True)
|
||||
hour = db.Column(db.Integer, nullable=True) # 0-23, null for daily aggregates
|
||||
|
||||
# Metrics
|
||||
total_requests = db.Column(db.Integer, default=0)
|
||||
unique_sessions = db.Column(db.Integer, default=0)
|
||||
unique_users = db.Column(db.Integer, default=0)
|
||||
|
||||
# Service breakdown
|
||||
transcriptions = db.Column(db.Integer, default=0)
|
||||
translations = db.Column(db.Integer, default=0)
|
||||
tts_requests = db.Column(db.Integer, default=0)
|
||||
|
||||
# Performance metrics
|
||||
avg_transcription_time_ms = db.Column(db.Float, nullable=True)
|
||||
avg_translation_time_ms = db.Column(db.Float, nullable=True)
|
||||
avg_tts_time_ms = db.Column(db.Float, nullable=True)
|
||||
|
||||
# Language stats (stored as JSONB)
|
||||
language_pairs = db.Column(JSONB, default={}) # {"en-es": 100, "fr-en": 50}
|
||||
detected_languages = db.Column(JSONB, default={}) # {"en": 150, "es": 100}
|
||||
|
||||
# Error stats
|
||||
error_count = db.Column(db.Integer, default=0)
|
||||
error_details = db.Column(JSONB, default={})
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_analytics_date_hour', 'date', 'hour'),
|
||||
db.UniqueConstraint('date', 'hour', name='uq_analytics_date_hour'),
|
||||
)
|
||||
|
||||
|
||||
class ApiKey(db.Model):
|
||||
"""Store API keys for authenticated access"""
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
key_hash = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
user_id = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Permissions and limits
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
rate_limit_per_minute = db.Column(db.Integer, default=60)
|
||||
rate_limit_per_hour = db.Column(db.Integer, default=1000)
|
||||
allowed_endpoints = db.Column(JSONB, default=[]) # Empty = all endpoints
|
||||
|
||||
# Usage tracking
|
||||
total_requests = db.Column(db.Integer, default=0)
|
||||
last_used_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
expires_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
@hybrid_property
|
||||
def is_expired(self):
|
||||
"""Check if API key is expired"""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
|
||||
def init_db(app):
|
||||
"""Initialize database with app"""
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
# Create tables if they don't exist
|
||||
db.create_all()
|
||||
|
||||
# Create any custom indexes or functions
|
||||
try:
|
||||
# Create a function for updating updated_at timestamp
|
||||
db.session.execute(text("""
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
"""))
|
||||
|
||||
# Drop existing trigger if it exists and recreate it
|
||||
db.session.execute(text("""
|
||||
DROP TRIGGER IF EXISTS update_user_preferences_updated_at ON user_preferences;
|
||||
"""))
|
||||
|
||||
# Create trigger for user_preferences
|
||||
db.session.execute(text("""
|
||||
CREATE TRIGGER update_user_preferences_updated_at
|
||||
BEFORE UPDATE ON user_preferences
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
"""))
|
||||
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
# Log error but don't fail - database might not support triggers
|
||||
db.session.rollback()
|
||||
app.logger.debug(f"Database initialization note: {e}")
|
||||
135
database_init.py
Normal file
135
database_init.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
# Database initialization script
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from sqlalchemy import create_engine, text
|
||||
from config import get_config
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def create_database():
|
||||
"""Create the database if it doesn't exist"""
|
||||
config = get_config()
|
||||
db_url = config.DATABASE_URL
|
||||
|
||||
if db_url.startswith('postgresql'):
|
||||
# Parse database name from URL
|
||||
parts = db_url.split('/')
|
||||
db_name = parts[-1].split('?')[0]
|
||||
base_url = '/'.join(parts[:-1])
|
||||
|
||||
# Connect to postgres database to create our database
|
||||
engine = create_engine(f"{base_url}/postgres")
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# Check if database exists
|
||||
result = conn.execute(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :dbname"),
|
||||
{"dbname": db_name}
|
||||
)
|
||||
exists = result.fetchone() is not None
|
||||
|
||||
if not exists:
|
||||
# Create database
|
||||
conn.execute(text(f"CREATE DATABASE {db_name}"))
|
||||
logger.info(f"Database '{db_name}' created successfully")
|
||||
else:
|
||||
logger.info(f"Database '{db_name}' already exists")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating database: {e}")
|
||||
return False
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
return True
|
||||
|
||||
def check_redis():
|
||||
"""Check Redis connectivity"""
|
||||
config = get_config()
|
||||
|
||||
try:
|
||||
import redis
|
||||
r = redis.from_url(config.REDIS_URL)
|
||||
r.ping()
|
||||
logger.info("Redis connection successful")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Redis connection failed: {e}")
|
||||
return False
|
||||
|
||||
def init_database_extensions():
|
||||
"""Initialize PostgreSQL extensions"""
|
||||
config = get_config()
|
||||
|
||||
if not config.DATABASE_URL.startswith('postgresql'):
|
||||
return True
|
||||
|
||||
engine = create_engine(config.DATABASE_URL)
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# Enable UUID extension
|
||||
conn.execute(text("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\""))
|
||||
logger.info("PostgreSQL extensions initialized")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing extensions: {e}")
|
||||
return False
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""Main initialization function"""
|
||||
logger.info("Starting database initialization...")
|
||||
|
||||
# Create database
|
||||
if not create_database():
|
||||
logger.error("Failed to create database")
|
||||
sys.exit(1)
|
||||
|
||||
# Initialize extensions
|
||||
if not init_database_extensions():
|
||||
logger.error("Failed to initialize database extensions")
|
||||
sys.exit(1)
|
||||
|
||||
# Check Redis
|
||||
if not check_redis():
|
||||
logger.warning("Redis not available - caching will be disabled")
|
||||
|
||||
logger.info("Database initialization completed successfully")
|
||||
|
||||
# Create all tables using SQLAlchemy models
|
||||
logger.info("Creating database tables...")
|
||||
try:
|
||||
from flask import Flask
|
||||
from database import db, init_db
|
||||
from config import get_config
|
||||
|
||||
# Import all models to ensure they're registered
|
||||
from auth_models import User, LoginHistory, UserSession, RevokedToken
|
||||
|
||||
# Create Flask app context
|
||||
app = Flask(__name__)
|
||||
config = get_config()
|
||||
app.config.from_mapping(config.__dict__)
|
||||
|
||||
# Initialize database
|
||||
init_db(app)
|
||||
|
||||
with app.app_context():
|
||||
# Create all tables
|
||||
db.create_all()
|
||||
logger.info("Database tables created successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create database tables: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
121
diagnose-pwa.py
Executable file
121
diagnose-pwa.py
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PWA Diagnostic Script for Talk2Me
|
||||
Checks common PWA installation issues
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
from urllib.parse import urljoin
|
||||
|
||||
def check_pwa(base_url):
|
||||
"""Check PWA requirements for the given URL"""
|
||||
|
||||
if not base_url.startswith(('http://', 'https://')):
|
||||
base_url = 'https://' + base_url
|
||||
|
||||
if not base_url.endswith('/'):
|
||||
base_url += '/'
|
||||
|
||||
print(f"Checking PWA for: {base_url}\n")
|
||||
|
||||
# Check HTTPS
|
||||
if not base_url.startswith('https://'):
|
||||
print("❌ PWA requires HTTPS (except for localhost)")
|
||||
return
|
||||
else:
|
||||
print("✅ HTTPS is enabled")
|
||||
|
||||
# Check main page
|
||||
try:
|
||||
response = requests.get(base_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
print("✅ Main page loads successfully")
|
||||
else:
|
||||
print(f"❌ Main page returned status code: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading main page: {e}")
|
||||
return
|
||||
|
||||
# Check manifest
|
||||
manifest_url = urljoin(base_url, '/static/manifest.json')
|
||||
print(f"\nChecking manifest at: {manifest_url}")
|
||||
try:
|
||||
response = requests.get(manifest_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
print("✅ Manifest file found")
|
||||
|
||||
# Parse manifest
|
||||
try:
|
||||
manifest = response.json()
|
||||
print(f" - Name: {manifest.get('name', 'Not set')}")
|
||||
print(f" - Short name: {manifest.get('short_name', 'Not set')}")
|
||||
print(f" - Display: {manifest.get('display', 'Not set')}")
|
||||
print(f" - Start URL: {manifest.get('start_url', 'Not set')}")
|
||||
print(f" - Icons: {len(manifest.get('icons', []))} defined")
|
||||
|
||||
# Check icons
|
||||
for icon in manifest.get('icons', []):
|
||||
icon_url = urljoin(base_url, icon['src'])
|
||||
try:
|
||||
icon_response = requests.head(icon_url, timeout=5)
|
||||
if icon_response.status_code == 200:
|
||||
print(f" ✅ {icon['sizes']}: {icon['src']}")
|
||||
else:
|
||||
print(f" ❌ {icon['sizes']}: {icon['src']} (Status: {icon_response.status_code})")
|
||||
except:
|
||||
print(f" ❌ {icon['sizes']}: {icon['src']} (Failed to load)")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
print("❌ Manifest is not valid JSON")
|
||||
else:
|
||||
print(f"❌ Manifest returned status code: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading manifest: {e}")
|
||||
|
||||
# Check service worker
|
||||
sw_url = urljoin(base_url, '/service-worker.js')
|
||||
print(f"\nChecking service worker at: {sw_url}")
|
||||
try:
|
||||
response = requests.get(sw_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
print("✅ Service worker file found")
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
if 'javascript' in content_type:
|
||||
print("✅ Service worker has correct content type")
|
||||
else:
|
||||
print(f"⚠️ Service worker content type: {content_type}")
|
||||
else:
|
||||
print(f"❌ Service worker returned status code: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading service worker: {e}")
|
||||
|
||||
# Check favicon
|
||||
favicon_url = urljoin(base_url, '/static/icons/favicon.ico')
|
||||
print(f"\nChecking favicon at: {favicon_url}")
|
||||
try:
|
||||
response = requests.head(favicon_url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
print("✅ Favicon found")
|
||||
else:
|
||||
print(f"⚠️ Favicon returned status code: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error loading favicon: {e}")
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("PWA Installation Tips:")
|
||||
print("1. Clear browser cache and app data")
|
||||
print("2. Visit the site in Chrome on Android")
|
||||
print("3. Wait a few seconds for the install prompt")
|
||||
print("4. Or tap menu (⋮) → 'Add to Home screen'")
|
||||
print("5. Check Chrome DevTools → Application → Manifest")
|
||||
print("="*50)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
url = sys.argv[1]
|
||||
else:
|
||||
url = input("Enter the URL to check (e.g., talk2me.dr74.net): ")
|
||||
|
||||
check_pwa(url)
|
||||
19
docker-compose.amd.yml
Normal file
19
docker-compose.amd.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
version: '3.8'
|
||||
|
||||
# Docker Compose override for AMD GPU support (ROCm)
|
||||
# Usage: docker-compose -f docker-compose.yml -f docker-compose.amd.yml up
|
||||
|
||||
services:
|
||||
talk2me:
|
||||
environment:
|
||||
- HSA_OVERRIDE_GFX_VERSION=10.3.0 # Adjust based on your GPU model
|
||||
- ROCR_VISIBLE_DEVICES=0 # Use first GPU
|
||||
volumes:
|
||||
- /dev/kfd:/dev/kfd # ROCm KFD interface
|
||||
- /dev/dri:/dev/dri # Direct Rendering Interface
|
||||
devices:
|
||||
- /dev/kfd
|
||||
- /dev/dri
|
||||
group_add:
|
||||
- video # Required for GPU access
|
||||
- render # Required for GPU access
|
||||
11
docker-compose.apple.yml
Normal file
11
docker-compose.apple.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3.8'
|
||||
|
||||
# Docker Compose override for Apple Silicon
|
||||
# Usage: docker-compose -f docker-compose.yml -f docker-compose.apple.yml up
|
||||
|
||||
services:
|
||||
talk2me:
|
||||
platform: linux/arm64/v8 # For M1/M2/M3 Macs
|
||||
environment:
|
||||
- PYTORCH_ENABLE_MPS_FALLBACK=1 # Enable Metal Performance Shaders fallback
|
||||
- PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.7 # Memory management for MPS
|
||||
16
docker-compose.nvidia.yml
Normal file
16
docker-compose.nvidia.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
version: '3.8'
|
||||
|
||||
# Docker Compose override for NVIDIA GPU support
|
||||
# Usage: docker-compose -f docker-compose.yml -f docker-compose.nvidia.yml up
|
||||
|
||||
services:
|
||||
talk2me:
|
||||
environment:
|
||||
- CUDA_VISIBLE_DEVICES=0 # Use first GPU
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
75
init_all_databases.py
Executable file
75
init_all_databases.py
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize all database tables for Talk2Me"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def run_script(script_name):
|
||||
"""Run a Python script and return success status"""
|
||||
try:
|
||||
logger.info(f"Running {script_name}...")
|
||||
result = subprocess.run([sys.executable, script_name], capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"✓ {script_name} completed successfully")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"✗ {script_name} failed with return code {result.returncode}")
|
||||
if result.stderr:
|
||||
logger.error(f"Error output: {result.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Failed to run {script_name}: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Initialize all databases"""
|
||||
logger.info("=== Talk2Me Database Initialization ===")
|
||||
|
||||
# Check if DATABASE_URL is set
|
||||
if not os.environ.get('DATABASE_URL'):
|
||||
logger.error("DATABASE_URL environment variable not set!")
|
||||
logger.info("Please set DATABASE_URL in your .env file")
|
||||
logger.info("Example: DATABASE_URL=postgresql://postgres:password@localhost:5432/talk2me")
|
||||
return False
|
||||
|
||||
logger.info(f"Using database: {os.environ.get('DATABASE_URL')}")
|
||||
|
||||
scripts = [
|
||||
"database_init.py", # Initialize SQLAlchemy models
|
||||
"init_auth_db.py", # Initialize authentication tables
|
||||
"init_analytics_db.py" # Initialize analytics tables
|
||||
]
|
||||
|
||||
success = True
|
||||
for script in scripts:
|
||||
if os.path.exists(script):
|
||||
if not run_script(script):
|
||||
success = False
|
||||
else:
|
||||
logger.warning(f"Script {script} not found, skipping...")
|
||||
|
||||
if success:
|
||||
logger.info("\n✅ All database initialization completed successfully!")
|
||||
logger.info("\nYou can now:")
|
||||
logger.info("1. Create an admin user by calling POST /api/init-admin-user")
|
||||
logger.info("2. Or use the admin token to log in and create users")
|
||||
logger.info("3. Check /api/test-auth to verify authentication is working")
|
||||
else:
|
||||
logger.error("\n❌ Some database initialization steps failed!")
|
||||
logger.info("Please check the errors above and try again")
|
||||
|
||||
return success
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
72
init_analytics_db.py
Executable file
72
init_analytics_db.py
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize analytics database tables"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def init_analytics_db():
|
||||
"""Initialize analytics database tables"""
|
||||
|
||||
# Get database URL from environment
|
||||
database_url = os.environ.get('DATABASE_URL', 'postgresql://localhost/talk2me')
|
||||
|
||||
try:
|
||||
# Connect to PostgreSQL
|
||||
conn = psycopg2.connect(database_url)
|
||||
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Connected to PostgreSQL database")
|
||||
|
||||
# Read SQL file
|
||||
sql_file = os.path.join(os.path.dirname(__file__), 'migrations', 'create_analytics_tables.sql')
|
||||
|
||||
if not os.path.exists(sql_file):
|
||||
logger.error(f"SQL file not found: {sql_file}")
|
||||
return False
|
||||
|
||||
with open(sql_file, 'r') as f:
|
||||
sql_content = f.read()
|
||||
|
||||
# Execute SQL commands
|
||||
logger.info("Creating analytics tables...")
|
||||
cursor.execute(sql_content)
|
||||
|
||||
logger.info("Analytics tables created successfully!")
|
||||
|
||||
# Verify tables were created
|
||||
cursor.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN (
|
||||
'error_logs', 'request_logs', 'translation_logs',
|
||||
'transcription_logs', 'tts_logs', 'daily_stats'
|
||||
)
|
||||
""")
|
||||
|
||||
created_tables = [row[0] for row in cursor.fetchall()]
|
||||
logger.info(f"Created tables: {', '.join(created_tables)}")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize analytics database: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = init_analytics_db()
|
||||
sys.exit(0 if success else 1)
|
||||
149
init_auth_db.py
Normal file
149
init_auth_db.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize authentication database and create default admin user"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import getpass
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from config import init_app as init_config
|
||||
from database import db, init_db
|
||||
from auth_models import User, bcrypt
|
||||
from auth import create_user
|
||||
|
||||
def create_admin_user():
|
||||
"""Create the default admin user"""
|
||||
# Skip if running non-interactively
|
||||
if not sys.stdin.isatty():
|
||||
print("Running non-interactively, skipping interactive admin creation.")
|
||||
return True
|
||||
|
||||
print("\n=== Talk2Me Admin User Setup ===\n")
|
||||
|
||||
# Get admin credentials
|
||||
while True:
|
||||
email = input("Admin email: ").strip()
|
||||
if '@' in email and '.' in email:
|
||||
break
|
||||
print("Please enter a valid email address.")
|
||||
|
||||
while True:
|
||||
username = input("Admin username: ").strip()
|
||||
if len(username) >= 3:
|
||||
break
|
||||
print("Username must be at least 3 characters.")
|
||||
|
||||
while True:
|
||||
password = getpass.getpass("Admin password (min 8 chars): ")
|
||||
if len(password) >= 8:
|
||||
password_confirm = getpass.getpass("Confirm password: ")
|
||||
if password == password_confirm:
|
||||
break
|
||||
print("Passwords don't match. Try again.")
|
||||
else:
|
||||
print("Password must be at least 8 characters.")
|
||||
|
||||
full_name = input("Full name (optional): ").strip() or None
|
||||
|
||||
# Create admin user
|
||||
print("\nCreating admin user...")
|
||||
user, error = create_user(
|
||||
email=email,
|
||||
username=username,
|
||||
password=password,
|
||||
full_name=full_name,
|
||||
role='admin',
|
||||
is_verified=True
|
||||
)
|
||||
|
||||
if error:
|
||||
print(f"Error creating admin: {error}")
|
||||
return False
|
||||
|
||||
# Set higher rate limits for admin
|
||||
user.rate_limit_per_minute = 300
|
||||
user.rate_limit_per_hour = 5000
|
||||
user.rate_limit_per_day = 50000
|
||||
|
||||
# Add all permissions
|
||||
user.permissions = ['all']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
print(f"\n✅ Admin user created successfully!")
|
||||
print(f" Email: {user.email}")
|
||||
print(f" Username: {user.username}")
|
||||
print(f" API Key: {user.api_key}")
|
||||
print(f"\n📝 Save your API key securely. You can use it to authenticate API requests.")
|
||||
print(f"\n🔐 Login at: http://localhost:5005/login")
|
||||
print(f"📊 Admin dashboard: http://localhost:5005/admin/users")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def init_database():
|
||||
"""Initialize the database with all tables"""
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
|
||||
# Initialize configuration
|
||||
init_config(app)
|
||||
|
||||
# Initialize bcrypt
|
||||
bcrypt.init_app(app)
|
||||
|
||||
# Initialize database
|
||||
init_db(app)
|
||||
|
||||
with app.app_context():
|
||||
print("Creating database tables...")
|
||||
|
||||
# Import all models to ensure they're registered
|
||||
from auth_models import User, LoginHistory, UserSession, RevokedToken
|
||||
from database import Translation, Transcription, UserPreferences, UsageAnalytics, ApiKey
|
||||
|
||||
# Create all tables
|
||||
db.create_all()
|
||||
print("✅ Database tables created successfully!")
|
||||
|
||||
# Check if admin user already exists
|
||||
admin_exists = User.query.filter_by(role='admin').first()
|
||||
|
||||
if admin_exists:
|
||||
print(f"\n⚠️ Admin user already exists: {admin_exists.username}")
|
||||
# Skip creating new admin if running non-interactively
|
||||
if not sys.stdin.isatty():
|
||||
print("Running non-interactively, skipping admin user creation.")
|
||||
return
|
||||
create_new = input("Create another admin user? (y/n): ").lower().strip()
|
||||
if create_new != 'y':
|
||||
print("\nExiting without creating new admin.")
|
||||
return
|
||||
|
||||
# Create admin user
|
||||
if not create_admin_user():
|
||||
print("\n❌ Failed to create admin user.")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n✨ Authentication system initialized successfully!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
init_database()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nSetup cancelled by user.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error during setup: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -1,403 +1,238 @@
|
||||
# Memory management system to prevent leaks and monitor usage
|
||||
"""Memory management for Talk2Me application"""
|
||||
|
||||
import gc
|
||||
import os
|
||||
import logging
|
||||
import psutil
|
||||
import torch
|
||||
import logging
|
||||
import threading
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import weakref
|
||||
import tempfile
|
||||
import shutil
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class MemoryStats:
|
||||
"""Current memory statistics"""
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
process_memory_mb: float = 0.0
|
||||
system_memory_percent: float = 0.0
|
||||
"""Memory statistics"""
|
||||
process_memory_mb: float
|
||||
available_memory_mb: float
|
||||
memory_percent: float
|
||||
gpu_memory_mb: float = 0.0
|
||||
gpu_memory_percent: float = 0.0
|
||||
temp_files_count: int = 0
|
||||
temp_files_size_mb: float = 0.0
|
||||
active_sessions: int = 0
|
||||
gc_collections: Dict[int, int] = field(default_factory=dict)
|
||||
|
||||
class MemoryManager:
|
||||
"""
|
||||
Comprehensive memory management system to prevent leaks
|
||||
"""
|
||||
def __init__(self, app=None, config=None):
|
||||
self.config = config or {}
|
||||
"""Manage memory usage for the application"""
|
||||
|
||||
def __init__(self, app=None, config: Optional[Dict[str, Any]] = None):
|
||||
self.app = app
|
||||
self._cleanup_callbacks = []
|
||||
self._resource_registry = weakref.WeakValueDictionary()
|
||||
self._monitoring_thread = None
|
||||
self._shutdown = False
|
||||
|
||||
# Memory thresholds
|
||||
self.memory_threshold_mb = self.config.get('memory_threshold_mb', 4096) # 4GB
|
||||
self.gpu_memory_threshold_mb = self.config.get('gpu_memory_threshold_mb', 2048) # 2GB
|
||||
self.cleanup_interval = self.config.get('cleanup_interval', 30) # 30 seconds
|
||||
|
||||
# Whisper model reference
|
||||
self.config = config or {}
|
||||
self.memory_threshold_mb = self.config.get('memory_threshold_mb', 4096)
|
||||
self.gpu_memory_threshold_mb = self.config.get('gpu_memory_threshold_mb', 2048)
|
||||
self.cleanup_interval = self.config.get('cleanup_interval', 30)
|
||||
self.whisper_model = None
|
||||
self.model_reload_count = 0
|
||||
self.last_model_reload = time.time()
|
||||
self._cleanup_thread = None
|
||||
self._stop_cleanup = threading.Event()
|
||||
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Initialize memory management for Flask app"""
|
||||
"""Initialize with Flask app"""
|
||||
self.app = app
|
||||
app.memory_manager = self
|
||||
|
||||
# Start monitoring thread
|
||||
self._start_monitoring()
|
||||
# Start cleanup thread
|
||||
self._start_cleanup_thread()
|
||||
|
||||
# Register cleanup on shutdown
|
||||
import atexit
|
||||
atexit.register(self.shutdown)
|
||||
|
||||
logger.info("Memory manager initialized")
|
||||
logger.info(f"Memory manager initialized with thresholds: "
|
||||
f"Process={self.memory_threshold_mb}MB, "
|
||||
f"GPU={self.gpu_memory_threshold_mb}MB")
|
||||
|
||||
def set_whisper_model(self, model):
|
||||
"""Register the Whisper model for management"""
|
||||
"""Set reference to Whisper model for memory management"""
|
||||
self.whisper_model = model
|
||||
logger.info("Whisper model registered with memory manager")
|
||||
|
||||
def _start_monitoring(self):
|
||||
"""Start background memory monitoring"""
|
||||
self._monitoring_thread = threading.Thread(
|
||||
target=self._monitor_memory,
|
||||
daemon=True
|
||||
)
|
||||
self._monitoring_thread.start()
|
||||
|
||||
def _monitor_memory(self):
|
||||
"""Background thread to monitor and manage memory"""
|
||||
logger.info("Memory monitoring thread started")
|
||||
def get_memory_stats(self) -> MemoryStats:
|
||||
"""Get current memory statistics"""
|
||||
process = psutil.Process()
|
||||
memory_info = process.memory_info()
|
||||
|
||||
while not self._shutdown:
|
||||
stats = MemoryStats(
|
||||
process_memory_mb=memory_info.rss / 1024 / 1024,
|
||||
available_memory_mb=psutil.virtual_memory().available / 1024 / 1024,
|
||||
memory_percent=process.memory_percent()
|
||||
)
|
||||
|
||||
# Check GPU memory if available
|
||||
if torch.cuda.is_available():
|
||||
try:
|
||||
# Collect memory statistics
|
||||
stats = self.get_memory_stats()
|
||||
|
||||
# Check if we need to free memory
|
||||
if self._should_cleanup(stats):
|
||||
logger.warning(f"Memory threshold exceeded - Process: {stats.process_memory_mb:.1f}MB, "
|
||||
f"GPU: {stats.gpu_memory_mb:.1f}MB")
|
||||
self.cleanup_memory(aggressive=True)
|
||||
|
||||
# Log stats periodically
|
||||
if int(time.time()) % 300 == 0: # Every 5 minutes
|
||||
logger.info(f"Memory stats - Process: {stats.process_memory_mb:.1f}MB, "
|
||||
f"System: {stats.system_memory_percent:.1f}%, "
|
||||
f"GPU: {stats.gpu_memory_mb:.1f}MB")
|
||||
|
||||
stats.gpu_memory_mb = torch.cuda.memory_allocated() / 1024 / 1024
|
||||
stats.gpu_memory_percent = (torch.cuda.memory_allocated() /
|
||||
torch.cuda.get_device_properties(0).total_memory * 100)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in memory monitoring: {e}")
|
||||
|
||||
time.sleep(self.cleanup_interval)
|
||||
logger.error(f"Error getting GPU memory stats: {e}")
|
||||
|
||||
return stats
|
||||
|
||||
def _should_cleanup(self, stats: MemoryStats) -> bool:
|
||||
"""Determine if memory cleanup is needed"""
|
||||
def check_memory_pressure(self) -> bool:
|
||||
"""Check if system is under memory pressure"""
|
||||
stats = self.get_memory_stats()
|
||||
|
||||
# Check process memory
|
||||
if stats.process_memory_mb > self.memory_threshold_mb:
|
||||
logger.warning(f"High process memory usage: {stats.process_memory_mb:.1f}MB")
|
||||
return True
|
||||
|
||||
# Check system memory
|
||||
if stats.system_memory_percent > 85:
|
||||
if stats.memory_percent > 80:
|
||||
logger.warning(f"High system memory usage: {stats.memory_percent:.1f}%")
|
||||
return True
|
||||
|
||||
# Check GPU memory
|
||||
if stats.gpu_memory_mb > self.gpu_memory_threshold_mb:
|
||||
logger.warning(f"High GPU memory usage: {stats.gpu_memory_mb:.1f}MB")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_memory_stats(self) -> MemoryStats:
|
||||
"""Get current memory statistics"""
|
||||
stats = MemoryStats()
|
||||
def cleanup_memory(self, aggressive: bool = False):
|
||||
"""Clean up memory"""
|
||||
logger.info("Starting memory cleanup...")
|
||||
|
||||
try:
|
||||
# Process memory
|
||||
process = psutil.Process()
|
||||
memory_info = process.memory_info()
|
||||
stats.process_memory_mb = memory_info.rss / 1024 / 1024
|
||||
|
||||
# System memory
|
||||
system_memory = psutil.virtual_memory()
|
||||
stats.system_memory_percent = system_memory.percent
|
||||
|
||||
# GPU memory if available
|
||||
if torch.cuda.is_available():
|
||||
stats.gpu_memory_mb = torch.cuda.memory_allocated() / 1024 / 1024
|
||||
stats.gpu_memory_percent = (torch.cuda.memory_allocated() /
|
||||
torch.cuda.get_device_properties(0).total_memory * 100)
|
||||
|
||||
# Temp files
|
||||
temp_dir = self.app.config.get('UPLOAD_FOLDER', tempfile.gettempdir())
|
||||
if os.path.exists(temp_dir):
|
||||
temp_files = list(os.listdir(temp_dir))
|
||||
stats.temp_files_count = len(temp_files)
|
||||
stats.temp_files_size_mb = sum(
|
||||
os.path.getsize(os.path.join(temp_dir, f))
|
||||
for f in temp_files if os.path.isfile(os.path.join(temp_dir, f))
|
||||
) / 1024 / 1024
|
||||
|
||||
# Session count
|
||||
if hasattr(self.app, 'session_manager'):
|
||||
stats.active_sessions = len(self.app.session_manager.sessions)
|
||||
|
||||
# GC stats
|
||||
gc_stats = gc.get_stats()
|
||||
for i, stat in enumerate(gc_stats):
|
||||
if isinstance(stat, dict):
|
||||
stats.gc_collections[i] = stat.get('collections', 0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting memory stats: {e}")
|
||||
# Run garbage collection
|
||||
collected = gc.collect()
|
||||
logger.info(f"Garbage collector: collected {collected} objects")
|
||||
|
||||
return stats
|
||||
|
||||
def cleanup_memory(self, aggressive=False):
|
||||
"""Perform memory cleanup"""
|
||||
logger.info(f"Starting memory cleanup (aggressive={aggressive})")
|
||||
freed_mb = 0
|
||||
|
||||
try:
|
||||
# 1. Force garbage collection
|
||||
gc.collect()
|
||||
if aggressive:
|
||||
gc.collect(2) # Full collection
|
||||
|
||||
# 2. Clear GPU memory cache
|
||||
if torch.cuda.is_available():
|
||||
before_gpu = torch.cuda.memory_allocated() / 1024 / 1024
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.synchronize()
|
||||
after_gpu = torch.cuda.memory_allocated() / 1024 / 1024
|
||||
freed_mb += (before_gpu - after_gpu)
|
||||
logger.info(f"Freed {before_gpu - after_gpu:.1f}MB GPU memory")
|
||||
|
||||
# 3. Clean old temporary files
|
||||
if hasattr(self.app, 'config'):
|
||||
temp_dir = self.app.config.get('UPLOAD_FOLDER')
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
freed_mb += self._cleanup_temp_files(temp_dir, aggressive)
|
||||
|
||||
# 4. Trigger session cleanup
|
||||
if hasattr(self.app, 'session_manager'):
|
||||
self.app.session_manager.cleanup_expired_sessions()
|
||||
if aggressive:
|
||||
self.app.session_manager.cleanup_idle_sessions()
|
||||
|
||||
# 5. Run registered cleanup callbacks
|
||||
for callback in self._cleanup_callbacks:
|
||||
try:
|
||||
callback()
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup callback error: {e}")
|
||||
|
||||
# 6. Reload Whisper model if needed (aggressive mode only)
|
||||
if aggressive and self.whisper_model and torch.cuda.is_available():
|
||||
current_gpu_mb = torch.cuda.memory_allocated() / 1024 / 1024
|
||||
if current_gpu_mb > self.gpu_memory_threshold_mb * 0.8:
|
||||
self._reload_whisper_model()
|
||||
|
||||
logger.info(f"Memory cleanup completed - freed approximately {freed_mb:.1f}MB")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during memory cleanup: {e}")
|
||||
|
||||
def _cleanup_temp_files(self, temp_dir: str, aggressive: bool) -> float:
|
||||
"""Clean up temporary files"""
|
||||
freed_mb = 0
|
||||
current_time = time.time()
|
||||
max_age = 300 if not aggressive else 60 # 5 minutes or 1 minute
|
||||
|
||||
try:
|
||||
for filename in os.listdir(temp_dir):
|
||||
filepath = os.path.join(temp_dir, filename)
|
||||
if os.path.isfile(filepath):
|
||||
file_age = current_time - os.path.getmtime(filepath)
|
||||
if file_age > max_age:
|
||||
file_size = os.path.getsize(filepath) / 1024 / 1024
|
||||
try:
|
||||
os.remove(filepath)
|
||||
freed_mb += file_size
|
||||
logger.debug(f"Removed old temp file: {filename}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove {filepath}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning temp files: {e}")
|
||||
|
||||
return freed_mb
|
||||
|
||||
def _reload_whisper_model(self):
|
||||
"""Reload Whisper model to clear GPU memory fragmentation"""
|
||||
if not self.whisper_model:
|
||||
return
|
||||
|
||||
# Don't reload too frequently
|
||||
if time.time() - self.last_model_reload < 300: # 5 minutes
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info("Reloading Whisper model to clear GPU memory")
|
||||
|
||||
# Get model info
|
||||
import whisper
|
||||
model_size = getattr(self.whisper_model, 'model_size', 'base')
|
||||
device = next(self.whisper_model.parameters()).device
|
||||
|
||||
# Clear the old model
|
||||
del self.whisper_model
|
||||
# Clear GPU cache if available
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
gc.collect()
|
||||
torch.cuda.synchronize()
|
||||
logger.info("Cleared GPU cache")
|
||||
|
||||
if aggressive:
|
||||
# Force garbage collection of all generations
|
||||
for i in range(3):
|
||||
gc.collect(i)
|
||||
|
||||
# Reload model
|
||||
self.whisper_model = whisper.load_model(model_size, device=device)
|
||||
self.model_reload_count += 1
|
||||
self.last_model_reload = time.time()
|
||||
|
||||
# Update app reference
|
||||
if hasattr(self.app, 'whisper_model'):
|
||||
self.app.whisper_model = self.whisper_model
|
||||
|
||||
logger.info(f"Whisper model reloaded successfully (reload #{self.model_reload_count})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reload Whisper model: {e}")
|
||||
# Clear Whisper model cache if needed
|
||||
if self.whisper_model and hasattr(self.whisper_model, 'clear_cache'):
|
||||
self.whisper_model.clear_cache()
|
||||
logger.info("Cleared Whisper model cache")
|
||||
|
||||
def register_cleanup_callback(self, callback: Callable):
|
||||
"""Register a callback to be called during cleanup"""
|
||||
self._cleanup_callbacks.append(callback)
|
||||
def _cleanup_worker(self):
|
||||
"""Background cleanup worker"""
|
||||
while not self._stop_cleanup.wait(self.cleanup_interval):
|
||||
try:
|
||||
if self.check_memory_pressure():
|
||||
self.cleanup_memory(aggressive=True)
|
||||
else:
|
||||
# Light cleanup
|
||||
gc.collect(0)
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in memory cleanup worker: {e}")
|
||||
|
||||
def register_resource(self, resource, name: str = None):
|
||||
"""Register a resource for tracking"""
|
||||
if name:
|
||||
self._resource_registry[name] = resource
|
||||
def _start_cleanup_thread(self):
|
||||
"""Start background cleanup thread"""
|
||||
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_cleanup.clear()
|
||||
self._cleanup_thread = threading.Thread(target=self._cleanup_worker, daemon=True)
|
||||
self._cleanup_thread.start()
|
||||
logger.info("Started memory cleanup thread")
|
||||
|
||||
def release_resource(self, name: str):
|
||||
"""Release a tracked resource"""
|
||||
if name in self._resource_registry:
|
||||
del self._resource_registry[name]
|
||||
def stop(self):
|
||||
"""Stop memory manager"""
|
||||
self._stop_cleanup.set()
|
||||
if self._cleanup_thread:
|
||||
self._cleanup_thread.join(timeout=5)
|
||||
|
||||
def get_metrics(self) -> Dict:
|
||||
"""Get memory management metrics"""
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
"""Get memory metrics for monitoring"""
|
||||
stats = self.get_memory_stats()
|
||||
|
||||
return {
|
||||
'memory': {
|
||||
'process_mb': round(stats.process_memory_mb, 1),
|
||||
'system_percent': round(stats.system_memory_percent, 1),
|
||||
'gpu_mb': round(stats.gpu_memory_mb, 1),
|
||||
'gpu_percent': round(stats.gpu_memory_percent, 1)
|
||||
},
|
||||
'temp_files': {
|
||||
'count': stats.temp_files_count,
|
||||
'size_mb': round(stats.temp_files_size_mb, 1)
|
||||
},
|
||||
'sessions': {
|
||||
'active': stats.active_sessions
|
||||
},
|
||||
'model': {
|
||||
'reload_count': self.model_reload_count,
|
||||
'last_reload': datetime.fromtimestamp(self.last_model_reload).isoformat()
|
||||
},
|
||||
'process_memory_mb': round(stats.process_memory_mb, 2),
|
||||
'available_memory_mb': round(stats.available_memory_mb, 2),
|
||||
'memory_percent': round(stats.memory_percent, 2),
|
||||
'gpu_memory_mb': round(stats.gpu_memory_mb, 2),
|
||||
'gpu_memory_percent': round(stats.gpu_memory_percent, 2),
|
||||
'thresholds': {
|
||||
'memory_mb': self.memory_threshold_mb,
|
||||
'process_mb': self.memory_threshold_mb,
|
||||
'gpu_mb': self.gpu_memory_threshold_mb
|
||||
}
|
||||
},
|
||||
'under_pressure': self.check_memory_pressure()
|
||||
}
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown memory manager"""
|
||||
logger.info("Shutting down memory manager")
|
||||
self._shutdown = True
|
||||
|
||||
# Final cleanup
|
||||
self.cleanup_memory(aggressive=True)
|
||||
|
||||
# Wait for monitoring thread
|
||||
if self._monitoring_thread:
|
||||
self._monitoring_thread.join(timeout=5)
|
||||
|
||||
# Context manager for audio processing
|
||||
class AudioProcessingContext:
|
||||
"""Context manager to ensure audio resources are cleaned up"""
|
||||
def __init__(self, memory_manager: MemoryManager, name: str = None):
|
||||
"""Context manager for audio processing with memory management"""
|
||||
|
||||
def __init__(self, memory_manager: MemoryManager, name: str = "audio_processing"):
|
||||
self.memory_manager = memory_manager
|
||||
self.name = name or f"audio_{int(time.time() * 1000)}"
|
||||
self.name = name
|
||||
self.temp_files = []
|
||||
self.start_time = None
|
||||
self.start_memory = None
|
||||
|
||||
def __enter__(self):
|
||||
self.start_time = time.time()
|
||||
if torch.cuda.is_available():
|
||||
self.start_memory = torch.cuda.memory_allocated()
|
||||
|
||||
# Check memory before processing
|
||||
if self.memory_manager and self.memory_manager.check_memory_pressure():
|
||||
logger.warning(f"Memory pressure detected before {self.name}")
|
||||
self.memory_manager.cleanup_memory()
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
# Clean up temp files
|
||||
for filepath in self.temp_files:
|
||||
# Clean up temporary files
|
||||
for temp_file in self.temp_files:
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove temp file {filepath}: {e}")
|
||||
logger.error(f"Failed to remove temp file {temp_file}: {e}")
|
||||
|
||||
# Clear GPU cache if used
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# Log memory usage
|
||||
if self.start_memory is not None:
|
||||
memory_used = torch.cuda.memory_allocated() - self.start_memory
|
||||
duration = time.time() - self.start_time
|
||||
logger.debug(f"Audio processing '{self.name}' - Duration: {duration:.2f}s, "
|
||||
f"GPU memory: {memory_used / 1024 / 1024:.1f}MB")
|
||||
|
||||
# Force garbage collection if there was an error
|
||||
if exc_type is not None:
|
||||
# Clean up memory after processing
|
||||
if self.memory_manager:
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
duration = time.time() - self.start_time
|
||||
logger.info(f"{self.name} completed in {duration:.2f}s")
|
||||
|
||||
def add_temp_file(self, filepath: str):
|
||||
"""Register a temporary file for cleanup"""
|
||||
"""Add a temporary file to be cleaned up"""
|
||||
self.temp_files.append(filepath)
|
||||
|
||||
# Utility functions
|
||||
def with_memory_management(func):
|
||||
"""Decorator to add memory management to functions"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Get memory manager from app context
|
||||
from flask import current_app
|
||||
memory_manager = getattr(current_app, 'memory_manager', None)
|
||||
|
||||
if memory_manager:
|
||||
with AudioProcessingContext(memory_manager, name=func.__name__):
|
||||
return func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
# Check memory before
|
||||
if memory_manager.check_memory_pressure():
|
||||
logger.warning(f"Memory pressure before {func.__name__}")
|
||||
memory_manager.cleanup_memory()
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
# Light cleanup after
|
||||
gc.collect(0)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
return wrapper
|
||||
|
||||
def init_memory_management(app, **kwargs):
|
||||
"""Initialize memory management for the application"""
|
||||
config = {
|
||||
'memory_threshold_mb': kwargs.get('memory_threshold_mb', 4096),
|
||||
'gpu_memory_threshold_mb': kwargs.get('gpu_memory_threshold_mb', 2048),
|
||||
'cleanup_interval': kwargs.get('cleanup_interval', 30)
|
||||
}
|
||||
|
||||
memory_manager = MemoryManager(app, config)
|
||||
return memory_manager
|
||||
return wrapper
|
||||
135
migrations.py
Normal file
135
migrations.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# Database migration scripts
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from flask import Flask
|
||||
from flask_migrate import Migrate, init, migrate, upgrade
|
||||
from database import db, init_db
|
||||
from config import Config
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def create_app():
|
||||
"""Create Flask app for migrations"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# Load configuration
|
||||
config = Config()
|
||||
app.config.from_object(config)
|
||||
|
||||
# Initialize database
|
||||
init_db(app)
|
||||
|
||||
return app
|
||||
|
||||
def init_migrations():
|
||||
"""Initialize migration repository"""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
# Initialize Flask-Migrate
|
||||
migrate_instance = Migrate(app, db)
|
||||
|
||||
# Initialize migration repository
|
||||
try:
|
||||
init()
|
||||
logger.info("Migration repository initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize migrations: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def create_migration(message="Auto migration"):
|
||||
"""Create a new migration"""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
# Initialize Flask-Migrate
|
||||
migrate_instance = Migrate(app, db)
|
||||
|
||||
try:
|
||||
migrate(message=message)
|
||||
logger.info(f"Migration created: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create migration: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def run_migrations():
|
||||
"""Run pending migrations"""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
# Initialize Flask-Migrate
|
||||
migrate_instance = Migrate(app, db)
|
||||
|
||||
try:
|
||||
upgrade()
|
||||
logger.info("Migrations completed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run migrations: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def create_initial_data():
|
||||
"""Create initial data if needed"""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
# Add any initial data here
|
||||
# For example, creating default API keys, admin users, etc.
|
||||
|
||||
db.session.commit()
|
||||
logger.info("Initial data created")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Failed to create initial data: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python migrations.py [init|create|run|seed]")
|
||||
sys.exit(1)
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
if command == "init":
|
||||
if init_migrations():
|
||||
print("Migration repository initialized successfully")
|
||||
else:
|
||||
print("Failed to initialize migrations")
|
||||
sys.exit(1)
|
||||
|
||||
elif command == "create":
|
||||
message = sys.argv[2] if len(sys.argv) > 2 else "Auto migration"
|
||||
if create_migration(message):
|
||||
print(f"Migration created: {message}")
|
||||
else:
|
||||
print("Failed to create migration")
|
||||
sys.exit(1)
|
||||
|
||||
elif command == "run":
|
||||
if run_migrations():
|
||||
print("Migrations completed successfully")
|
||||
else:
|
||||
print("Failed to run migrations")
|
||||
sys.exit(1)
|
||||
|
||||
elif command == "seed":
|
||||
if create_initial_data():
|
||||
print("Initial data created successfully")
|
||||
else:
|
||||
print("Failed to create initial data")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
print("Available commands: init, create, run, seed")
|
||||
sys.exit(1)
|
||||
221
migrations/add_user_authentication.py
Normal file
221
migrations/add_user_authentication.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Add user authentication tables and update existing models
|
||||
|
||||
This migration:
|
||||
1. Creates user authentication tables (users, login_history, user_sessions, revoked_tokens)
|
||||
2. Updates translation and transcription tables to link to users
|
||||
3. Adds proper foreign key constraints and indexes
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
import uuid
|
||||
|
||||
# revision identifiers
|
||||
revision = 'add_user_authentication'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create users table
|
||||
op.create_table('users',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4),
|
||||
sa.Column('email', sa.String(255), nullable=False),
|
||||
sa.Column('username', sa.String(100), nullable=False),
|
||||
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||
sa.Column('full_name', sa.String(255), nullable=True),
|
||||
sa.Column('avatar_url', sa.String(500), nullable=True),
|
||||
sa.Column('api_key', sa.String(64), nullable=False),
|
||||
sa.Column('api_key_created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
|
||||
sa.Column('is_verified', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('is_suspended', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('suspension_reason', sa.Text(), nullable=True),
|
||||
sa.Column('suspended_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('suspended_until', sa.DateTime(), nullable=True),
|
||||
sa.Column('role', sa.String(20), nullable=False, default='user'),
|
||||
sa.Column('permissions', postgresql.JSONB(astext_type=sa.Text()), nullable=False, default=[]),
|
||||
sa.Column('rate_limit_per_minute', sa.Integer(), nullable=False, default=30),
|
||||
sa.Column('rate_limit_per_hour', sa.Integer(), nullable=False, default=500),
|
||||
sa.Column('rate_limit_per_day', sa.Integer(), nullable=False, default=5000),
|
||||
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('total_translations', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('total_transcriptions', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('total_tts_requests', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_login_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_active_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('password_changed_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('failed_login_attempts', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('locked_until', sa.DateTime(), nullable=True),
|
||||
sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=False, default={}),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email'),
|
||||
sa.UniqueConstraint('username'),
|
||||
sa.UniqueConstraint('api_key')
|
||||
)
|
||||
|
||||
# Create indexes on users table
|
||||
op.create_index('idx_users_email', 'users', ['email'])
|
||||
op.create_index('idx_users_username', 'users', ['username'])
|
||||
op.create_index('idx_users_api_key', 'users', ['api_key'])
|
||||
op.create_index('idx_users_email_active', 'users', ['email', 'is_active'])
|
||||
op.create_index('idx_users_role_active', 'users', ['role', 'is_active'])
|
||||
op.create_index('idx_users_created_at', 'users', ['created_at'])
|
||||
|
||||
# Create login_history table
|
||||
op.create_table('login_history',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('login_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('logout_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('login_method', sa.String(20), nullable=False),
|
||||
sa.Column('success', sa.Boolean(), nullable=False),
|
||||
sa.Column('failure_reason', sa.String(255), nullable=True),
|
||||
sa.Column('session_id', sa.String(255), nullable=True),
|
||||
sa.Column('jwt_jti', sa.String(255), nullable=True),
|
||||
sa.Column('ip_address', sa.String(45), nullable=False),
|
||||
sa.Column('user_agent', sa.String(500), nullable=True),
|
||||
sa.Column('device_info', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('country', sa.String(2), nullable=True),
|
||||
sa.Column('city', sa.String(100), nullable=True),
|
||||
sa.Column('is_suspicious', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('security_notes', sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes on login_history
|
||||
op.create_index('idx_login_history_user_id', 'login_history', ['user_id'])
|
||||
op.create_index('idx_login_history_user_time', 'login_history', ['user_id', 'login_at'])
|
||||
op.create_index('idx_login_history_session', 'login_history', ['session_id'])
|
||||
op.create_index('idx_login_history_jwt_jti', 'login_history', ['jwt_jti'])
|
||||
op.create_index('idx_login_history_ip', 'login_history', ['ip_address'])
|
||||
|
||||
# Create user_sessions table
|
||||
op.create_table('user_sessions',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4),
|
||||
sa.Column('session_id', sa.String(255), nullable=False),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('access_token_jti', sa.String(255), nullable=True),
|
||||
sa.Column('refresh_token_jti', sa.String(255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_active_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('ip_address', sa.String(45), nullable=False),
|
||||
sa.Column('user_agent', sa.String(500), nullable=True),
|
||||
sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=False, default={}),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('session_id')
|
||||
)
|
||||
|
||||
# Create indexes on user_sessions
|
||||
op.create_index('idx_user_sessions_session_id', 'user_sessions', ['session_id'])
|
||||
op.create_index('idx_user_sessions_user_id', 'user_sessions', ['user_id'])
|
||||
op.create_index('idx_user_sessions_user_active', 'user_sessions', ['user_id', 'expires_at'])
|
||||
op.create_index('idx_user_sessions_token', 'user_sessions', ['access_token_jti'])
|
||||
|
||||
# Create revoked_tokens table
|
||||
op.create_table('revoked_tokens',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4),
|
||||
sa.Column('jti', sa.String(255), nullable=False),
|
||||
sa.Column('token_type', sa.String(20), nullable=False),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('reason', sa.String(255), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('jti')
|
||||
)
|
||||
|
||||
# Create indexes on revoked_tokens
|
||||
op.create_index('idx_revoked_tokens_jti', 'revoked_tokens', ['jti'])
|
||||
op.create_index('idx_revoked_tokens_user_id', 'revoked_tokens', ['user_id'])
|
||||
op.create_index('idx_revoked_tokens_expires', 'revoked_tokens', ['expires_at'])
|
||||
|
||||
# Update translations table to add user_id with proper foreign key
|
||||
# First, check if user_id column exists
|
||||
try:
|
||||
op.add_column('translations', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_translations_user_id', 'translations', 'users', ['user_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index('idx_translations_user_id', 'translations', ['user_id'])
|
||||
except:
|
||||
pass # Column might already exist
|
||||
|
||||
# Update transcriptions table to add user_id with proper foreign key
|
||||
try:
|
||||
op.add_column('transcriptions', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_transcriptions_user_id', 'transcriptions', 'users', ['user_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index('idx_transcriptions_user_id', 'transcriptions', ['user_id'])
|
||||
except:
|
||||
pass # Column might already exist
|
||||
|
||||
# Update user_preferences table to add proper foreign key if not exists
|
||||
try:
|
||||
op.create_foreign_key('fk_user_preferences_user_id', 'user_preferences', 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
except:
|
||||
pass # Foreign key might already exist
|
||||
|
||||
# Update api_keys table to add proper foreign key if not exists
|
||||
try:
|
||||
op.add_column('api_keys', sa.Column('user_id_new', postgresql.UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_api_keys_user_id', 'api_keys', 'users', ['user_id_new'], ['id'], ondelete='CASCADE')
|
||||
except:
|
||||
pass # Column/FK might already exist
|
||||
|
||||
# Create function for updating updated_at timestamp
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
""")
|
||||
|
||||
# Drop existing trigger if it exists and recreate it
|
||||
op.execute("""
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
""")
|
||||
|
||||
# Create trigger for users table
|
||||
op.execute("""
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Drop triggers
|
||||
op.execute("DROP TRIGGER IF EXISTS update_users_updated_at ON users")
|
||||
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column()")
|
||||
|
||||
# Drop foreign keys
|
||||
try:
|
||||
op.drop_constraint('fk_translations_user_id', 'translations', type_='foreignkey')
|
||||
op.drop_constraint('fk_transcriptions_user_id', 'transcriptions', type_='foreignkey')
|
||||
op.drop_constraint('fk_user_preferences_user_id', 'user_preferences', type_='foreignkey')
|
||||
op.drop_constraint('fk_api_keys_user_id', 'api_keys', type_='foreignkey')
|
||||
except:
|
||||
pass
|
||||
|
||||
# Drop columns
|
||||
try:
|
||||
op.drop_column('translations', 'user_id')
|
||||
op.drop_column('transcriptions', 'user_id')
|
||||
op.drop_column('api_keys', 'user_id_new')
|
||||
except:
|
||||
pass
|
||||
|
||||
# Drop tables
|
||||
op.drop_table('revoked_tokens')
|
||||
op.drop_table('user_sessions')
|
||||
op.drop_table('login_history')
|
||||
op.drop_table('users')
|
||||
135
migrations/create_analytics_tables.sql
Normal file
135
migrations/create_analytics_tables.sql
Normal file
@@ -0,0 +1,135 @@
|
||||
-- Create analytics tables for Talk2Me admin dashboard
|
||||
|
||||
-- Error logs table
|
||||
CREATE TABLE IF NOT EXISTS error_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
error_type VARCHAR(100) NOT NULL,
|
||||
error_message TEXT,
|
||||
endpoint VARCHAR(255),
|
||||
method VARCHAR(10),
|
||||
status_code INTEGER,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
request_id VARCHAR(100),
|
||||
stack_trace TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for error_logs
|
||||
CREATE INDEX IF NOT EXISTS idx_error_logs_created_at ON error_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_error_logs_error_type ON error_logs(error_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_error_logs_endpoint ON error_logs(endpoint);
|
||||
|
||||
-- Request logs table for detailed analytics
|
||||
CREATE TABLE IF NOT EXISTS request_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
endpoint VARCHAR(255) NOT NULL,
|
||||
method VARCHAR(10) NOT NULL,
|
||||
status_code INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
request_size_bytes INTEGER,
|
||||
response_size_bytes INTEGER,
|
||||
session_id VARCHAR(100),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for request_logs
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_endpoint ON request_logs(endpoint);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_session_id ON request_logs(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_response_time ON request_logs(response_time_ms);
|
||||
|
||||
-- Translation logs table
|
||||
CREATE TABLE IF NOT EXISTS translation_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_language VARCHAR(10),
|
||||
target_language VARCHAR(10),
|
||||
text_length INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
success BOOLEAN DEFAULT TRUE,
|
||||
error_message TEXT,
|
||||
session_id VARCHAR(100),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for translation_logs
|
||||
CREATE INDEX IF NOT EXISTS idx_translation_logs_created_at ON translation_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_translation_logs_languages ON translation_logs(source_language, target_language);
|
||||
|
||||
-- Transcription logs table
|
||||
CREATE TABLE IF NOT EXISTS transcription_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
detected_language VARCHAR(10),
|
||||
audio_duration_seconds FLOAT,
|
||||
file_size_bytes INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
success BOOLEAN DEFAULT TRUE,
|
||||
error_message TEXT,
|
||||
session_id VARCHAR(100),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for transcription_logs
|
||||
CREATE INDEX IF NOT EXISTS idx_transcription_logs_created_at ON transcription_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_transcription_logs_language ON transcription_logs(detected_language);
|
||||
|
||||
-- TTS logs table
|
||||
CREATE TABLE IF NOT EXISTS tts_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10),
|
||||
text_length INTEGER,
|
||||
voice VARCHAR(50),
|
||||
response_time_ms INTEGER,
|
||||
success BOOLEAN DEFAULT TRUE,
|
||||
error_message TEXT,
|
||||
session_id VARCHAR(100),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for tts_logs
|
||||
CREATE INDEX IF NOT EXISTS idx_tts_logs_created_at ON tts_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_tts_logs_language ON tts_logs(language);
|
||||
|
||||
-- Daily aggregated stats table for faster queries
|
||||
CREATE TABLE IF NOT EXISTS daily_stats (
|
||||
date DATE PRIMARY KEY,
|
||||
total_requests INTEGER DEFAULT 0,
|
||||
total_translations INTEGER DEFAULT 0,
|
||||
total_transcriptions INTEGER DEFAULT 0,
|
||||
total_tts INTEGER DEFAULT 0,
|
||||
total_errors INTEGER DEFAULT 0,
|
||||
unique_sessions INTEGER DEFAULT 0,
|
||||
avg_response_time_ms FLOAT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Create trigger for daily_stats
|
||||
DROP TRIGGER IF EXISTS update_daily_stats_updated_at ON daily_stats;
|
||||
CREATE TRIGGER update_daily_stats_updated_at
|
||||
BEFORE UPDATE ON daily_stats
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Create view for language pair statistics
|
||||
CREATE OR REPLACE VIEW language_pair_stats AS
|
||||
SELECT
|
||||
source_language || ' -> ' || target_language as language_pair,
|
||||
COUNT(*) as usage_count,
|
||||
AVG(response_time_ms) as avg_response_time,
|
||||
MAX(created_at) as last_used
|
||||
FROM translation_logs
|
||||
WHERE success = TRUE
|
||||
GROUP BY source_language, target_language
|
||||
ORDER BY usage_count DESC;
|
||||
1652
package-lock.json
generated
1652
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -4,9 +4,10 @@
|
||||
"description": "Real-time voice translation web application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"dev": "tsc --watch",
|
||||
"build": "webpack",
|
||||
"build-tsc": "tsc",
|
||||
"watch": "webpack --watch",
|
||||
"dev": "webpack --watch",
|
||||
"clean": "rm -rf static/js/dist",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
@@ -20,7 +21,9 @@
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
"ts-loader": "^9.5.2",
|
||||
"typescript": "^5.3.0",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class RateLimiter:
|
||||
"""
|
||||
def __init__(self):
|
||||
self.buckets = defaultdict(lambda: {
|
||||
'tokens': 0,
|
||||
'tokens': 5, # Start with some tokens to avoid immediate burst errors
|
||||
'last_update': time.time(),
|
||||
'requests': deque(maxlen=1000) # Track last 1000 requests
|
||||
})
|
||||
@@ -145,8 +145,25 @@ class RateLimiter:
|
||||
|
||||
return True, None
|
||||
|
||||
def is_exempt_path(self, path):
|
||||
"""Check if path is exempt from rate limiting"""
|
||||
# Handle both path strings and endpoint names
|
||||
if path.startswith('admin.'):
|
||||
return True
|
||||
|
||||
exempt_paths = ['/admin', '/health', '/static']
|
||||
return any(path.startswith(p) for p in exempt_paths)
|
||||
|
||||
def check_rate_limit(self, client_id, endpoint, request_size=0):
|
||||
"""Check if request should be allowed"""
|
||||
# Log what endpoint we're checking
|
||||
logger.debug(f"Checking rate limit for endpoint: {endpoint}")
|
||||
|
||||
# Skip rate limiting for exempt paths before any processing
|
||||
if self.is_exempt_path(endpoint):
|
||||
logger.debug(f"Endpoint {endpoint} is exempt from rate limiting")
|
||||
return True, None, None
|
||||
|
||||
with self.lock:
|
||||
# Check global limits first
|
||||
global_ok, global_msg = self.check_global_limits()
|
||||
@@ -295,6 +312,14 @@ def rate_limit(endpoint=None,
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Skip rate limiting for admin routes - check both path and endpoint
|
||||
if request.path.startswith('/admin'):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Also check endpoint name
|
||||
if request.endpoint and request.endpoint.startswith('admin.'):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Get client ID
|
||||
client_id = rate_limiter.get_client_id(request)
|
||||
ip = request.remote_addr
|
||||
@@ -403,6 +428,10 @@ ip_filter = IPFilter()
|
||||
|
||||
def ip_filter_check():
|
||||
"""Middleware to check IP filtering"""
|
||||
# Skip IP filtering for admin routes
|
||||
if request.path.startswith('/admin'):
|
||||
return None
|
||||
|
||||
ip = request.remote_addr
|
||||
if not ip_filter.is_allowed(ip):
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
446
redis_manager.py
Normal file
446
redis_manager.py
Normal file
@@ -0,0 +1,446 @@
|
||||
# Redis connection and caching management
|
||||
import redis
|
||||
import json
|
||||
import pickle
|
||||
import logging
|
||||
from typing import Optional, Any, Dict, List, Union
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisManager:
|
||||
"""Manage Redis connections and operations"""
|
||||
|
||||
def __init__(self, app=None, config=None):
|
||||
self.redis_client = None
|
||||
self.config = config or {}
|
||||
self.key_prefix = self.config.get('key_prefix', 'talk2me:')
|
||||
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Initialize Redis with Flask app"""
|
||||
# Get Redis configuration
|
||||
redis_url = app.config.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
# Parse connection options
|
||||
decode_responses = app.config.get('REDIS_DECODE_RESPONSES', False)
|
||||
max_connections = app.config.get('REDIS_MAX_CONNECTIONS', 50)
|
||||
socket_timeout = app.config.get('REDIS_SOCKET_TIMEOUT', 5)
|
||||
|
||||
# Create connection pool
|
||||
pool = redis.ConnectionPool.from_url(
|
||||
redis_url,
|
||||
max_connections=max_connections,
|
||||
socket_timeout=socket_timeout,
|
||||
decode_responses=decode_responses
|
||||
)
|
||||
|
||||
self.redis_client = redis.Redis(connection_pool=pool)
|
||||
|
||||
# Test connection
|
||||
try:
|
||||
self.redis_client.ping()
|
||||
logger.info(f"Redis connected successfully to {redis_url}")
|
||||
except redis.ConnectionError as e:
|
||||
logger.error(f"Failed to connect to Redis: {e}")
|
||||
raise
|
||||
|
||||
# Store reference in app
|
||||
app.redis = self
|
||||
|
||||
def _make_key(self, key: str) -> str:
|
||||
"""Create a prefixed key"""
|
||||
return f"{self.key_prefix}{key}"
|
||||
|
||||
# Basic operations
|
||||
def get(self, key: str, default=None) -> Any:
|
||||
"""Get value from Redis"""
|
||||
try:
|
||||
value = self.redis_client.get(self._make_key(key))
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
# Try to deserialize JSON first
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Try pickle for complex objects
|
||||
try:
|
||||
return pickle.loads(value)
|
||||
except:
|
||||
# Return as string
|
||||
return value.decode('utf-8') if isinstance(value, bytes) else value
|
||||
except Exception as e:
|
||||
logger.error(f"Redis get error for key {key}: {e}")
|
||||
return default
|
||||
|
||||
def set(self, key: str, value: Any, expire: Optional[int] = None) -> bool:
|
||||
"""Set value in Redis with optional expiration"""
|
||||
try:
|
||||
# Serialize value
|
||||
if isinstance(value, (str, int, float)):
|
||||
serialized = str(value)
|
||||
elif isinstance(value, (dict, list)):
|
||||
serialized = json.dumps(value)
|
||||
else:
|
||||
serialized = pickle.dumps(value)
|
||||
|
||||
return self.redis_client.set(
|
||||
self._make_key(key),
|
||||
serialized,
|
||||
ex=expire
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis set error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
def delete(self, *keys) -> int:
|
||||
"""Delete one or more keys"""
|
||||
try:
|
||||
prefixed_keys = [self._make_key(k) for k in keys]
|
||||
return self.redis_client.delete(*prefixed_keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis delete error: {e}")
|
||||
return 0
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if key exists"""
|
||||
try:
|
||||
return bool(self.redis_client.exists(self._make_key(key)))
|
||||
except Exception as e:
|
||||
logger.error(f"Redis exists error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
def expire(self, key: str, seconds: int) -> bool:
|
||||
"""Set expiration on a key"""
|
||||
try:
|
||||
return bool(self.redis_client.expire(self._make_key(key), seconds))
|
||||
except Exception as e:
|
||||
logger.error(f"Redis expire error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
# Hash operations for session/rate limiting
|
||||
def hget(self, name: str, key: str, default=None) -> Any:
|
||||
"""Get value from hash"""
|
||||
try:
|
||||
value = self.redis_client.hget(self._make_key(name), key)
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value.decode('utf-8') if isinstance(value, bytes) else value
|
||||
except Exception as e:
|
||||
logger.error(f"Redis hget error for {name}:{key}: {e}")
|
||||
return default
|
||||
|
||||
def hset(self, name: str, key: str, value: Any) -> bool:
|
||||
"""Set value in hash"""
|
||||
try:
|
||||
if isinstance(value, (dict, list)):
|
||||
value = json.dumps(value)
|
||||
return bool(self.redis_client.hset(self._make_key(name), key, value))
|
||||
except Exception as e:
|
||||
logger.error(f"Redis hset error for {name}:{key}: {e}")
|
||||
return False
|
||||
|
||||
def hgetall(self, name: str) -> Dict[str, Any]:
|
||||
"""Get all values from hash"""
|
||||
try:
|
||||
data = self.redis_client.hgetall(self._make_key(name))
|
||||
result = {}
|
||||
for k, v in data.items():
|
||||
key = k.decode('utf-8') if isinstance(k, bytes) else k
|
||||
try:
|
||||
result[key] = json.loads(v)
|
||||
except:
|
||||
result[key] = v.decode('utf-8') if isinstance(v, bytes) else v
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Redis hgetall error for {name}: {e}")
|
||||
return {}
|
||||
|
||||
def hdel(self, name: str, *keys) -> int:
|
||||
"""Delete fields from hash"""
|
||||
try:
|
||||
return self.redis_client.hdel(self._make_key(name), *keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis hdel error for {name}: {e}")
|
||||
return 0
|
||||
|
||||
# List operations for queues
|
||||
def lpush(self, key: str, *values) -> int:
|
||||
"""Push values to the left of list"""
|
||||
try:
|
||||
serialized = []
|
||||
for v in values:
|
||||
if isinstance(v, (dict, list)):
|
||||
serialized.append(json.dumps(v))
|
||||
else:
|
||||
serialized.append(v)
|
||||
return self.redis_client.lpush(self._make_key(key), *serialized)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis lpush error for {key}: {e}")
|
||||
return 0
|
||||
|
||||
def rpop(self, key: str, default=None) -> Any:
|
||||
"""Pop value from the right of list"""
|
||||
try:
|
||||
value = self.redis_client.rpop(self._make_key(key))
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
try:
|
||||
return json.loads(value)
|
||||
except:
|
||||
return value.decode('utf-8') if isinstance(value, bytes) else value
|
||||
except Exception as e:
|
||||
logger.error(f"Redis rpop error for {key}: {e}")
|
||||
return default
|
||||
|
||||
def llen(self, key: str) -> int:
|
||||
"""Get length of list"""
|
||||
try:
|
||||
return self.redis_client.llen(self._make_key(key))
|
||||
except Exception as e:
|
||||
logger.error(f"Redis llen error for {key}: {e}")
|
||||
return 0
|
||||
|
||||
# Set operations for unique tracking
|
||||
def sadd(self, key: str, *values) -> int:
|
||||
"""Add members to set"""
|
||||
try:
|
||||
return self.redis_client.sadd(self._make_key(key), *values)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis sadd error for {key}: {e}")
|
||||
return 0
|
||||
|
||||
def srem(self, key: str, *values) -> int:
|
||||
"""Remove members from set"""
|
||||
try:
|
||||
return self.redis_client.srem(self._make_key(key), *values)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis srem error for {key}: {e}")
|
||||
return 0
|
||||
|
||||
def sismember(self, key: str, value: Any) -> bool:
|
||||
"""Check if value is member of set"""
|
||||
try:
|
||||
return bool(self.redis_client.sismember(self._make_key(key), value))
|
||||
except Exception as e:
|
||||
logger.error(f"Redis sismember error for {key}: {e}")
|
||||
return False
|
||||
|
||||
def scard(self, key: str) -> int:
|
||||
"""Get number of members in set"""
|
||||
try:
|
||||
return self.redis_client.scard(self._make_key(key))
|
||||
except Exception as e:
|
||||
logger.error(f"Redis scard error for {key}: {e}")
|
||||
return 0
|
||||
|
||||
def smembers(self, key: str) -> set:
|
||||
"""Get all members of set"""
|
||||
try:
|
||||
members = self.redis_client.smembers(self._make_key(key))
|
||||
return {m.decode('utf-8') if isinstance(m, bytes) else m for m in members}
|
||||
except Exception as e:
|
||||
logger.error(f"Redis smembers error for {key}: {e}")
|
||||
return set()
|
||||
|
||||
# Atomic counters
|
||||
def incr(self, key: str, amount: int = 1) -> int:
|
||||
"""Increment counter"""
|
||||
try:
|
||||
return self.redis_client.incr(self._make_key(key), amount)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis incr error for {key}: {e}")
|
||||
return 0
|
||||
|
||||
def decr(self, key: str, amount: int = 1) -> int:
|
||||
"""Decrement counter"""
|
||||
try:
|
||||
return self.redis_client.decr(self._make_key(key), amount)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis decr error for {key}: {e}")
|
||||
return 0
|
||||
|
||||
# Transaction support
|
||||
def pipeline(self):
|
||||
"""Create a pipeline for atomic operations"""
|
||||
return self.redis_client.pipeline()
|
||||
|
||||
# Pub/Sub support
|
||||
def publish(self, channel: str, message: Any) -> int:
|
||||
"""Publish message to channel"""
|
||||
try:
|
||||
if isinstance(message, (dict, list)):
|
||||
message = json.dumps(message)
|
||||
return self.redis_client.publish(self._make_key(channel), message)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis publish error for {channel}: {e}")
|
||||
return 0
|
||||
|
||||
def subscribe(self, *channels):
|
||||
"""Subscribe to channels"""
|
||||
pubsub = self.redis_client.pubsub()
|
||||
prefixed_channels = [self._make_key(c) for c in channels]
|
||||
pubsub.subscribe(*prefixed_channels)
|
||||
return pubsub
|
||||
|
||||
# Cache helpers
|
||||
def cache_translation(self, source_text: str, source_lang: str,
|
||||
target_lang: str, translation: str,
|
||||
expire_hours: int = 24) -> bool:
|
||||
"""Cache a translation"""
|
||||
key = self._translation_key(source_text, source_lang, target_lang)
|
||||
data = {
|
||||
'translation': translation,
|
||||
'timestamp': time.time(),
|
||||
'hits': 0
|
||||
}
|
||||
return self.set(key, data, expire=expire_hours * 3600)
|
||||
|
||||
def get_cached_translation(self, source_text: str, source_lang: str,
|
||||
target_lang: str) -> Optional[str]:
|
||||
"""Get cached translation and increment hit counter"""
|
||||
key = self._translation_key(source_text, source_lang, target_lang)
|
||||
data = self.get(key)
|
||||
|
||||
if data and isinstance(data, dict):
|
||||
# Increment hit counter
|
||||
data['hits'] = data.get('hits', 0) + 1
|
||||
self.set(key, data)
|
||||
return data.get('translation')
|
||||
|
||||
return None
|
||||
|
||||
def _translation_key(self, text: str, source_lang: str, target_lang: str) -> str:
|
||||
"""Generate cache key for translation"""
|
||||
# Create a hash of the text to handle long texts
|
||||
text_hash = hashlib.md5(text.encode()).hexdigest()
|
||||
return f"translation:{source_lang}:{target_lang}:{text_hash}"
|
||||
|
||||
# Session management
|
||||
def save_session(self, session_id: str, data: Dict[str, Any],
|
||||
expire_seconds: int = 3600) -> bool:
|
||||
"""Save session data"""
|
||||
key = f"session:{session_id}"
|
||||
return self.set(key, data, expire=expire_seconds)
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get session data"""
|
||||
key = f"session:{session_id}"
|
||||
return self.get(key)
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete session data"""
|
||||
key = f"session:{session_id}"
|
||||
return bool(self.delete(key))
|
||||
|
||||
def extend_session(self, session_id: str, expire_seconds: int = 3600) -> bool:
|
||||
"""Extend session expiration"""
|
||||
key = f"session:{session_id}"
|
||||
return self.expire(key, expire_seconds)
|
||||
|
||||
# Rate limiting
|
||||
def check_rate_limit(self, identifier: str, limit: int,
|
||||
window_seconds: int) -> tuple[bool, int]:
|
||||
"""Check rate limit using sliding window"""
|
||||
key = f"rate_limit:{identifier}:{window_seconds}"
|
||||
now = time.time()
|
||||
window_start = now - window_seconds
|
||||
|
||||
pipe = self.pipeline()
|
||||
|
||||
# Remove old entries
|
||||
pipe.zremrangebyscore(self._make_key(key), 0, window_start)
|
||||
|
||||
# Count current entries
|
||||
pipe.zcard(self._make_key(key))
|
||||
|
||||
# Add current request
|
||||
pipe.zadd(self._make_key(key), {str(now): now})
|
||||
|
||||
# Set expiration
|
||||
pipe.expire(self._make_key(key), window_seconds + 1)
|
||||
|
||||
results = pipe.execute()
|
||||
current_count = results[1]
|
||||
|
||||
if current_count >= limit:
|
||||
return False, limit - current_count
|
||||
|
||||
return True, limit - current_count - 1
|
||||
|
||||
# Cleanup
|
||||
def cleanup_expired_keys(self, pattern: str = "*") -> int:
|
||||
"""Clean up expired keys matching pattern"""
|
||||
try:
|
||||
cursor = 0
|
||||
deleted = 0
|
||||
|
||||
while True:
|
||||
cursor, keys = self.redis_client.scan(
|
||||
cursor,
|
||||
match=self._make_key(pattern),
|
||||
count=100
|
||||
)
|
||||
|
||||
for key in keys:
|
||||
ttl = self.redis_client.ttl(key)
|
||||
if ttl == -2: # Key doesn't exist
|
||||
continue
|
||||
elif ttl == -1: # Key exists but no TTL
|
||||
# Set a default TTL of 24 hours for keys without expiration
|
||||
self.redis_client.expire(key, 86400)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
return deleted
|
||||
except Exception as e:
|
||||
logger.error(f"Redis cleanup error: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
# Cache decorator
|
||||
def redis_cache(expire_seconds: int = 300, key_prefix: str = ""):
|
||||
"""Decorator to cache function results in Redis"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Get Redis instance from app context
|
||||
from flask import current_app
|
||||
redis_manager = getattr(current_app, 'redis', None)
|
||||
|
||||
if not redis_manager:
|
||||
# No Redis, execute function normally
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# Generate cache key
|
||||
cache_key = f"{key_prefix}:{func.__name__}:"
|
||||
cache_key += hashlib.md5(
|
||||
f"{args}:{kwargs}".encode()
|
||||
).hexdigest()
|
||||
|
||||
# Try to get from cache
|
||||
cached = redis_manager.get(cache_key)
|
||||
if cached is not None:
|
||||
logger.debug(f"Cache hit for {func.__name__}")
|
||||
return cached
|
||||
|
||||
# Execute function and cache result
|
||||
result = func(*args, **kwargs)
|
||||
redis_manager.set(cache_key, result, expire=expire_seconds)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
365
redis_rate_limiter.py
Normal file
365
redis_rate_limiter.py
Normal file
@@ -0,0 +1,365 @@
|
||||
# Redis-based rate limiting implementation
|
||||
import time
|
||||
import logging
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, g
|
||||
import hashlib
|
||||
from typing import Optional, Dict, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisRateLimiter:
|
||||
"""Token bucket rate limiter using Redis for distributed rate limiting"""
|
||||
|
||||
def __init__(self, redis_manager):
|
||||
self.redis = redis_manager
|
||||
|
||||
# Default limits (can be overridden per endpoint)
|
||||
self.default_limits = {
|
||||
'requests_per_minute': 30,
|
||||
'requests_per_hour': 500,
|
||||
'burst_size': 10,
|
||||
'token_refresh_rate': 0.5 # tokens per second
|
||||
}
|
||||
|
||||
# Endpoint-specific limits
|
||||
self.endpoint_limits = {
|
||||
'/transcribe': {
|
||||
'requests_per_minute': 10,
|
||||
'requests_per_hour': 100,
|
||||
'burst_size': 3,
|
||||
'token_refresh_rate': 0.167,
|
||||
'max_request_size': 10 * 1024 * 1024 # 10MB
|
||||
},
|
||||
'/translate': {
|
||||
'requests_per_minute': 20,
|
||||
'requests_per_hour': 300,
|
||||
'burst_size': 5,
|
||||
'token_refresh_rate': 0.333,
|
||||
'max_request_size': 100 * 1024 # 100KB
|
||||
},
|
||||
'/translate/stream': {
|
||||
'requests_per_minute': 10,
|
||||
'requests_per_hour': 150,
|
||||
'burst_size': 3,
|
||||
'token_refresh_rate': 0.167,
|
||||
'max_request_size': 100 * 1024 # 100KB
|
||||
},
|
||||
'/speak': {
|
||||
'requests_per_minute': 15,
|
||||
'requests_per_hour': 200,
|
||||
'burst_size': 3,
|
||||
'token_refresh_rate': 0.25,
|
||||
'max_request_size': 50 * 1024 # 50KB
|
||||
}
|
||||
}
|
||||
|
||||
# Global limits
|
||||
self.global_limits = {
|
||||
'total_requests_per_minute': 1000,
|
||||
'total_requests_per_hour': 10000,
|
||||
'concurrent_requests': 50
|
||||
}
|
||||
|
||||
def get_client_id(self, req) -> str:
|
||||
"""Get unique client identifier"""
|
||||
ip = req.remote_addr or 'unknown'
|
||||
user_agent = req.headers.get('User-Agent', '')
|
||||
|
||||
# Handle proxied requests
|
||||
forwarded_for = req.headers.get('X-Forwarded-For')
|
||||
if forwarded_for:
|
||||
ip = forwarded_for.split(',')[0].strip()
|
||||
|
||||
# Create unique identifier
|
||||
identifier = f"{ip}:{user_agent}"
|
||||
return hashlib.md5(identifier.encode()).hexdigest()
|
||||
|
||||
def get_limits(self, endpoint: str) -> Dict:
|
||||
"""Get rate limits for endpoint"""
|
||||
return self.endpoint_limits.get(endpoint, self.default_limits)
|
||||
|
||||
def is_ip_blocked(self, ip: str) -> bool:
|
||||
"""Check if IP is blocked"""
|
||||
# Check permanent blocks
|
||||
if self.redis.sismember('blocked_ips:permanent', ip):
|
||||
return True
|
||||
|
||||
# Check temporary blocks
|
||||
block_key = f'blocked_ip:{ip}'
|
||||
if self.redis.exists(block_key):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def block_ip_temporarily(self, ip: str, duration: int = 3600):
|
||||
"""Block IP temporarily"""
|
||||
block_key = f'blocked_ip:{ip}'
|
||||
self.redis.set(block_key, 1, expire=duration)
|
||||
logger.warning(f"IP {ip} temporarily blocked for {duration} seconds")
|
||||
|
||||
def check_global_limits(self) -> Tuple[bool, Optional[str]]:
|
||||
"""Check global rate limits"""
|
||||
now = time.time()
|
||||
|
||||
# Check requests per minute
|
||||
minute_key = 'global:requests:minute'
|
||||
allowed, remaining = self.redis.check_rate_limit(
|
||||
minute_key,
|
||||
self.global_limits['total_requests_per_minute'],
|
||||
60
|
||||
)
|
||||
if not allowed:
|
||||
return False, "Global rate limit exceeded (per minute)"
|
||||
|
||||
# Check requests per hour
|
||||
hour_key = 'global:requests:hour'
|
||||
allowed, remaining = self.redis.check_rate_limit(
|
||||
hour_key,
|
||||
self.global_limits['total_requests_per_hour'],
|
||||
3600
|
||||
)
|
||||
if not allowed:
|
||||
return False, "Global rate limit exceeded (per hour)"
|
||||
|
||||
# Check concurrent requests
|
||||
concurrent_key = 'global:concurrent'
|
||||
current_concurrent = self.redis.get(concurrent_key, 0)
|
||||
if current_concurrent >= self.global_limits['concurrent_requests']:
|
||||
return False, "Too many concurrent requests"
|
||||
|
||||
return True, None
|
||||
|
||||
def check_rate_limit(self, client_id: str, endpoint: str,
|
||||
request_size: int = 0) -> Tuple[bool, Optional[str], Optional[Dict]]:
|
||||
"""Check if request should be allowed"""
|
||||
# Check global limits first
|
||||
global_ok, global_msg = self.check_global_limits()
|
||||
if not global_ok:
|
||||
return False, global_msg, None
|
||||
|
||||
# Get limits for endpoint
|
||||
limits = self.get_limits(endpoint)
|
||||
|
||||
# Check request size if applicable
|
||||
if request_size > 0 and 'max_request_size' in limits:
|
||||
if request_size > limits['max_request_size']:
|
||||
return False, "Request too large", None
|
||||
|
||||
# Token bucket implementation using Redis
|
||||
bucket_key = f'bucket:{client_id}:{endpoint}'
|
||||
now = time.time()
|
||||
|
||||
# Get current bucket state
|
||||
bucket_data = self.redis.hgetall(bucket_key)
|
||||
|
||||
# Initialize bucket if empty
|
||||
if not bucket_data:
|
||||
bucket_data = {
|
||||
'tokens': limits['burst_size'],
|
||||
'last_update': now
|
||||
}
|
||||
else:
|
||||
# Update tokens based on time passed
|
||||
last_update = float(bucket_data.get('last_update', now))
|
||||
time_passed = now - last_update
|
||||
new_tokens = time_passed * limits['token_refresh_rate']
|
||||
|
||||
current_tokens = float(bucket_data.get('tokens', 0))
|
||||
bucket_data['tokens'] = min(
|
||||
limits['burst_size'],
|
||||
current_tokens + new_tokens
|
||||
)
|
||||
bucket_data['last_update'] = now
|
||||
|
||||
# Check sliding window limits
|
||||
minute_allowed, minute_remaining = self.redis.check_rate_limit(
|
||||
f'window:{client_id}:{endpoint}:minute',
|
||||
limits['requests_per_minute'],
|
||||
60
|
||||
)
|
||||
|
||||
if not minute_allowed:
|
||||
return False, "Rate limit exceeded (per minute)", {
|
||||
'retry_after': 60,
|
||||
'limit': limits['requests_per_minute'],
|
||||
'remaining': 0,
|
||||
'reset': int(now + 60)
|
||||
}
|
||||
|
||||
hour_allowed, hour_remaining = self.redis.check_rate_limit(
|
||||
f'window:{client_id}:{endpoint}:hour',
|
||||
limits['requests_per_hour'],
|
||||
3600
|
||||
)
|
||||
|
||||
if not hour_allowed:
|
||||
return False, "Rate limit exceeded (per hour)", {
|
||||
'retry_after': 3600,
|
||||
'limit': limits['requests_per_hour'],
|
||||
'remaining': 0,
|
||||
'reset': int(now + 3600)
|
||||
}
|
||||
|
||||
# Check token bucket
|
||||
if float(bucket_data['tokens']) < 1:
|
||||
retry_after = int(1 / limits['token_refresh_rate'])
|
||||
return False, "Rate limit exceeded (burst)", {
|
||||
'retry_after': retry_after,
|
||||
'limit': limits['burst_size'],
|
||||
'remaining': 0,
|
||||
'reset': int(now + retry_after)
|
||||
}
|
||||
|
||||
# Request allowed - update bucket
|
||||
bucket_data['tokens'] = float(bucket_data['tokens']) - 1
|
||||
|
||||
# Save bucket state
|
||||
self.redis.hset(bucket_key, 'tokens', bucket_data['tokens'])
|
||||
self.redis.hset(bucket_key, 'last_update', bucket_data['last_update'])
|
||||
self.redis.expire(bucket_key, 86400) # Expire after 24 hours
|
||||
|
||||
return True, None, {
|
||||
'limit': limits['requests_per_minute'],
|
||||
'remaining': minute_remaining,
|
||||
'reset': int(now + 60)
|
||||
}
|
||||
|
||||
def increment_concurrent(self):
|
||||
"""Increment concurrent request counter"""
|
||||
self.redis.incr('global:concurrent')
|
||||
|
||||
def decrement_concurrent(self):
|
||||
"""Decrement concurrent request counter"""
|
||||
self.redis.decr('global:concurrent')
|
||||
|
||||
def get_client_stats(self, client_id: str) -> Optional[Dict]:
|
||||
"""Get statistics for a client"""
|
||||
stats = {
|
||||
'requests_last_minute': 0,
|
||||
'requests_last_hour': 0,
|
||||
'buckets': {}
|
||||
}
|
||||
|
||||
# Get request counts from all endpoints
|
||||
for endpoint in self.endpoint_limits.keys():
|
||||
minute_key = f'window:{client_id}:{endpoint}:minute'
|
||||
hour_key = f'window:{client_id}:{endpoint}:hour'
|
||||
|
||||
# This is approximate since we're using sliding windows
|
||||
minute_count = self.redis.scard(minute_key)
|
||||
hour_count = self.redis.scard(hour_key)
|
||||
|
||||
stats['requests_last_minute'] += minute_count
|
||||
stats['requests_last_hour'] += hour_count
|
||||
|
||||
# Get bucket info
|
||||
bucket_key = f'bucket:{client_id}:{endpoint}'
|
||||
bucket_data = self.redis.hgetall(bucket_key)
|
||||
if bucket_data:
|
||||
stats['buckets'][endpoint] = {
|
||||
'tokens': float(bucket_data.get('tokens', 0)),
|
||||
'last_update': float(bucket_data.get('last_update', 0))
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def rate_limit(endpoint=None,
|
||||
requests_per_minute=None,
|
||||
requests_per_hour=None,
|
||||
burst_size=None,
|
||||
check_size=False):
|
||||
"""
|
||||
Rate limiting decorator for Flask routes using Redis
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get Redis rate limiter from app
|
||||
from flask import current_app
|
||||
|
||||
if not hasattr(current_app, 'redis_rate_limiter'):
|
||||
# No Redis rate limiter, execute function normally
|
||||
return f(*args, **kwargs)
|
||||
|
||||
rate_limiter = current_app.redis_rate_limiter
|
||||
|
||||
# Get client ID
|
||||
client_id = rate_limiter.get_client_id(request)
|
||||
ip = request.remote_addr
|
||||
|
||||
# Check if IP is blocked
|
||||
if rate_limiter.is_ip_blocked(ip):
|
||||
return jsonify({
|
||||
'error': 'IP temporarily blocked due to excessive requests'
|
||||
}), 429
|
||||
|
||||
# Get endpoint
|
||||
endpoint_path = endpoint or request.endpoint
|
||||
|
||||
# Override default limits if specified
|
||||
if any([requests_per_minute, requests_per_hour, burst_size]):
|
||||
limits = rate_limiter.get_limits(endpoint_path).copy()
|
||||
if requests_per_minute:
|
||||
limits['requests_per_minute'] = requests_per_minute
|
||||
if requests_per_hour:
|
||||
limits['requests_per_hour'] = requests_per_hour
|
||||
if burst_size:
|
||||
limits['burst_size'] = burst_size
|
||||
rate_limiter.endpoint_limits[endpoint_path] = limits
|
||||
|
||||
# Check request size if needed
|
||||
request_size = 0
|
||||
if check_size:
|
||||
request_size = request.content_length or 0
|
||||
|
||||
# Check rate limit
|
||||
allowed, message, headers = rate_limiter.check_rate_limit(
|
||||
client_id, endpoint_path, request_size
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
# Log excessive requests
|
||||
logger.warning(f"Rate limit exceeded for {client_id} on {endpoint_path}: {message}")
|
||||
|
||||
# Check if we should temporarily block this IP
|
||||
stats = rate_limiter.get_client_stats(client_id)
|
||||
if stats and stats['requests_last_minute'] > 100:
|
||||
rate_limiter.block_ip_temporarily(ip, 3600) # 1 hour block
|
||||
|
||||
response = jsonify({
|
||||
'error': message,
|
||||
'retry_after': headers.get('retry_after') if headers else 60
|
||||
})
|
||||
response.status_code = 429
|
||||
|
||||
# Add rate limit headers
|
||||
if headers:
|
||||
response.headers['X-RateLimit-Limit'] = str(headers['limit'])
|
||||
response.headers['X-RateLimit-Remaining'] = str(headers['remaining'])
|
||||
response.headers['X-RateLimit-Reset'] = str(headers['reset'])
|
||||
response.headers['Retry-After'] = str(headers['retry_after'])
|
||||
|
||||
return response
|
||||
|
||||
# Track concurrent requests
|
||||
rate_limiter.increment_concurrent()
|
||||
|
||||
try:
|
||||
# Add rate limit info to response
|
||||
g.rate_limit_headers = headers
|
||||
response = f(*args, **kwargs)
|
||||
|
||||
# Add headers to successful response
|
||||
if headers and hasattr(response, 'headers'):
|
||||
response.headers['X-RateLimit-Limit'] = str(headers['limit'])
|
||||
response.headers['X-RateLimit-Remaining'] = str(headers['remaining'])
|
||||
response.headers['X-RateLimit-Reset'] = str(headers['reset'])
|
||||
|
||||
return response
|
||||
finally:
|
||||
rate_limiter.decrement_concurrent()
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
389
redis_session_manager.py
Normal file
389
redis_session_manager.py
Normal file
@@ -0,0 +1,389 @@
|
||||
# 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")
|
||||
@@ -1,5 +1,10 @@
|
||||
flask
|
||||
flask-cors
|
||||
flask-sqlalchemy
|
||||
flask-migrate
|
||||
flask-jwt-extended
|
||||
flask-bcrypt
|
||||
flask-login
|
||||
requests
|
||||
openai-whisper
|
||||
torch
|
||||
@@ -10,3 +15,10 @@ python-dotenv
|
||||
click
|
||||
colorlog
|
||||
psutil
|
||||
redis
|
||||
psycopg2-binary
|
||||
alembic
|
||||
flask-socketio
|
||||
python-socketio
|
||||
eventlet
|
||||
python-dateutil
|
||||
|
||||
32
run_dev_server.sh
Executable file
32
run_dev_server.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Run Talk2Me development server locally
|
||||
|
||||
echo "Starting Talk2Me development server..."
|
||||
echo "=================================="
|
||||
echo "Admin Dashboard: http://localhost:5005/admin"
|
||||
echo " Token: 4CFvwzmeDWhecfuOHYz7Hyb8nQQ="
|
||||
echo ""
|
||||
echo "User Login: http://localhost:5005/login"
|
||||
echo " Username: admin"
|
||||
echo " Password: talk2me123"
|
||||
echo ""
|
||||
echo "API Authentication:"
|
||||
echo " API Key: 6sy2_m8e89FeC2RmUo0CcgufM9b_0OoIwIa8LSEbNhI"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
|
||||
# Kill any existing process on port 5005
|
||||
lsof -ti:5005 | xargs kill -9 2>/dev/null
|
||||
|
||||
# Set environment variables
|
||||
export FLASK_ENV=development
|
||||
export FLASK_DEBUG=1
|
||||
|
||||
# Run with gunicorn for a more production-like environment
|
||||
gunicorn --bind 0.0.0.0:5005 \
|
||||
--workers 1 \
|
||||
--threads 2 \
|
||||
--timeout 120 \
|
||||
--reload \
|
||||
--log-level debug \
|
||||
wsgi:application
|
||||
776
setup-script.sh
776
setup-script.sh
@@ -1,776 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p templates static/{css,js}
|
||||
|
||||
# Move HTML template to templates directory
|
||||
cat > templates/index.html << 'EOL'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Voice Language Translator</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
body {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.record-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
margin: 20px auto;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.record-btn:active {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.recording {
|
||||
background-color: #dc3545 !important;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.card {
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-header {
|
||||
border-radius: 15px 15px 0 0 !important;
|
||||
}
|
||||
.language-select {
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.text-display {
|
||||
min-height: 100px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.btn-action {
|
||||
border-radius: 10px;
|
||||
padding: 8px 15px;
|
||||
margin: 5px;
|
||||
}
|
||||
.spinner-border {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.status-indicator {
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="text-center mb-4">Voice Language Translator</h1>
|
||||
<p class="text-center text-muted">Powered by Gemma 3, Whisper & Edge TTS</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">Source</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<select id="sourceLanguage" class="form-select language-select mb-3">
|
||||
{% for language in languages %}
|
||||
<option value="{{ language }}">{{ language }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="text-display" id="sourceText">
|
||||
<p class="text-muted">Your transcribed text will appear here...</p>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<button id="playSource" class="btn btn-outline-primary btn-action" disabled>
|
||||
<i class="fas fa-play"></i> Play
|
||||
</button>
|
||||
<button id="clearSource" class="btn btn-outline-secondary btn-action">
|
||||
<i class="fas fa-trash"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">Translation</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<select id="targetLanguage" class="form-select language-select mb-3">
|
||||
{% for language in languages %}
|
||||
<option value="{{ language }}">{{ language }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="text-display" id="translatedText">
|
||||
<p class="text-muted">Translation will appear here...</p>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<button id="playTranslation" class="btn btn-outline-success btn-action" disabled>
|
||||
<i class="fas fa-play"></i> Play
|
||||
</button>
|
||||
<button id="clearTranslation" class="btn btn-outline-secondary btn-action">
|
||||
<i class="fas fa-trash"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button id="recordBtn" class="btn btn-primary record-btn">
|
||||
<i class="fas fa-microphone"></i>
|
||||
</button>
|
||||
<p class="status-indicator" id="statusIndicator">Click to start recording</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<button id="translateBtn" class="btn btn-success" disabled>
|
||||
<i class="fas fa-language"></i> Translate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="progress d-none" id="progressContainer">
|
||||
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="audioPlayer" style="display: none;"></audio>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM elements
|
||||
const recordBtn = document.getElementById('recordBtn');
|
||||
const translateBtn = document.getElementById('translateBtn');
|
||||
const sourceText = document.getElementById('sourceText');
|
||||
const translatedText = document.getElementById('translatedText');
|
||||
const sourceLanguage = document.getElementById('sourceLanguage');
|
||||
const targetLanguage = document.getElementById('targetLanguage');
|
||||
const playSource = document.getElementById('playSource');
|
||||
const playTranslation = document.getElementById('playTranslation');
|
||||
const clearSource = document.getElementById('clearSource');
|
||||
const clearTranslation = document.getElementById('clearTranslation');
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
const progressContainer = document.getElementById('progressContainer');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const audioPlayer = document.getElementById('audioPlayer');
|
||||
|
||||
// Set initial values
|
||||
let isRecording = false;
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = [];
|
||||
let currentSourceText = '';
|
||||
let currentTranslationText = '';
|
||||
|
||||
// Make sure target language is different from source
|
||||
if (targetLanguage.options[0].value === sourceLanguage.value) {
|
||||
targetLanguage.selectedIndex = 1;
|
||||
}
|
||||
|
||||
// Event listeners for language selection
|
||||
sourceLanguage.addEventListener('change', function() {
|
||||
if (targetLanguage.value === sourceLanguage.value) {
|
||||
for (let i = 0; i < targetLanguage.options.length; i++) {
|
||||
if (targetLanguage.options[i].value !== sourceLanguage.value) {
|
||||
targetLanguage.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
targetLanguage.addEventListener('change', function() {
|
||||
if (targetLanguage.value === sourceLanguage.value) {
|
||||
for (let i = 0; i < sourceLanguage.options.length; i++) {
|
||||
if (sourceLanguage.options[i].value !== targetLanguage.value) {
|
||||
sourceLanguage.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Record button click event
|
||||
recordBtn.addEventListener('click', function() {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
}
|
||||
});
|
||||
|
||||
// Function to start recording
|
||||
function startRecording() {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then(stream => {
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunks = [];
|
||||
|
||||
mediaRecorder.addEventListener('dataavailable', event => {
|
||||
audioChunks.push(event.data);
|
||||
});
|
||||
|
||||
mediaRecorder.addEventListener('stop', () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
|
||||
transcribeAudio(audioBlob);
|
||||
});
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecording = true;
|
||||
recordBtn.classList.add('recording');
|
||||
recordBtn.classList.replace('btn-primary', 'btn-danger');
|
||||
recordBtn.innerHTML = '<i class="fas fa-stop"></i>';
|
||||
statusIndicator.textContent = 'Recording... Click to stop';
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error accessing microphone:', error);
|
||||
alert('Error accessing microphone. Please make sure you have given permission for microphone access.');
|
||||
});
|
||||
}
|
||||
|
||||
// Function to stop recording
|
||||
function stopRecording() {
|
||||
mediaRecorder.stop();
|
||||
isRecording = false;
|
||||
recordBtn.classList.remove('recording');
|
||||
recordBtn.classList.replace('btn-danger', 'btn-primary');
|
||||
recordBtn.innerHTML = '<i class="fas fa-microphone"></i>';
|
||||
statusIndicator.textContent = 'Processing audio...';
|
||||
|
||||
// Stop all audio tracks
|
||||
mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
// Function to transcribe audio
|
||||
function transcribeAudio(audioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob);
|
||||
formData.append('source_lang', sourceLanguage.value);
|
||||
|
||||
showProgress();
|
||||
|
||||
fetch('/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
hideProgress();
|
||||
|
||||
if (data.success) {
|
||||
currentSourceText = data.text;
|
||||
sourceText.innerHTML = `<p>${data.text}</p>`;
|
||||
playSource.disabled = false;
|
||||
translateBtn.disabled = false;
|
||||
statusIndicator.textContent = 'Transcription complete';
|
||||
} else {
|
||||
sourceText.innerHTML = `<p class="text-danger">Error: ${data.error}</p>`;
|
||||
statusIndicator.textContent = 'Transcription failed';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
hideProgress();
|
||||
console.error('Transcription error:', error);
|
||||
sourceText.innerHTML = `<p class="text-danger">Failed to transcribe audio. Please try again.</p>`;
|
||||
statusIndicator.textContent = 'Transcription failed';
|
||||
});
|
||||
}
|
||||
|
||||
// Translate button click event
|
||||
translateBtn.addEventListener('click', function() {
|
||||
if (!currentSourceText) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusIndicator.textContent = 'Translating...';
|
||||
showProgress();
|
||||
|
||||
fetch('/translate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: currentSourceText,
|
||||
source_lang: sourceLanguage.value,
|
||||
target_lang: targetLanguage.value
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
hideProgress();
|
||||
|
||||
if (data.success) {
|
||||
currentTranslationText = data.translation;
|
||||
translatedText.innerHTML = `<p>${data.translation}</p>`;
|
||||
playTranslation.disabled = false;
|
||||
statusIndicator.textContent = 'Translation complete';
|
||||
} else {
|
||||
translatedText.innerHTML = `<p class="text-danger">Error: ${data.error}</p>`;
|
||||
statusIndicator.textContent = 'Translation failed';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
hideProgress();
|
||||
console.error('Translation error:', error);
|
||||
translatedText.innerHTML = `<p class="text-danger">Failed to translate. Please try again.</p>`;
|
||||
statusIndicator.textContent = 'Translation failed';
|
||||
});
|
||||
});
|
||||
|
||||
// Play source text
|
||||
playSource.addEventListener('click', function() {
|
||||
if (!currentSourceText) return;
|
||||
|
||||
playAudio(currentSourceText, sourceLanguage.value);
|
||||
statusIndicator.textContent = 'Playing source audio...';
|
||||
});
|
||||
|
||||
// Play translation
|
||||
playTranslation.addEventListener('click', function() {
|
||||
if (!currentTranslationText) return;
|
||||
|
||||
playAudio(currentTranslationText, targetLanguage.value);
|
||||
statusIndicator.textContent = 'Playing translation audio...';
|
||||
});
|
||||
|
||||
// Function to play audio via TTS
|
||||
function playAudio(text, language) {
|
||||
showProgress();
|
||||
|
||||
fetch('/speak', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: text,
|
||||
language: language
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
hideProgress();
|
||||
|
||||
if (data.success) {
|
||||
audioPlayer.src = data.audio_url;
|
||||
audioPlayer.onended = function() {
|
||||
statusIndicator.textContent = 'Ready';
|
||||
};
|
||||
audioPlayer.play();
|
||||
} else {
|
||||
statusIndicator.textContent = 'TTS failed';
|
||||
alert('Failed to play audio: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
hideProgress();
|
||||
console.error('TTS error:', error);
|
||||
statusIndicator.textContent = 'TTS failed';
|
||||
});
|
||||
}
|
||||
|
||||
// Clear buttons
|
||||
clearSource.addEventListener('click', function() {
|
||||
sourceText.innerHTML = '<p class="text-muted">Your transcribed text will appear here...</p>';
|
||||
currentSourceText = '';
|
||||
playSource.disabled = true;
|
||||
translateBtn.disabled = true;
|
||||
});
|
||||
|
||||
clearTranslation.addEventListener('click', function() {
|
||||
translatedText.innerHTML = '<p class="text-muted">Translation will appear here...</p>';
|
||||
currentTranslationText = '';
|
||||
playTranslation.disabled = true;
|
||||
});
|
||||
|
||||
// Progress indicator functions
|
||||
function showProgress() {
|
||||
progressContainer.classList.remove('d-none');
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += 5;
|
||||
if (progress > 90) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
progressBar.style.width = `${progress}%`;
|
||||
}, 100);
|
||||
progressBar.dataset.interval = interval;
|
||||
}
|
||||
|
||||
function hideProgress() {
|
||||
const interval = progressBar.dataset.interval;
|
||||
if (interval) {
|
||||
clearInterval(Number(interval));
|
||||
}
|
||||
progressBar.style.width = '100%';
|
||||
setTimeout(() => {
|
||||
progressContainer.classList.add('d-none');
|
||||
progressBar.style.width = '0%';
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
EOL
|
||||
|
||||
# Create app.py
|
||||
cat > app.py << 'EOL'
|
||||
import os
|
||||
import time
|
||||
import tempfile
|
||||
import requests
|
||||
import json
|
||||
from flask import Flask, render_template, request, jsonify, Response, send_file
|
||||
import whisper
|
||||
import torch
|
||||
import ollama
|
||||
import logging
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['UPLOAD_FOLDER'] = tempfile.mkdtemp()
|
||||
app.config['TTS_SERVER'] = os.environ.get('TTS_SERVER_URL', 'http://localhost:5050/v1/audio/speech')
|
||||
app.config['TTS_API_KEY'] = os.environ.get('TTS_API_KEY', 'your_api_key_here')
|
||||
|
||||
# Add a route to check TTS server status
|
||||
@app.route('/check_tts_server', methods=['GET'])
|
||||
def check_tts_server():
|
||||
try:
|
||||
# Try a simple HTTP request to the TTS server
|
||||
response = requests.get(app.config['TTS_SERVER'].rsplit('/api/generate', 1)[0] + '/status', timeout=5)
|
||||
if response.status_code == 200:
|
||||
return jsonify({
|
||||
'status': 'online',
|
||||
'url': app.config['TTS_SERVER']
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'TTS server returned status code {response.status_code}',
|
||||
'url': app.config['TTS_SERVER']
|
||||
})
|
||||
except requests.exceptions.RequestException as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to TTS server: {str(e)}',
|
||||
'url': app.config['TTS_SERVER']
|
||||
})
|
||||
|
||||
# Initialize logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load Whisper model
|
||||
logger.info("Loading Whisper model...")
|
||||
whisper_model = whisper.load_model("base")
|
||||
logger.info("Whisper model loaded successfully")
|
||||
|
||||
# Supported languages
|
||||
SUPPORTED_LANGUAGES = {
|
||||
"ar": "Arabic",
|
||||
"hy": "Armenian",
|
||||
"az": "Azerbaijani",
|
||||
"en": "English",
|
||||
"fr": "French",
|
||||
"ka": "Georgian",
|
||||
"kk": "Kazakh",
|
||||
"zh": "Mandarin",
|
||||
"fa": "Farsi",
|
||||
"pt": "Portuguese",
|
||||
"ru": "Russian",
|
||||
"es": "Spanish",
|
||||
"tr": "Turkish",
|
||||
"uz": "Uzbek"
|
||||
}
|
||||
|
||||
# Map language names to language codes
|
||||
LANGUAGE_TO_CODE = {v: k for k, v in SUPPORTED_LANGUAGES.items()}
|
||||
|
||||
# Map language names to OpenAI TTS voice options
|
||||
LANGUAGE_TO_VOICE = {
|
||||
"Arabic": "alloy", # Using OpenAI general voices
|
||||
"Armenian": "echo", # as OpenAI doesn't have specific voices
|
||||
"Azerbaijani": "nova", # for all these languages
|
||||
"English": "echo", # We'll use the available voices
|
||||
"French": "alloy", # and rely on the translation being
|
||||
"Georgian": "fable", # in the correct language text
|
||||
"Kazakh": "onyx",
|
||||
"Mandarin": "shimmer",
|
||||
"Farsi": "nova",
|
||||
"Portuguese": "alloy",
|
||||
"Russian": "echo",
|
||||
"Spanish": "nova",
|
||||
"Turkish": "fable",
|
||||
"Uzbek": "onyx"
|
||||
}
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html', languages=sorted(SUPPORTED_LANGUAGES.values()))
|
||||
|
||||
@app.route('/transcribe', methods=['POST'])
|
||||
def transcribe():
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({'error': 'No audio file provided'}), 400
|
||||
|
||||
audio_file = request.files['audio']
|
||||
source_lang = request.form.get('source_lang', '')
|
||||
|
||||
# Save the audio file temporarily
|
||||
temp_path = os.path.join(app.config['UPLOAD_FOLDER'], 'input_audio.wav')
|
||||
audio_file.save(temp_path)
|
||||
|
||||
try:
|
||||
# Use Whisper for transcription
|
||||
result = whisper_model.transcribe(
|
||||
temp_path,
|
||||
language=LANGUAGE_TO_CODE.get(source_lang, None)
|
||||
)
|
||||
transcribed_text = result["text"]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'text': transcribed_text
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription error: {str(e)}")
|
||||
return jsonify({'error': f'Transcription failed: {str(e)}'}), 500
|
||||
finally:
|
||||
# Clean up the temporary file
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
@app.route('/translate', methods=['POST'])
|
||||
def translate():
|
||||
try:
|
||||
data = request.json
|
||||
text = data.get('text', '')
|
||||
source_lang = data.get('source_lang', '')
|
||||
target_lang = data.get('target_lang', '')
|
||||
|
||||
if not text or not source_lang or not target_lang:
|
||||
return jsonify({'error': 'Missing required parameters'}), 400
|
||||
|
||||
# Create a prompt for Gemma 3 translation
|
||||
prompt = f"""
|
||||
Translate the following text from {source_lang} to {target_lang}:
|
||||
|
||||
"{text}"
|
||||
|
||||
Provide only the translation without any additional text.
|
||||
"""
|
||||
|
||||
# Use Ollama to interact with Gemma 3
|
||||
response = ollama.chat(
|
||||
model="gemma3",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
translated_text = response['message']['content'].strip()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'translation': translated_text
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Translation error: {str(e)}")
|
||||
return jsonify({'error': f'Translation failed: {str(e)}'}), 500
|
||||
|
||||
@app.route('/speak', methods=['POST'])
|
||||
def speak():
|
||||
try:
|
||||
data = request.json
|
||||
text = data.get('text', '')
|
||||
language = data.get('language', '')
|
||||
|
||||
if not text or not language:
|
||||
return jsonify({'error': 'Missing required parameters'}), 400
|
||||
|
||||
voice = LANGUAGE_TO_VOICE.get(language)
|
||||
if not voice:
|
||||
return jsonify({'error': 'Unsupported language for TTS'}), 400
|
||||
|
||||
# Get TTS server URL from environment or config
|
||||
tts_server_url = app.config['TTS_SERVER']
|
||||
|
||||
try:
|
||||
# Request TTS from the Edge TTS server
|
||||
logger.info(f"Sending TTS request to {tts_server_url}")
|
||||
tts_response = requests.post(
|
||||
tts_server_url,
|
||||
json={
|
||||
'text': text,
|
||||
'voice': voice,
|
||||
'output_format': 'mp3'
|
||||
},
|
||||
timeout=10 # Add timeout
|
||||
)
|
||||
|
||||
logger.info(f"TTS response status: {tts_response.status_code}")
|
||||
|
||||
if tts_response.status_code != 200:
|
||||
error_msg = f'TTS request failed with status {tts_response.status_code}'
|
||||
logger.error(error_msg)
|
||||
|
||||
# Try to get error details from response if possible
|
||||
try:
|
||||
error_details = tts_response.json()
|
||||
logger.error(f"Error details: {error_details}")
|
||||
except:
|
||||
pass
|
||||
|
||||
return jsonify({'error': error_msg}), 500
|
||||
|
||||
# The response contains the audio data directly
|
||||
temp_audio_path = os.path.join(app.config['UPLOAD_FOLDER'], f'output_{int(time.time())}.mp3')
|
||||
with open(temp_audio_path, 'wb') as f:
|
||||
f.write(tts_response.content)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'audio_url': f'/get_audio/{os.path.basename(temp_audio_path)}'
|
||||
})
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f'Failed to connect to TTS server: {str(e)}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'error': error_msg}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"TTS error: {str(e)}")
|
||||
return jsonify({'error': f'TTS failed: {str(e)}'}), 500
|
||||
|
||||
@app.route('/get_audio/<filename>')
|
||||
def get_audio(filename):
|
||||
try:
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
return send_file(file_path, mimetype='audio/mpeg')
|
||||
except Exception as e:
|
||||
logger.error(f"Audio retrieval error: {str(e)}")
|
||||
return jsonify({'error': f'Audio retrieval failed: {str(e)}'}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=8000, debug=True)
|
||||
EOL
|
||||
|
||||
# Create requirements.txt
|
||||
cat > requirements.txt << 'EOL'
|
||||
flask==2.3.2
|
||||
requests==2.31.0
|
||||
openai-whisper==20231117
|
||||
torch==2.1.0
|
||||
ollama==0.1.5
|
||||
EOL
|
||||
|
||||
# Create README.md
|
||||
cat > README.md << 'EOL'
|
||||
# Voice Language Translator
|
||||
|
||||
A mobile-friendly web application that translates spoken language between multiple languages using:
|
||||
- Gemma 3 open-source LLM via Ollama for translation
|
||||
- OpenAI Whisper for speech-to-text
|
||||
- OpenAI Edge TTS for text-to-speech
|
||||
|
||||
## Supported Languages
|
||||
|
||||
- Arabic
|
||||
- Armenian
|
||||
- Azerbaijani
|
||||
- English
|
||||
- French
|
||||
- Georgian
|
||||
- Kazakh
|
||||
- Mandarin
|
||||
- Farsi
|
||||
- Portuguese
|
||||
- Russian
|
||||
- Spanish
|
||||
- Turkish
|
||||
- Uzbek
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
1. Install the required Python packages:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Make sure you have Ollama installed and the Gemma 3 model loaded:
|
||||
```
|
||||
ollama pull gemma3
|
||||
```
|
||||
|
||||
3. Ensure your OpenAI Edge TTS server is running on port 5050.
|
||||
|
||||
4. Run the application:
|
||||
```
|
||||
python app.py
|
||||
```
|
||||
|
||||
5. Open your browser and navigate to:
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Select your source language from the dropdown menu
|
||||
2. Press the microphone button and speak
|
||||
3. Press the button again to stop recording
|
||||
4. Wait for the transcription to complete
|
||||
5. Select your target language
|
||||
6. Press the "Translate" button
|
||||
7. Use the play buttons to hear the original or translated text
|
||||
|
||||
## Technical Details
|
||||
|
||||
- The app uses Flask for the web server
|
||||
- Audio is processed client-side using the MediaRecorder API
|
||||
- Whisper for speech recognition with language hints
|
||||
- Ollama provides access to the Gemma 3 model for translation
|
||||
- OpenAI Edge TTS delivers natural-sounding speech output
|
||||
|
||||
## Mobile Support
|
||||
|
||||
The interface is fully responsive and designed to work well on mobile devices.
|
||||
EOL
|
||||
|
||||
# Make the script executable
|
||||
chmod +x app.py
|
||||
|
||||
echo "Setup complete! Run the app with: python app.py"
|
||||
156
setup_databases.sh
Executable file
156
setup_databases.sh
Executable file
@@ -0,0 +1,156 @@
|
||||
#!/bin/bash
|
||||
# Setup script for Redis and PostgreSQL
|
||||
|
||||
echo "Talk2Me Database Setup Script"
|
||||
echo "============================="
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
echo "Please do not run this script as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to check if command exists
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Check for PostgreSQL
|
||||
echo "Checking PostgreSQL installation..."
|
||||
if command_exists psql; then
|
||||
echo "✓ PostgreSQL is installed"
|
||||
psql --version
|
||||
else
|
||||
echo "✗ PostgreSQL is not installed"
|
||||
echo "Please install PostgreSQL first:"
|
||||
echo " Ubuntu/Debian: sudo apt-get install postgresql postgresql-contrib"
|
||||
echo " MacOS: brew install postgresql"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for Redis
|
||||
echo ""
|
||||
echo "Checking Redis installation..."
|
||||
if command_exists redis-cli; then
|
||||
echo "✓ Redis is installed"
|
||||
redis-cli --version
|
||||
else
|
||||
echo "✗ Redis is not installed"
|
||||
echo "Please install Redis first:"
|
||||
echo " Ubuntu/Debian: sudo apt-get install redis-server"
|
||||
echo " MacOS: brew install redis"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Redis is running
|
||||
echo ""
|
||||
echo "Checking Redis server status..."
|
||||
if redis-cli ping > /dev/null 2>&1; then
|
||||
echo "✓ Redis server is running"
|
||||
else
|
||||
echo "✗ Redis server is not running"
|
||||
echo "Starting Redis server..."
|
||||
if command_exists systemctl; then
|
||||
sudo systemctl start redis
|
||||
else
|
||||
redis-server --daemonize yes
|
||||
fi
|
||||
sleep 2
|
||||
if redis-cli ping > /dev/null 2>&1; then
|
||||
echo "✓ Redis server started successfully"
|
||||
else
|
||||
echo "✗ Failed to start Redis server"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create PostgreSQL database
|
||||
echo ""
|
||||
echo "Setting up PostgreSQL database..."
|
||||
read -p "Enter PostgreSQL username (default: $USER): " PG_USER
|
||||
PG_USER=${PG_USER:-$USER}
|
||||
|
||||
read -p "Enter database name (default: talk2me): " DB_NAME
|
||||
DB_NAME=${DB_NAME:-talk2me}
|
||||
|
||||
# Check if database exists
|
||||
if psql -U "$PG_USER" -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then
|
||||
echo "Database '$DB_NAME' already exists"
|
||||
read -p "Do you want to drop and recreate it? (y/N): " RECREATE
|
||||
if [[ $RECREATE =~ ^[Yy]$ ]]; then
|
||||
echo "Dropping database '$DB_NAME'..."
|
||||
psql -U "$PG_USER" -c "DROP DATABASE $DB_NAME;"
|
||||
echo "Creating database '$DB_NAME'..."
|
||||
psql -U "$PG_USER" -c "CREATE DATABASE $DB_NAME;"
|
||||
fi
|
||||
else
|
||||
echo "Creating database '$DB_NAME'..."
|
||||
psql -U "$PG_USER" -c "CREATE DATABASE $DB_NAME;"
|
||||
fi
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if [ ! -f .env ]; then
|
||||
echo ""
|
||||
echo "Creating .env file..."
|
||||
cat > .env << EOF
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://$PG_USER@localhost/$DB_NAME
|
||||
SQLALCHEMY_DATABASE_URI=postgresql://$PG_USER@localhost/$DB_NAME
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_DECODE_RESPONSES=false
|
||||
REDIS_MAX_CONNECTIONS=50
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=$(openssl rand -base64 32)
|
||||
ADMIN_TOKEN=$(openssl rand -base64 24)
|
||||
|
||||
# TTS Configuration
|
||||
TTS_SERVER_URL=http://localhost:5050/v1/audio/speech
|
||||
TTS_API_KEY=your-tts-api-key-here
|
||||
|
||||
# Whisper Configuration
|
||||
WHISPER_MODEL_SIZE=base
|
||||
WHISPER_DEVICE=auto
|
||||
|
||||
# Ollama Configuration
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
OLLAMA_MODEL=gemma3:27b
|
||||
EOF
|
||||
echo "✓ .env file created"
|
||||
echo "Please update the TTS_API_KEY in .env file"
|
||||
else
|
||||
echo "✓ .env file already exists"
|
||||
fi
|
||||
|
||||
# Install Python dependencies
|
||||
echo ""
|
||||
echo "Installing Python dependencies..."
|
||||
if [ -f "requirements.txt" ]; then
|
||||
pip install -r requirements.txt
|
||||
echo "✓ Python dependencies installed"
|
||||
else
|
||||
echo "✗ requirements.txt not found"
|
||||
fi
|
||||
|
||||
# Initialize database
|
||||
echo ""
|
||||
echo "Initializing database..."
|
||||
python database_init.py
|
||||
|
||||
echo ""
|
||||
echo "Setup complete!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Update the TTS_API_KEY in .env file"
|
||||
echo "2. Run 'python migrations.py init' to initialize migrations"
|
||||
echo "3. Run 'python migrations.py create \"Initial migration\"' to create first migration"
|
||||
echo "4. Run 'python migrations.py run' to apply migrations"
|
||||
echo "5. Backup your current app.py and rename app_with_db.py to app.py"
|
||||
echo "6. Start the application with 'python app.py'"
|
||||
echo ""
|
||||
echo "To run Redis and PostgreSQL as services:"
|
||||
echo " Redis: sudo systemctl enable redis && sudo systemctl start redis"
|
||||
echo " PostgreSQL: sudo systemctl enable postgresql && sudo systemctl start postgresql"
|
||||
218
static/check-pwa-status.html
Normal file
218
static/check-pwa-status.html
Normal file
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PWA Status Check - Talk2Me</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
margin: 10px 0;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Talk2Me PWA Status Check</h1>
|
||||
|
||||
<div id="results"></div>
|
||||
|
||||
<h2>Actions</h2>
|
||||
<button onclick="testInstall()">Test Install Prompt</button>
|
||||
<button onclick="clearPWA()">Clear PWA Data</button>
|
||||
<button onclick="location.reload()">Refresh Page</button>
|
||||
|
||||
<script>
|
||||
const results = document.getElementById('results');
|
||||
|
||||
function addResult(message, type = 'info') {
|
||||
const div = document.createElement('div');
|
||||
div.className = `status ${type}`;
|
||||
div.textContent = message;
|
||||
results.appendChild(div);
|
||||
}
|
||||
|
||||
// Check HTTPS
|
||||
if (location.protocol === 'https:' || location.hostname === 'localhost') {
|
||||
addResult('✓ HTTPS enabled', 'success');
|
||||
} else {
|
||||
addResult('✗ HTTPS required for PWA', 'error');
|
||||
}
|
||||
|
||||
// Check Service Worker support
|
||||
if ('serviceWorker' in navigator) {
|
||||
addResult('✓ Service Worker supported', 'success');
|
||||
|
||||
// Check registration
|
||||
navigator.serviceWorker.getRegistration().then(reg => {
|
||||
if (reg) {
|
||||
addResult(`✓ Service Worker registered (scope: ${reg.scope})`, 'success');
|
||||
if (reg.active) {
|
||||
addResult('✓ Service Worker is active', 'success');
|
||||
} else if (reg.installing) {
|
||||
addResult('⚠ Service Worker is installing', 'warning');
|
||||
} else if (reg.waiting) {
|
||||
addResult('⚠ Service Worker is waiting', 'warning');
|
||||
}
|
||||
} else {
|
||||
addResult('✗ Service Worker not registered', 'error');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
addResult('✗ Service Worker not supported', 'error');
|
||||
}
|
||||
|
||||
// Check manifest
|
||||
const manifestLink = document.querySelector('link[rel="manifest"]');
|
||||
if (manifestLink) {
|
||||
addResult('✓ Manifest link found', 'success');
|
||||
|
||||
fetch(manifestLink.href)
|
||||
.then(r => r.json())
|
||||
.then(manifest => {
|
||||
addResult('✓ Manifest loaded successfully', 'success');
|
||||
|
||||
// Check required fields
|
||||
const required = ['name', 'short_name', 'start_url', 'display', 'icons'];
|
||||
required.forEach(field => {
|
||||
if (manifest[field]) {
|
||||
addResult(`✓ Manifest has ${field}`, 'success');
|
||||
} else {
|
||||
addResult(`✗ Manifest missing ${field}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Check icons
|
||||
if (manifest.icons && manifest.icons.length > 0) {
|
||||
const has192 = manifest.icons.some(i => i.sizes && i.sizes.includes('192'));
|
||||
const has512 = manifest.icons.some(i => i.sizes && i.sizes.includes('512'));
|
||||
|
||||
if (has192) addResult('✓ Has 192x192 icon', 'success');
|
||||
else addResult('✗ Missing 192x192 icon', 'error');
|
||||
|
||||
if (has512) addResult('✓ Has 512x512 icon', 'success');
|
||||
else addResult('⚠ Missing 512x512 icon (recommended)', 'warning');
|
||||
|
||||
// Check icon purposes
|
||||
manifest.icons.forEach((icon, i) => {
|
||||
if (icon.purpose) {
|
||||
addResult(`Icon ${i+1}: purpose="${icon.purpose}"`, 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show manifest content
|
||||
const pre = document.createElement('pre');
|
||||
pre.textContent = JSON.stringify(manifest, null, 2);
|
||||
results.appendChild(pre);
|
||||
})
|
||||
.catch(err => {
|
||||
addResult(`✗ Failed to load manifest: ${err}`, 'error');
|
||||
});
|
||||
} else {
|
||||
addResult('✗ No manifest link found', 'error');
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
addResult('✓ App is running in standalone mode (already installed)', 'success');
|
||||
} else {
|
||||
addResult('App is running in browser mode', 'info');
|
||||
}
|
||||
|
||||
// Listen for install prompt
|
||||
let deferredPrompt;
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
addResult('✓ Install prompt is available!', 'success');
|
||||
addResult('Chrome recognizes this as an installable PWA', 'success');
|
||||
});
|
||||
|
||||
// Check Chrome version
|
||||
const userAgent = navigator.userAgent;
|
||||
const chromeMatch = userAgent.match(/Chrome\/(\d+)/);
|
||||
if (chromeMatch) {
|
||||
const version = parseInt(chromeMatch[1]);
|
||||
addResult(`Chrome version: ${version}`, 'info');
|
||||
if (version < 90) {
|
||||
addResult('⚠ Chrome version is old, consider updating', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Test install
|
||||
function testInstall() {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
addResult('✓ User accepted the install prompt', 'success');
|
||||
} else {
|
||||
addResult('User dismissed the install prompt', 'warning');
|
||||
}
|
||||
deferredPrompt = null;
|
||||
});
|
||||
} else {
|
||||
addResult('No install prompt available. Chrome may not recognize this as installable.', 'error');
|
||||
addResult('Try: Menu (⋮) → Add to Home screen', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear PWA data
|
||||
function clearPWA() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
for(let registration of registrations) {
|
||||
registration.unregister();
|
||||
}
|
||||
addResult('Service Workers unregistered', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(function(names) {
|
||||
for (let name of names) {
|
||||
caches.delete(name);
|
||||
}
|
||||
addResult('Caches cleared', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
addResult('PWA data cleared. Reload the page to re-register.', 'info');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -422,6 +422,41 @@
|
||||
max-width: 300px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Make the entire layout more compact on mobile */
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Reduce spacing on mobile */
|
||||
.mb-3 {
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Compact cards on mobile */
|
||||
.card {
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
/* Hide less important elements on small screens */
|
||||
.text-muted.small {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Adjust button sizes */
|
||||
.btn {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Make dropdowns more compact */
|
||||
.form-select {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Streaming translation styles */
|
||||
|
||||
1385
static/js/app.js
1385
static/js/app.js
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,9 @@ import {
|
||||
TranscriptionResponse,
|
||||
TranslationResponse,
|
||||
TTSResponse,
|
||||
TTSServerStatus,
|
||||
TTSConfigUpdate,
|
||||
TTSConfigResponse,
|
||||
// TTSServerStatus, // Moved to admin dashboard
|
||||
// TTSConfigUpdate, // Moved to admin dashboard
|
||||
// TTSConfigResponse, // Moved to admin dashboard
|
||||
TranslationRequest,
|
||||
TTSRequest,
|
||||
PushPublicKeyResponse,
|
||||
@@ -143,11 +143,12 @@ function initApp(): void {
|
||||
const progressContainer = document.getElementById('progressContainer') as HTMLDivElement;
|
||||
const progressBar = document.getElementById('progressBar') as HTMLDivElement;
|
||||
const audioPlayer = document.getElementById('audioPlayer') as HTMLAudioElement;
|
||||
const ttsServerAlert = document.getElementById('ttsServerAlert') as HTMLDivElement;
|
||||
const ttsServerMessage = document.getElementById('ttsServerMessage') as HTMLSpanElement;
|
||||
const ttsServerUrl = document.getElementById('ttsServerUrl') as HTMLInputElement;
|
||||
const ttsApiKey = document.getElementById('ttsApiKey') as HTMLInputElement;
|
||||
const updateTtsServer = document.getElementById('updateTtsServer') as HTMLButtonElement;
|
||||
// TTS server UI elements - moved to admin dashboard
|
||||
// const ttsServerAlert = document.getElementById('ttsServerAlert') as HTMLDivElement;
|
||||
// const ttsServerMessage = document.getElementById('ttsServerMessage') as HTMLSpanElement;
|
||||
// const ttsServerUrl = document.getElementById('ttsServerUrl') as HTMLInputElement;
|
||||
// const ttsApiKey = document.getElementById('ttsApiKey') as HTMLInputElement;
|
||||
// const updateTtsServer = document.getElementById('updateTtsServer') as HTMLButtonElement;
|
||||
const loadingOverlay = document.getElementById('loadingOverlay') as HTMLDivElement;
|
||||
const loadingText = document.getElementById('loadingText') as HTMLParagraphElement;
|
||||
|
||||
@@ -157,7 +158,7 @@ function initApp(): void {
|
||||
let currentAudioHandler: AudioBlobHandler | null = null;
|
||||
let currentSourceText: string = '';
|
||||
let currentTranslationText: string = '';
|
||||
let currentTtsServerUrl: string = '';
|
||||
// let currentTtsServerUrl: string = ''; // Moved to admin dashboard
|
||||
|
||||
// Performance monitoring
|
||||
const performanceMonitor = PerformanceMonitor.getInstance();
|
||||
@@ -167,7 +168,8 @@ function initApp(): void {
|
||||
let multiSpeakerEnabled = false;
|
||||
|
||||
// Check TTS server status on page load
|
||||
checkTtsServer();
|
||||
// Moved to admin dashboard - commenting out user-facing TTS status check
|
||||
// checkTtsServer();
|
||||
|
||||
// Check for saved translations in IndexedDB
|
||||
loadSavedTranslations();
|
||||
@@ -293,7 +295,7 @@ function initApp(): void {
|
||||
<div class="conversation-entry">
|
||||
<div class="conversation-speaker">
|
||||
<span class="conversation-speaker-avatar" style="background-color: ${entry.speakerColor}">
|
||||
${entry.speakerName.substr(0, 2).toUpperCase()}
|
||||
${entry.speakerName.substring(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span style="color: ${entry.speakerColor}">${entry.speakerName}</span>
|
||||
<span class="conversation-time">${new Date(entry.timestamp).toLocaleTimeString()}</span>
|
||||
@@ -314,6 +316,8 @@ function initApp(): void {
|
||||
}
|
||||
|
||||
// Update TTS server URL and API key
|
||||
// Moved to admin dashboard - commenting out user-facing TTS configuration update
|
||||
/*
|
||||
updateTtsServer.addEventListener('click', function() {
|
||||
const newUrl = ttsServerUrl.value.trim();
|
||||
const newApiKey = ttsApiKey.value.trim();
|
||||
@@ -359,7 +363,8 @@ function initApp(): void {
|
||||
// Save URL to localStorage but not the API key for security
|
||||
if (newUrl) localStorage.setItem('ttsServerUrl', newUrl);
|
||||
// Check TTS server with new configuration
|
||||
checkTtsServer();
|
||||
// Moved to admin dashboard - commenting out user-facing TTS status check
|
||||
// checkTtsServer();
|
||||
} else {
|
||||
alert('Failed to update TTS configuration: ' + data.error);
|
||||
}
|
||||
@@ -369,6 +374,7 @@ function initApp(): void {
|
||||
alert('Failed to update TTS configuration. See console for details.');
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
// Make sure target language is different from source
|
||||
if (targetLanguage.options[0].value === sourceLanguage.value) {
|
||||
@@ -714,6 +720,21 @@ function initApp(): void {
|
||||
language: data.detected_language || sourceLanguage.value,
|
||||
timestamp: new Date().toISOString()
|
||||
} as TranscriptionRecord);
|
||||
|
||||
// Automatically trigger translation
|
||||
setTimeout(async () => {
|
||||
statusIndicator.textContent = 'Automatically translating...';
|
||||
statusIndicator.classList.add('processing');
|
||||
try {
|
||||
await performTranslation();
|
||||
} catch (error) {
|
||||
console.error('Auto-translation failed:', error);
|
||||
statusIndicator.textContent = 'Transcription complete - Translation failed';
|
||||
statusIndicator.classList.remove('processing');
|
||||
statusIndicator.classList.add('warning');
|
||||
setTimeout(() => statusIndicator.classList.remove('warning'), 2000);
|
||||
}
|
||||
}, 500); // Small delay for better UX
|
||||
} else {
|
||||
sourceText.innerHTML = `<p class="text-danger fade-in">Error: ${data.error}</p>`;
|
||||
statusIndicator.textContent = 'Transcription failed';
|
||||
@@ -756,8 +777,8 @@ function initApp(): void {
|
||||
}
|
||||
);
|
||||
|
||||
// Translate button click event
|
||||
translateBtn.addEventListener('click', errorBoundary.wrapAsync(async function() {
|
||||
// Function to perform translation (extracted from button handler)
|
||||
const performTranslation = async function(): Promise<void> {
|
||||
if (!currentSourceText) {
|
||||
return;
|
||||
}
|
||||
@@ -794,7 +815,10 @@ function initApp(): void {
|
||||
}
|
||||
|
||||
// No cache hit, proceed with API call
|
||||
statusIndicator.textContent = 'Translating...';
|
||||
// Don't update status if already showing 'Automatically translating...'
|
||||
if (!statusIndicator.textContent?.includes('Automatically translating')) {
|
||||
statusIndicator.textContent = 'Translating...';
|
||||
}
|
||||
|
||||
// Use streaming if enabled
|
||||
if (streamingEnabled && navigator.onLine) {
|
||||
@@ -992,12 +1016,19 @@ function initApp(): void {
|
||||
statusIndicator.textContent = 'Translation failed';
|
||||
}
|
||||
}
|
||||
}, 'translation', async () => {
|
||||
hideProgress();
|
||||
hideLoadingOverlay();
|
||||
translatedText.innerHTML = '<p class="text-danger">Translation failed. Please try again.</p>';
|
||||
statusIndicator.textContent = 'Translation error - please retry';
|
||||
}));
|
||||
};
|
||||
|
||||
// Translate button click event (now just calls performTranslation)
|
||||
translateBtn.addEventListener('click', errorBoundary.wrapAsync(
|
||||
performTranslation,
|
||||
'translation',
|
||||
async () => {
|
||||
hideProgress();
|
||||
hideLoadingOverlay();
|
||||
translatedText.innerHTML = '<p class="text-danger">Translation failed. Please try again.</p>';
|
||||
statusIndicator.textContent = 'Translation error - please retry';
|
||||
}
|
||||
));
|
||||
|
||||
// Play source text
|
||||
playSource.addEventListener('click', function() {
|
||||
@@ -1068,15 +1099,16 @@ function initApp(): void {
|
||||
statusIndicator.textContent = 'TTS failed';
|
||||
|
||||
// Show TTS server alert with error message
|
||||
ttsServerAlert.classList.remove('d-none');
|
||||
ttsServerAlert.classList.remove('alert-success');
|
||||
ttsServerAlert.classList.add('alert-warning');
|
||||
ttsServerMessage.textContent = data.error || 'TTS failed';
|
||||
// Moved to admin dashboard - commenting out user-facing TTS server alert
|
||||
// ttsServerAlert.classList.remove('d-none');
|
||||
// ttsServerAlert.classList.remove('alert-success');
|
||||
// ttsServerAlert.classList.add('alert-warning');
|
||||
// ttsServerMessage.textContent = data.error || 'TTS failed';
|
||||
|
||||
alert('Failed to play audio: ' + data.error);
|
||||
|
||||
// Check TTS server status again
|
||||
checkTtsServer();
|
||||
// checkTtsServer();
|
||||
}
|
||||
} catch (error: any) {
|
||||
hideProgress();
|
||||
@@ -1090,10 +1122,11 @@ function initApp(): void {
|
||||
connectionUI.showTemporaryMessage('Audio generation queued for when connection returns', 'warning');
|
||||
|
||||
// Show TTS server alert
|
||||
ttsServerAlert.classList.remove('d-none');
|
||||
ttsServerAlert.classList.remove('alert-success');
|
||||
ttsServerAlert.classList.add('alert-warning');
|
||||
ttsServerMessage.textContent = 'Connection error - request queued';
|
||||
// Moved to admin dashboard - commenting out user-facing TTS server alert
|
||||
// ttsServerAlert.classList.remove('d-none');
|
||||
// ttsServerAlert.classList.remove('alert-success');
|
||||
// ttsServerAlert.classList.add('alert-warning');
|
||||
// ttsServerMessage.textContent = 'Connection error - request queued';
|
||||
} else if (!navigator.onLine) {
|
||||
statusIndicator.textContent = 'Offline - audio generation unavailable';
|
||||
alert('Audio generation requires an internet connection.');
|
||||
@@ -1101,10 +1134,11 @@ function initApp(): void {
|
||||
statusIndicator.textContent = 'TTS failed';
|
||||
|
||||
// Show TTS server alert
|
||||
ttsServerAlert.classList.remove('d-none');
|
||||
ttsServerAlert.classList.remove('alert-success');
|
||||
ttsServerAlert.classList.add('alert-warning');
|
||||
ttsServerMessage.textContent = 'Failed to connect to TTS server';
|
||||
// Moved to admin dashboard - commenting out user-facing TTS server alert
|
||||
// ttsServerAlert.classList.remove('d-none');
|
||||
// ttsServerAlert.classList.remove('alert-success');
|
||||
// ttsServerAlert.classList.add('alert-warning');
|
||||
// ttsServerMessage.textContent = 'Failed to connect to TTS server';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1136,6 +1170,8 @@ function initApp(): void {
|
||||
});
|
||||
|
||||
// Function to check TTS server status
|
||||
// Moved to admin dashboard - commenting out user-facing TTS status check function
|
||||
/*
|
||||
function checkTtsServer(): void {
|
||||
fetch('/check_tts_server')
|
||||
.then(response => response.json() as Promise<TTSServerStatus>)
|
||||
@@ -1176,6 +1212,7 @@ function initApp(): void {
|
||||
ttsServerMessage.textContent = 'Failed to check TTS server status.';
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
// Progress indicator functions
|
||||
function showProgress(): void {
|
||||
@@ -1306,7 +1343,8 @@ function initApp(): void {
|
||||
queue.clearStuckRequests();
|
||||
|
||||
// Re-check TTS server
|
||||
checkTtsServer();
|
||||
// Moved to admin dashboard - commenting out user-facing TTS status check
|
||||
// checkTtsServer();
|
||||
|
||||
// Try to reload service worker if available
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -1398,7 +1436,7 @@ function initInstallPrompt(): void {
|
||||
const installButton = document.createElement('button');
|
||||
installButton.style.display = 'none';
|
||||
installButton.classList.add('btn', 'btn-success', 'fixed-bottom', 'm-3');
|
||||
installButton.innerHTML = 'Install Voice Translator <i class="fas fa-download ml-2"></i>';
|
||||
installButton.innerHTML = 'Install Talk2Me <i class="fas fa-download ml-2"></i>';
|
||||
document.body.appendChild(installButton);
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
{
|
||||
"name": "Talk2Me",
|
||||
"short_name": "Translator",
|
||||
"description": "Translate spoken language between multiple languages with speech input and output",
|
||||
"short_name": "Talk2Me",
|
||||
"description": "Real-time voice translation app - translate spoken language instantly",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#007bff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./static/icons/icon-192x192.png",
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "./static/icons/icon-512x512.png",
|
||||
"src": "/static/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
"shortcuts": [
|
||||
{
|
||||
"src": "./static/screenshots/screenshot1.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png"
|
||||
"name": "Start Recording",
|
||||
"short_name": "Record",
|
||||
"description": "Start voice recording for translation",
|
||||
"url": "/?action=record",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"categories": ["productivity", "utilities", "education"],
|
||||
"prefer_related_applications": false,
|
||||
"related_applications": []
|
||||
}
|
||||
|
||||
41
static/pwa-update.js
Normal file
41
static/pwa-update.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// PWA Update Helper
|
||||
// This script helps force PWA updates on clients
|
||||
|
||||
// Force service worker update
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
for(let registration of registrations) {
|
||||
registration.unregister().then(function() {
|
||||
console.log('Service worker unregistered');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Re-register after a short delay
|
||||
setTimeout(function() {
|
||||
navigator.serviceWorker.register('/service-worker.js')
|
||||
.then(function(registration) {
|
||||
console.log('Service worker re-registered');
|
||||
registration.update();
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Clear all caches
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(function(names) {
|
||||
for (let name of names) {
|
||||
caches.delete(name);
|
||||
console.log('Cache cleared:', name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reload manifest
|
||||
var link = document.querySelector('link[rel="manifest"]');
|
||||
if (link) {
|
||||
link.href = link.href + '?v=' + Date.now();
|
||||
console.log('Manifest reloaded');
|
||||
}
|
||||
|
||||
console.log('PWA update complete. Please reload the page.');
|
||||
@@ -1,10 +1,11 @@
|
||||
// Service Worker for Talk2Me PWA
|
||||
|
||||
const CACHE_NAME = 'voice-translator-v1';
|
||||
const CACHE_NAME = 'talk2me-v4'; // Increment version to force update
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/static/manifest.json',
|
||||
'/static/css/styles.css',
|
||||
'/static/js/dist/app.js',
|
||||
'/static/js/dist/app.bundle.js',
|
||||
'/static/icons/icon-192x192.png',
|
||||
'/static/icons/icon-512x512.png',
|
||||
'/static/icons/favicon.ico',
|
||||
|
||||
712
templates/admin_users.html
Normal file
712
templates/admin_users.html
Normal file
@@ -0,0 +1,712 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Management - Talk2Me Admin</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||
<style>
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.action-buttons .btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.stats-card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stats-card .card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.stats-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #4a5568;
|
||||
}
|
||||
.table-responsive {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.modal-header {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.search-filters {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/admin">Talk2Me Admin</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a class="nav-link" href="/" target="_blank">
|
||||
<i class="bi bi-box-arrow-up-right"></i> Main App
|
||||
</a>
|
||||
<a class="nav-link" href="#" onclick="logout()">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="mb-4">User Management</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Total Users</h6>
|
||||
<div class="stats-number" id="stat-total">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Active Users</h6>
|
||||
<div class="stats-number text-success" id="stat-active">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Suspended Users</h6>
|
||||
<div class="stats-number text-warning" id="stat-suspended">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stats-card">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Admin Users</h6>
|
||||
<div class="stats-number text-primary" id="stat-admins">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="search-filters">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Search by email, username, or name...">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="roleFilter">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="statusFilter">
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="sortBy">
|
||||
<option value="created_at">Created Date</option>
|
||||
<option value="last_login_at">Last Login</option>
|
||||
<option value="total_requests">Total Requests</option>
|
||||
<option value="username">Username</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-primary w-100" onclick="createUser()">
|
||||
<i class="bi bi-plus-circle"></i> Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Usage</th>
|
||||
<th>Last Login</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- Users will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center" id="pagination">
|
||||
<!-- Pagination will be loaded here -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- User Details Modal -->
|
||||
<div class="modal fade" id="userModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">User Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="userModalBody">
|
||||
<!-- User details will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit User Modal -->
|
||||
<div class="modal fade" id="createUserModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="createUserModalTitle">Create User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="userForm">
|
||||
<div class="mb-3">
|
||||
<label for="userEmail" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="userEmail" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userUsername" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="userUsername" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userPassword" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="userPassword" minlength="8">
|
||||
<small class="text-muted">Leave blank to keep existing password (edit mode)</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userFullName" class="form-label">Full Name</label>
|
||||
<input type="text" class="form-control" id="userFullName">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userRole" class="form-label">Role</label>
|
||||
<select class="form-select" id="userRole">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="userVerified">
|
||||
<label class="form-check-label" for="userVerified">
|
||||
Email Verified
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userRateLimit" class="form-label">Rate Limit (per minute)</label>
|
||||
<input type="number" class="form-control" id="userRateLimit" value="30">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveUser()">Save User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let editingUserId = null;
|
||||
|
||||
// Configure axios defaults for session-based auth
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
// Also check if there's a JWT token (for API users)
|
||||
let authToken = localStorage.getItem('auth_token');
|
||||
if (authToken) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
// For admin token auth, add the admin token header
|
||||
const adminToken = '{{ session.get("admin_token", "") }}';
|
||||
if (adminToken) {
|
||||
axios.defaults.headers.common['X-Admin-Token'] = adminToken;
|
||||
}
|
||||
|
||||
// Load users on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadStats();
|
||||
loadUsers();
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('searchInput').addEventListener('input', debounce(loadUsers, 300));
|
||||
document.getElementById('roleFilter').addEventListener('change', loadUsers);
|
||||
document.getElementById('statusFilter').addEventListener('change', loadUsers);
|
||||
document.getElementById('sortBy').addEventListener('change', loadUsers);
|
||||
});
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/admin/stats/users');
|
||||
const stats = response.data.stats;
|
||||
|
||||
document.getElementById('stat-total').textContent = stats.total_users;
|
||||
document.getElementById('stat-active').textContent = stats.active_users;
|
||||
document.getElementById('stat-suspended').textContent = stats.suspended_users;
|
||||
document.getElementById('stat-admins').textContent = stats.admin_users;
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers(page = 1) {
|
||||
try {
|
||||
currentPage = page;
|
||||
const params = {
|
||||
page: page,
|
||||
per_page: 20,
|
||||
search: document.getElementById('searchInput').value,
|
||||
role: document.getElementById('roleFilter').value,
|
||||
status: document.getElementById('statusFilter').value,
|
||||
sort_by: document.getElementById('sortBy').value,
|
||||
sort_order: 'desc'
|
||||
};
|
||||
|
||||
console.log('Loading users with params:', params);
|
||||
const response = await axios.get('/api/auth/admin/users', { params });
|
||||
const data = response.data;
|
||||
console.log('Received data:', data);
|
||||
|
||||
// Also try the debug endpoint to see all users
|
||||
try {
|
||||
const debugResponse = await axios.get('/api/debug-users');
|
||||
console.log('Debug endpoint shows:', debugResponse.data);
|
||||
} catch (debugError) {
|
||||
console.error('Debug endpoint error:', debugError);
|
||||
}
|
||||
|
||||
displayUsers(data.users);
|
||||
displayPagination(data.pagination);
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
console.error('Response:', error.response);
|
||||
showAlert('Failed to load users: ' + (error.response?.data?.error || error.message), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function displayUsers(users) {
|
||||
const tbody = document.getElementById('usersTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
console.log('displayUsers called with:', users);
|
||||
console.log('Number of users to display:', users ? users.length : 0);
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center">No users found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach(user => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
${user.avatar_url ?
|
||||
`<img src="${user.avatar_url}" class="user-avatar me-2" alt="${user.username}">` :
|
||||
`<div class="user-avatar me-2 bg-secondary d-flex align-items-center justify-content-center text-white">
|
||||
${user.username.charAt(0).toUpperCase()}
|
||||
</div>`
|
||||
}
|
||||
<div>
|
||||
<div class="fw-bold">${user.username}</div>
|
||||
<small class="text-muted">${user.email}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${user.role === 'admin' ? 'bg-primary' : 'bg-secondary'}">
|
||||
${user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${getStatusBadge(user)}
|
||||
</td>
|
||||
<td>
|
||||
<small>
|
||||
<i class="bi bi-translate"></i> ${user.total_translations}<br>
|
||||
<i class="bi bi-mic"></i> ${user.total_transcriptions}<br>
|
||||
<i class="bi bi-volume-up"></i> ${user.total_tts_requests}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<small>${user.last_login_at ? new Date(user.last_login_at).toLocaleString() : 'Never'}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small>${new Date(user.created_at).toLocaleDateString()}</small>
|
||||
</td>
|
||||
<td class="action-buttons">
|
||||
<button class="btn btn-sm btn-info" onclick="viewUser('${user.id}')" title="View Details">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning" onclick="editUser('${user.id}')" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
${user.is_suspended ?
|
||||
`<button class="btn btn-sm btn-success" onclick="unsuspendUser('${user.id}')" title="Unsuspend">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
</button>` :
|
||||
`<button class="btn btn-sm btn-warning" onclick="suspendUser('${user.id}')" title="Suspend">
|
||||
<i class="bi bi-pause-circle"></i>
|
||||
</button>`
|
||||
}
|
||||
${user.role !== 'admin' ?
|
||||
`<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>` : ''
|
||||
}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusBadge(user) {
|
||||
if (user.is_suspended) {
|
||||
return '<span class="badge bg-warning status-badge">Suspended</span>';
|
||||
} else if (!user.is_active) {
|
||||
return '<span class="badge bg-secondary status-badge">Inactive</span>';
|
||||
} else if (!user.is_verified) {
|
||||
return '<span class="badge bg-info status-badge">Unverified</span>';
|
||||
} else {
|
||||
return '<span class="badge bg-success status-badge">Active</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function displayPagination(pagination) {
|
||||
const nav = document.getElementById('pagination');
|
||||
nav.innerHTML = '';
|
||||
|
||||
const totalPages = pagination.pages;
|
||||
const currentPage = pagination.page;
|
||||
|
||||
// Previous button
|
||||
const prevLi = document.createElement('li');
|
||||
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
|
||||
prevLi.innerHTML = `<a class="page-link" href="#" onclick="loadUsers(${currentPage - 1})">Previous</a>`;
|
||||
nav.appendChild(prevLi);
|
||||
|
||||
// Page numbers
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
|
||||
const li = document.createElement('li');
|
||||
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
|
||||
li.innerHTML = `<a class="page-link" href="#" onclick="loadUsers(${i})">${i}</a>`;
|
||||
nav.appendChild(li);
|
||||
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'page-item disabled';
|
||||
li.innerHTML = '<span class="page-link">...</span>';
|
||||
nav.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Next button
|
||||
const nextLi = document.createElement('li');
|
||||
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
|
||||
nextLi.innerHTML = `<a class="page-link" href="#" onclick="loadUsers(${currentPage + 1})">Next</a>`;
|
||||
nav.appendChild(nextLi);
|
||||
}
|
||||
|
||||
async function viewUser(userId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/auth/admin/users/${userId}`);
|
||||
const data = response.data;
|
||||
|
||||
const modalBody = document.getElementById('userModalBody');
|
||||
modalBody.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>User Information</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Username:</dt>
|
||||
<dd class="col-sm-8">${data.user.username}</dd>
|
||||
<dt class="col-sm-4">Email:</dt>
|
||||
<dd class="col-sm-8">${data.user.email}</dd>
|
||||
<dt class="col-sm-4">Full Name:</dt>
|
||||
<dd class="col-sm-8">${data.user.full_name || 'N/A'}</dd>
|
||||
<dt class="col-sm-4">Role:</dt>
|
||||
<dd class="col-sm-8">${data.user.role}</dd>
|
||||
<dt class="col-sm-4">Status:</dt>
|
||||
<dd class="col-sm-8">${getStatusBadge(data.user)}</dd>
|
||||
<dt class="col-sm-4">API Key:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<code>${data.user.api_key}</code>
|
||||
<button class="btn btn-sm btn-secondary ms-2" onclick="copyToClipboard('${data.user.api_key}')">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Usage Statistics</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-6">Total Requests:</dt>
|
||||
<dd class="col-sm-6">${data.user.total_requests}</dd>
|
||||
<dt class="col-sm-6">Translations:</dt>
|
||||
<dd class="col-sm-6">${data.user.total_translations}</dd>
|
||||
<dt class="col-sm-6">Transcriptions:</dt>
|
||||
<dd class="col-sm-6">${data.user.total_transcriptions}</dd>
|
||||
<dt class="col-sm-6">TTS Requests:</dt>
|
||||
<dd class="col-sm-6">${data.user.total_tts_requests}</dd>
|
||||
<dt class="col-sm-6">Rate Limits:</dt>
|
||||
<dd class="col-sm-6">
|
||||
${data.user.rate_limit_per_minute}/min<br>
|
||||
${data.user.rate_limit_per_hour}/hour<br>
|
||||
${data.user.rate_limit_per_day}/day
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<h6>Login History</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>IP Address</th>
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.login_history.map(login => `
|
||||
<tr>
|
||||
<td>${new Date(login.login_at).toLocaleString()}</td>
|
||||
<td>${login.ip_address}</td>
|
||||
<td>${login.login_method}</td>
|
||||
<td>${login.success ?
|
||||
'<span class="badge bg-success">Success</span>' :
|
||||
`<span class="badge bg-danger">Failed</span>`
|
||||
}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<hr>
|
||||
<h6>Active Sessions (${data.active_sessions.length})</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session ID</th>
|
||||
<th>Created</th>
|
||||
<th>Last Active</th>
|
||||
<th>IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.active_sessions.map(session => `
|
||||
<tr>
|
||||
<td><code>${session.session_id.substr(0, 8)}...</code></td>
|
||||
<td>${new Date(session.created_at).toLocaleString()}</td>
|
||||
<td>${new Date(session.last_active_at).toLocaleString()}</td>
|
||||
<td>${session.ip_address}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('userModal'));
|
||||
modal.show();
|
||||
} catch (error) {
|
||||
console.error('Failed to load user details:', error);
|
||||
showAlert('Failed to load user details', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function createUser() {
|
||||
editingUserId = null;
|
||||
document.getElementById('createUserModalTitle').textContent = 'Create User';
|
||||
document.getElementById('userForm').reset();
|
||||
document.getElementById('userPassword').required = true;
|
||||
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async function editUser(userId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/auth/admin/users/${userId}`);
|
||||
const user = response.data.user;
|
||||
|
||||
editingUserId = userId;
|
||||
document.getElementById('createUserModalTitle').textContent = 'Edit User';
|
||||
document.getElementById('userEmail').value = user.email;
|
||||
document.getElementById('userUsername').value = user.username;
|
||||
document.getElementById('userPassword').value = '';
|
||||
document.getElementById('userPassword').required = false;
|
||||
document.getElementById('userFullName').value = user.full_name || '';
|
||||
document.getElementById('userRole').value = user.role;
|
||||
document.getElementById('userVerified').checked = user.is_verified;
|
||||
document.getElementById('userRateLimit').value = user.rate_limit_per_minute;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
|
||||
modal.show();
|
||||
} catch (error) {
|
||||
console.error('Failed to load user for editing:', error);
|
||||
showAlert('Failed to load user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
try {
|
||||
const data = {
|
||||
email: document.getElementById('userEmail').value,
|
||||
username: document.getElementById('userUsername').value,
|
||||
full_name: document.getElementById('userFullName').value,
|
||||
role: document.getElementById('userRole').value,
|
||||
is_verified: document.getElementById('userVerified').checked,
|
||||
rate_limit_per_minute: parseInt(document.getElementById('userRateLimit').value)
|
||||
};
|
||||
|
||||
if (editingUserId) {
|
||||
// Update existing user
|
||||
if (document.getElementById('userPassword').value) {
|
||||
data.password = document.getElementById('userPassword').value;
|
||||
}
|
||||
await axios.put(`/api/auth/admin/users/${editingUserId}`, data);
|
||||
showAlert('User updated successfully', 'success');
|
||||
} else {
|
||||
// Create new user
|
||||
data.password = document.getElementById('userPassword').value;
|
||||
await axios.post('/api/auth/admin/users', data);
|
||||
showAlert('User created successfully', 'success');
|
||||
}
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('createUserModal')).hide();
|
||||
loadUsers(currentPage);
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to save user:', error);
|
||||
showAlert(error.response?.data?.error || 'Failed to save user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function suspendUser(userId) {
|
||||
if (!confirm('Are you sure you want to suspend this user?')) return;
|
||||
|
||||
try {
|
||||
const reason = prompt('Enter suspension reason:');
|
||||
if (!reason) return;
|
||||
|
||||
await axios.post(`/api/auth/admin/users/${userId}/suspend`, { reason });
|
||||
showAlert('User suspended successfully', 'success');
|
||||
loadUsers(currentPage);
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to suspend user:', error);
|
||||
showAlert('Failed to suspend user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function unsuspendUser(userId) {
|
||||
if (!confirm('Are you sure you want to unsuspend this user?')) return;
|
||||
|
||||
try {
|
||||
await axios.post(`/api/auth/admin/users/${userId}/unsuspend`);
|
||||
showAlert('User unsuspended successfully', 'success');
|
||||
loadUsers(currentPage);
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to unsuspend user:', error);
|
||||
showAlert('Failed to unsuspend user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId) {
|
||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/auth/admin/users/${userId}`);
|
||||
showAlert('User deleted successfully', 'success');
|
||||
loadUsers(currentPage);
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
showAlert('Failed to delete user', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showAlert('Copied to clipboard', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3`;
|
||||
alertDiv.style.zIndex = '9999';
|
||||
alertDiv.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alertDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
alertDiv.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,21 +2,107 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Talk2Me</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Talk2Me - Real-time Voice Translation</title>
|
||||
|
||||
<!-- Icons for various platforms -->
|
||||
<link rel="icon" href="/static/icons/favicon.ico" sizes="any">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-icon-180x180.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/static/icons/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/static/icons/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-icon-180x180.png">
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="/static/icons/apple-icon-167x167.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-icon-180x180.png">
|
||||
<style>
|
||||
body {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Mobile-first approach */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
|
||||
.card-header h5 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text-display {
|
||||
min-height: 60px !important;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
padding: 10px !important;
|
||||
margin-bottom: 10px !important;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.language-select {
|
||||
padding: 5px 10px !important;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 5px 10px !important;
|
||||
font-size: 0.875rem;
|
||||
margin: 2px !important;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
font-size: 24px !important;
|
||||
margin: 10px auto !important;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 0.8rem !important;
|
||||
margin-top: 5px !important;
|
||||
}
|
||||
|
||||
/* Hide speaker toolbar on mobile by default */
|
||||
#speakerToolbar {
|
||||
position: fixed;
|
||||
bottom: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
#conversationView {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
z-index: 99;
|
||||
border-radius: 15px 15px 0 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
@@ -91,19 +177,39 @@
|
||||
font-style: italic;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Ensure record button area is always visible on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.record-section {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: white;
|
||||
padding: 10px 0;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||||
z-index: 50;
|
||||
margin-left: -10px;
|
||||
margin-right: -10px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="Translate spoken language between multiple languages with speech input and output">
|
||||
<meta name="description" content="Real-time voice translation app - translate spoken language instantly">
|
||||
<meta name="theme-color" content="#007bff">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Translator">
|
||||
<meta name="apple-mobile-web-app-title" content="Talk2Me">
|
||||
<meta name="application-name" content="Talk2Me">
|
||||
<meta name="msapplication-TileColor" content="#007bff">
|
||||
<meta name="msapplication-TileImage" content="/static/icons/icon-192x192.png">
|
||||
|
||||
<!-- PWA Icons and Manifest -->
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/icons/icon-512x512.png">
|
||||
|
||||
<!-- Apple Splash Screens -->
|
||||
<link rel="apple-touch-startup-image" href="/static/splash/apple-splash-2048-2732.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
|
||||
@@ -199,7 +305,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-center record-section">
|
||||
<button id="recordBtn" class="btn btn-primary record-btn">
|
||||
<i class="fas fa-microphone"></i>
|
||||
</button>
|
||||
@@ -211,12 +317,13 @@
|
||||
<i class="fas fa-sync"></i> Active: <span id="activeRequests">0</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<button id="translateBtn" class="btn btn-success" disabled>
|
||||
<i class="fas fa-language"></i> Translate
|
||||
</button>
|
||||
|
||||
<div class="mt-2">
|
||||
<button id="translateBtn" class="btn btn-outline-secondary btn-sm" disabled title="Translation happens automatically after transcription">
|
||||
<i class="fas fa-redo"></i> Re-translate
|
||||
</button>
|
||||
<small class="text-muted d-block mt-1">Translation happens automatically after transcription</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
@@ -247,7 +354,8 @@
|
||||
|
||||
<audio id="audioPlayer" style="display: none;"></audio>
|
||||
|
||||
<!-- TTS Server Configuration Alert -->
|
||||
<!-- TTS Server Configuration Alert - Moved to Admin Dashboard -->
|
||||
<!-- Commenting out user-facing TTS server configuration
|
||||
<div id="ttsServerAlert" class="alert alert-warning d-none" role="alert">
|
||||
<strong>TTS Server Status:</strong> <span id="ttsServerMessage">Checking...</span>
|
||||
<div class="mt-2">
|
||||
@@ -256,6 +364,7 @@
|
||||
<button id="updateTtsServer" class="btn btn-sm btn-primary">Update Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loadingOverlay" class="loading-overlay">
|
||||
@@ -399,6 +508,6 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/dist/app.js"></script>
|
||||
<script src="/static/js/dist/app.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
287
templates/login.html
Normal file
287
templates/login.html
Normal file
@@ -0,0 +1,287 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Talk2Me</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.login-header h1 {
|
||||
color: #333;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.login-header p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #667eea;
|
||||
border-color: #667eea;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #5a67d8;
|
||||
border-color: #5a67d8;
|
||||
}
|
||||
.alert {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.divider {
|
||||
text-align: center;
|
||||
margin: 1.5rem 0;
|
||||
position: relative;
|
||||
}
|
||||
.divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.divider span {
|
||||
background: white;
|
||||
padding: 0 1rem;
|
||||
position: relative;
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.api-key-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
.api-key-display {
|
||||
background: #f8f9fa;
|
||||
padding: 0.75rem;
|
||||
border-radius: 5px;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.loading {
|
||||
display: none;
|
||||
}
|
||||
.loading.show {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<h1><i class="bi bi-translate"></i> Talk2Me</h1>
|
||||
<p>Voice Translation Made Simple</p>
|
||||
</div>
|
||||
|
||||
<div id="alertContainer">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ error }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form id="loginForm" method="POST" action="{{ url_for('login', next=request.args.get('next')) }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Email or Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="loading spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
<span class="btn-text">Sign In</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">
|
||||
<span>OR</span>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="mb-2">Use the app without signing in</p>
|
||||
<a href="/" class="btn btn-outline-secondary w-100">
|
||||
<i class="bi bi-arrow-right-circle"></i> Continue as Guest
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="api-key-section" id="apiKeySection" style="display: none;">
|
||||
<h6 class="mb-2">Your API Key</h6>
|
||||
<p class="text-muted small">Use this key to authenticate API requests:</p>
|
||||
<div class="api-key-display" id="apiKeyDisplay">
|
||||
<span id="apiKey"></span>
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" onclick="copyApiKey()">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="back-link">
|
||||
<a href="/" class="text-muted small">
|
||||
<i class="bi bi-arrow-left"></i> Back to App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
// Check if already logged in
|
||||
const authToken = localStorage.getItem('auth_token');
|
||||
if (authToken) {
|
||||
// Verify token is still valid
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${authToken}`;
|
||||
axios.get('/api/auth/profile').then(response => {
|
||||
// Token is valid, redirect to app
|
||||
window.location.href = '/';
|
||||
}).catch(() => {
|
||||
// Token invalid, clear it
|
||||
localStorage.removeItem('auth_token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const loadingSpinner = document.querySelector('.loading');
|
||||
const btnText = document.querySelector('.btn-text');
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
|
||||
// Show loading state
|
||||
loadingSpinner.classList.add('show');
|
||||
btnText.textContent = 'Signing in...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', {
|
||||
username: username,
|
||||
password: password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// Store token
|
||||
const token = response.data.tokens.access_token;
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('refresh_token', response.data.tokens.refresh_token);
|
||||
localStorage.setItem('user_id', response.data.user.id);
|
||||
localStorage.setItem('username', response.data.user.username);
|
||||
localStorage.setItem('user_role', response.data.user.role);
|
||||
|
||||
// Show API key
|
||||
document.getElementById('apiKey').textContent = response.data.user.api_key;
|
||||
document.getElementById('apiKeySection').style.display = 'block';
|
||||
|
||||
showAlert('Login successful! Redirecting...', 'success');
|
||||
|
||||
// Redirect based on role or next parameter
|
||||
setTimeout(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const nextUrl = urlParams.get('next');
|
||||
|
||||
if (nextUrl) {
|
||||
window.location.href = nextUrl;
|
||||
} else if (response.data.user.role === 'admin') {
|
||||
window.location.href = '/admin';
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
const errorMessage = error.response?.data?.error || 'Login failed. Please try again.';
|
||||
showAlert(errorMessage, 'danger');
|
||||
|
||||
// Reset button state
|
||||
loadingSpinner.classList.remove('show');
|
||||
btnText.textContent = 'Sign In';
|
||||
submitBtn.disabled = false;
|
||||
|
||||
// Clear password field on error
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('password').focus();
|
||||
}
|
||||
});
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alertContainer = document.getElementById('alertContainer');
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
alertContainer.innerHTML = '';
|
||||
alertContainer.appendChild(alert);
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function copyApiKey() {
|
||||
const apiKey = document.getElementById('apiKey').textContent;
|
||||
navigator.clipboard.writeText(apiKey).then(() => {
|
||||
showAlert('API key copied to clipboard!', 'success');
|
||||
}).catch(() => {
|
||||
showAlert('Failed to copy API key', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Enter key in form fields
|
||||
document.getElementById('username').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('password').focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
228
test-cors.html
228
test-cors.html
@@ -1,228 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CORS Test for Talk2Me</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.test-result {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
#results {
|
||||
margin-top: 20px;
|
||||
}
|
||||
pre {
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>CORS Test for Talk2Me API</h1>
|
||||
|
||||
<p>This page tests CORS configuration for the Talk2Me API. Open this file from a different origin (e.g., file:// or a different port) to test cross-origin requests.</p>
|
||||
|
||||
<div>
|
||||
<label for="apiUrl">API Base URL:</label>
|
||||
<input type="text" id="apiUrl" placeholder="http://localhost:5005" value="http://localhost:5005">
|
||||
</div>
|
||||
|
||||
<h2>Tests:</h2>
|
||||
|
||||
<button onclick="testHealthEndpoint()">Test Health Endpoint</button>
|
||||
<button onclick="testPreflightRequest()">Test Preflight Request</button>
|
||||
<button onclick="testTranscribeEndpoint()">Test Transcribe Endpoint (OPTIONS)</button>
|
||||
<button onclick="testWithCredentials()">Test With Credentials</button>
|
||||
|
||||
<div id="results"></div>
|
||||
|
||||
<script>
|
||||
function addResult(test, success, message, details = null) {
|
||||
const resultsDiv = document.getElementById('results');
|
||||
const resultDiv = document.createElement('div');
|
||||
resultDiv.className = `test-result ${success ? 'success' : 'error'}`;
|
||||
|
||||
let html = `<strong>${test}:</strong> ${message}`;
|
||||
if (details) {
|
||||
html += `<pre>${JSON.stringify(details, null, 2)}</pre>`;
|
||||
}
|
||||
resultDiv.innerHTML = html;
|
||||
resultsDiv.appendChild(resultDiv);
|
||||
}
|
||||
|
||||
function getApiUrl() {
|
||||
return document.getElementById('apiUrl').value.trim();
|
||||
}
|
||||
|
||||
async function testHealthEndpoint() {
|
||||
const apiUrl = getApiUrl();
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/health`, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Origin': window.location.origin
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check CORS headers
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': response.headers.get('Access-Control-Allow-Origin'),
|
||||
'Access-Control-Allow-Credentials': response.headers.get('Access-Control-Allow-Credentials')
|
||||
};
|
||||
|
||||
addResult('Health Endpoint GET', true, 'Request successful', {
|
||||
status: response.status,
|
||||
data: data,
|
||||
corsHeaders: corsHeaders
|
||||
});
|
||||
} catch (error) {
|
||||
addResult('Health Endpoint GET', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testPreflightRequest() {
|
||||
const apiUrl = getApiUrl();
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/push-public-key`, {
|
||||
method: 'OPTIONS',
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Origin': window.location.origin,
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'content-type'
|
||||
}
|
||||
});
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': response.headers.get('Access-Control-Allow-Origin'),
|
||||
'Access-Control-Allow-Methods': response.headers.get('Access-Control-Allow-Methods'),
|
||||
'Access-Control-Allow-Headers': response.headers.get('Access-Control-Allow-Headers'),
|
||||
'Access-Control-Max-Age': response.headers.get('Access-Control-Max-Age')
|
||||
};
|
||||
|
||||
addResult('Preflight Request', response.ok, `Status: ${response.status}`, corsHeaders);
|
||||
} catch (error) {
|
||||
addResult('Preflight Request', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testTranscribeEndpoint() {
|
||||
const apiUrl = getApiUrl();
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/transcribe`, {
|
||||
method: 'OPTIONS',
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Origin': window.location.origin,
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'content-type'
|
||||
}
|
||||
});
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': response.headers.get('Access-Control-Allow-Origin'),
|
||||
'Access-Control-Allow-Methods': response.headers.get('Access-Control-Allow-Methods'),
|
||||
'Access-Control-Allow-Headers': response.headers.get('Access-Control-Allow-Headers'),
|
||||
'Access-Control-Allow-Credentials': response.headers.get('Access-Control-Allow-Credentials')
|
||||
};
|
||||
|
||||
addResult('Transcribe Endpoint OPTIONS', response.ok, `Status: ${response.status}`, corsHeaders);
|
||||
} catch (error) {
|
||||
addResult('Transcribe Endpoint OPTIONS', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testWithCredentials() {
|
||||
const apiUrl = getApiUrl();
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/health`, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Origin': window.location.origin
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
addResult('Request with Credentials', true, 'Request successful', {
|
||||
status: response.status,
|
||||
credentialsIncluded: true,
|
||||
data: data
|
||||
});
|
||||
} catch (error) {
|
||||
addResult('Request with Credentials', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear results before running new tests
|
||||
function clearResults() {
|
||||
document.getElementById('results').innerHTML = '';
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
document.querySelectorAll('button').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
if (!e.target.textContent.includes('Test')) return;
|
||||
clearResults();
|
||||
});
|
||||
});
|
||||
|
||||
// Show current origin
|
||||
window.addEventListener('load', () => {
|
||||
const info = document.createElement('div');
|
||||
info.style.marginBottom = '20px';
|
||||
info.style.padding = '10px';
|
||||
info.style.backgroundColor = '#e9ecef';
|
||||
info.style.borderRadius = '5px';
|
||||
info.innerHTML = `<strong>Current Origin:</strong> ${window.location.origin}<br>
|
||||
<strong>Protocol:</strong> ${window.location.protocol}<br>
|
||||
<strong>Note:</strong> For effective CORS testing, open this file from a different origin than your API server.`;
|
||||
document.body.insertBefore(info, document.querySelector('h2'));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,168 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for error logging system
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from error_logger import ErrorLogger, log_errors, log_performance, get_logger
|
||||
|
||||
def test_basic_logging():
|
||||
"""Test basic logging functionality"""
|
||||
print("\n=== Testing Basic Logging ===")
|
||||
|
||||
# Get logger
|
||||
logger = get_logger('test')
|
||||
|
||||
# Test different log levels
|
||||
logger.debug("This is a debug message")
|
||||
logger.info("This is an info message")
|
||||
logger.warning("This is a warning message")
|
||||
logger.error("This is an error message")
|
||||
|
||||
print("✓ Basic logging test completed")
|
||||
|
||||
def test_error_logging():
|
||||
"""Test error logging with exceptions"""
|
||||
print("\n=== Testing Error Logging ===")
|
||||
|
||||
@log_errors('test.functions')
|
||||
def failing_function():
|
||||
raise ValueError("This is a test error")
|
||||
|
||||
try:
|
||||
failing_function()
|
||||
except ValueError:
|
||||
print("✓ Error was logged")
|
||||
|
||||
# Check if error log exists
|
||||
if os.path.exists('logs/errors.log'):
|
||||
print("✓ Error log file created")
|
||||
|
||||
# Read last line
|
||||
with open('logs/errors.log', 'r') as f:
|
||||
lines = f.readlines()
|
||||
if lines:
|
||||
try:
|
||||
error_entry = json.loads(lines[-1])
|
||||
print(f"✓ Error logged with level: {error_entry.get('level')}")
|
||||
print(f"✓ Error type: {error_entry.get('exception', {}).get('type')}")
|
||||
except json.JSONDecodeError:
|
||||
print("✗ Error log entry is not valid JSON")
|
||||
else:
|
||||
print("✗ Error log file not created")
|
||||
|
||||
def test_performance_logging():
|
||||
"""Test performance logging"""
|
||||
print("\n=== Testing Performance Logging ===")
|
||||
|
||||
@log_performance('test_operation')
|
||||
def slow_function():
|
||||
time.sleep(0.1) # Simulate slow operation
|
||||
return "result"
|
||||
|
||||
result = slow_function()
|
||||
print(f"✓ Function returned: {result}")
|
||||
|
||||
# Check performance log
|
||||
if os.path.exists('logs/performance.log'):
|
||||
print("✓ Performance log file created")
|
||||
|
||||
# Read last line
|
||||
with open('logs/performance.log', 'r') as f:
|
||||
lines = f.readlines()
|
||||
if lines:
|
||||
try:
|
||||
perf_entry = json.loads(lines[-1])
|
||||
duration = perf_entry.get('extra_fields', {}).get('duration_ms', 0)
|
||||
print(f"✓ Performance logged with duration: {duration}ms")
|
||||
except json.JSONDecodeError:
|
||||
print("✗ Performance log entry is not valid JSON")
|
||||
else:
|
||||
print("✗ Performance log file not created")
|
||||
|
||||
def test_structured_logging():
|
||||
"""Test structured logging format"""
|
||||
print("\n=== Testing Structured Logging ===")
|
||||
|
||||
logger = get_logger('test.structured')
|
||||
|
||||
# Log with extra fields
|
||||
logger.info("Structured log test", extra={
|
||||
'extra_fields': {
|
||||
'user_id': 123,
|
||||
'action': 'test_action',
|
||||
'metadata': {'key': 'value'}
|
||||
}
|
||||
})
|
||||
|
||||
# Check main log
|
||||
if os.path.exists('logs/talk2me.log'):
|
||||
with open('logs/talk2me.log', 'r') as f:
|
||||
lines = f.readlines()
|
||||
if lines:
|
||||
try:
|
||||
# Find our test entry
|
||||
for line in reversed(lines):
|
||||
entry = json.loads(line)
|
||||
if entry.get('message') == 'Structured log test':
|
||||
print("✓ Structured log entry found")
|
||||
print(f"✓ Contains timestamp: {'timestamp' in entry}")
|
||||
print(f"✓ Contains hostname: {'hostname' in entry}")
|
||||
print(f"✓ Contains extra fields: {'user_id' in entry}")
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
print("✗ Log entry is not valid JSON")
|
||||
|
||||
def test_log_rotation():
|
||||
"""Test log rotation settings"""
|
||||
print("\n=== Testing Log Rotation ===")
|
||||
|
||||
# Check if log files exist and their sizes
|
||||
log_files = {
|
||||
'talk2me.log': 'logs/talk2me.log',
|
||||
'errors.log': 'logs/errors.log',
|
||||
'access.log': 'logs/access.log',
|
||||
'security.log': 'logs/security.log',
|
||||
'performance.log': 'logs/performance.log'
|
||||
}
|
||||
|
||||
for name, path in log_files.items():
|
||||
if os.path.exists(path):
|
||||
size = os.path.getsize(path)
|
||||
print(f"✓ {name}: {size} bytes")
|
||||
else:
|
||||
print(f"- {name}: not created yet")
|
||||
|
||||
def main():
|
||||
"""Run all tests"""
|
||||
print("Error Logging System Tests")
|
||||
print("==========================")
|
||||
|
||||
# Create a test Flask app
|
||||
from flask import Flask
|
||||
app = Flask(__name__)
|
||||
app.config['LOG_LEVEL'] = 'DEBUG'
|
||||
app.config['FLASK_ENV'] = 'testing'
|
||||
|
||||
# Initialize error logger
|
||||
error_logger = ErrorLogger(app)
|
||||
|
||||
# Run tests
|
||||
test_basic_logging()
|
||||
test_error_logging()
|
||||
test_performance_logging()
|
||||
test_structured_logging()
|
||||
test_log_rotation()
|
||||
|
||||
print("\n✅ All tests completed!")
|
||||
print("\nCheck the logs directory for generated log files:")
|
||||
print("- logs/talk2me.log - Main application log")
|
||||
print("- logs/errors.log - Error log with stack traces")
|
||||
print("- logs/performance.log - Performance metrics")
|
||||
print("- logs/access.log - HTTP access log")
|
||||
print("- logs/security.log - Security events")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,264 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for session management system
|
||||
"""
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
import time
|
||||
import os
|
||||
from session_manager import SessionManager, UserSession, SessionResource
|
||||
from flask import Flask, g, session
|
||||
|
||||
class TestSessionManager(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.config = {
|
||||
'max_session_duration': 3600,
|
||||
'max_idle_time': 900,
|
||||
'max_resources_per_session': 5, # Small limit for testing
|
||||
'max_bytes_per_session': 1024 * 1024, # 1MB for testing
|
||||
'cleanup_interval': 1, # 1 second for faster testing
|
||||
'session_storage_path': self.temp_dir
|
||||
}
|
||||
self.manager = SessionManager(self.config)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test fixtures"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_create_session(self):
|
||||
"""Test session creation"""
|
||||
session = self.manager.create_session(
|
||||
session_id='test-123',
|
||||
user_id='user-1',
|
||||
ip_address='127.0.0.1',
|
||||
user_agent='Test Agent'
|
||||
)
|
||||
|
||||
self.assertEqual(session.session_id, 'test-123')
|
||||
self.assertEqual(session.user_id, 'user-1')
|
||||
self.assertEqual(session.ip_address, '127.0.0.1')
|
||||
self.assertEqual(session.user_agent, 'Test Agent')
|
||||
self.assertEqual(len(session.resources), 0)
|
||||
|
||||
def test_get_session(self):
|
||||
"""Test session retrieval"""
|
||||
self.manager.create_session(session_id='test-456')
|
||||
session = self.manager.get_session('test-456')
|
||||
|
||||
self.assertIsNotNone(session)
|
||||
self.assertEqual(session.session_id, 'test-456')
|
||||
|
||||
# Non-existent session
|
||||
session = self.manager.get_session('non-existent')
|
||||
self.assertIsNone(session)
|
||||
|
||||
def test_add_resource(self):
|
||||
"""Test adding resources to session"""
|
||||
self.manager.create_session(session_id='test-789')
|
||||
|
||||
# Add a resource
|
||||
resource = self.manager.add_resource(
|
||||
session_id='test-789',
|
||||
resource_type='audio_file',
|
||||
resource_id='audio-1',
|
||||
path='/tmp/test.wav',
|
||||
size_bytes=1024,
|
||||
metadata={'format': 'wav'}
|
||||
)
|
||||
|
||||
self.assertIsNotNone(resource)
|
||||
self.assertEqual(resource.resource_id, 'audio-1')
|
||||
self.assertEqual(resource.resource_type, 'audio_file')
|
||||
self.assertEqual(resource.size_bytes, 1024)
|
||||
|
||||
# Check session updated
|
||||
session = self.manager.get_session('test-789')
|
||||
self.assertEqual(len(session.resources), 1)
|
||||
self.assertEqual(session.total_bytes_used, 1024)
|
||||
|
||||
def test_resource_limits(self):
|
||||
"""Test resource limit enforcement"""
|
||||
self.manager.create_session(session_id='test-limits')
|
||||
|
||||
# Add resources up to limit
|
||||
for i in range(5):
|
||||
self.manager.add_resource(
|
||||
session_id='test-limits',
|
||||
resource_type='temp_file',
|
||||
resource_id=f'file-{i}',
|
||||
size_bytes=100
|
||||
)
|
||||
|
||||
session = self.manager.get_session('test-limits')
|
||||
self.assertEqual(len(session.resources), 5)
|
||||
|
||||
# Add one more - should remove oldest
|
||||
self.manager.add_resource(
|
||||
session_id='test-limits',
|
||||
resource_type='temp_file',
|
||||
resource_id='file-new',
|
||||
size_bytes=100
|
||||
)
|
||||
|
||||
session = self.manager.get_session('test-limits')
|
||||
self.assertEqual(len(session.resources), 5) # Still 5
|
||||
self.assertNotIn('file-0', session.resources) # Oldest removed
|
||||
self.assertIn('file-new', session.resources) # New one added
|
||||
|
||||
def test_size_limits(self):
|
||||
"""Test size limit enforcement"""
|
||||
self.manager.create_session(session_id='test-size')
|
||||
|
||||
# Add a large resource
|
||||
self.manager.add_resource(
|
||||
session_id='test-size',
|
||||
resource_type='audio_file',
|
||||
resource_id='large-1',
|
||||
size_bytes=500 * 1024 # 500KB
|
||||
)
|
||||
|
||||
# Add another large resource
|
||||
self.manager.add_resource(
|
||||
session_id='test-size',
|
||||
resource_type='audio_file',
|
||||
resource_id='large-2',
|
||||
size_bytes=600 * 1024 # 600KB - would exceed 1MB limit
|
||||
)
|
||||
|
||||
session = self.manager.get_session('test-size')
|
||||
# First resource should be removed to make space
|
||||
self.assertNotIn('large-1', session.resources)
|
||||
self.assertIn('large-2', session.resources)
|
||||
self.assertLessEqual(session.total_bytes_used, 1024 * 1024)
|
||||
|
||||
def test_remove_resource(self):
|
||||
"""Test resource removal"""
|
||||
self.manager.create_session(session_id='test-remove')
|
||||
self.manager.add_resource(
|
||||
session_id='test-remove',
|
||||
resource_type='temp_file',
|
||||
resource_id='to-remove',
|
||||
size_bytes=1000
|
||||
)
|
||||
|
||||
# Remove resource
|
||||
success = self.manager.remove_resource('test-remove', 'to-remove')
|
||||
self.assertTrue(success)
|
||||
|
||||
# Check it's gone
|
||||
session = self.manager.get_session('test-remove')
|
||||
self.assertEqual(len(session.resources), 0)
|
||||
self.assertEqual(session.total_bytes_used, 0)
|
||||
|
||||
def test_cleanup_session(self):
|
||||
"""Test session cleanup"""
|
||||
# Create session with resources
|
||||
self.manager.create_session(session_id='test-cleanup')
|
||||
|
||||
# Create actual temp file
|
||||
temp_file = os.path.join(self.temp_dir, 'test-file.txt')
|
||||
with open(temp_file, 'w') as f:
|
||||
f.write('test content')
|
||||
|
||||
self.manager.add_resource(
|
||||
session_id='test-cleanup',
|
||||
resource_type='temp_file',
|
||||
path=temp_file,
|
||||
size_bytes=12
|
||||
)
|
||||
|
||||
# Cleanup session
|
||||
success = self.manager.cleanup_session('test-cleanup')
|
||||
self.assertTrue(success)
|
||||
|
||||
# Check session is gone
|
||||
session = self.manager.get_session('test-cleanup')
|
||||
self.assertIsNone(session)
|
||||
|
||||
# Check file is deleted
|
||||
self.assertFalse(os.path.exists(temp_file))
|
||||
|
||||
def test_session_info(self):
|
||||
"""Test session info retrieval"""
|
||||
self.manager.create_session(
|
||||
session_id='test-info',
|
||||
ip_address='192.168.1.1'
|
||||
)
|
||||
|
||||
self.manager.add_resource(
|
||||
session_id='test-info',
|
||||
resource_type='audio_file',
|
||||
size_bytes=2048
|
||||
)
|
||||
|
||||
info = self.manager.get_session_info('test-info')
|
||||
self.assertIsNotNone(info)
|
||||
self.assertEqual(info['session_id'], 'test-info')
|
||||
self.assertEqual(info['ip_address'], '192.168.1.1')
|
||||
self.assertEqual(info['resource_count'], 1)
|
||||
self.assertEqual(info['total_bytes_used'], 2048)
|
||||
|
||||
def test_stats(self):
|
||||
"""Test statistics calculation"""
|
||||
# Create multiple sessions
|
||||
for i in range(3):
|
||||
self.manager.create_session(session_id=f'test-stats-{i}')
|
||||
self.manager.add_resource(
|
||||
session_id=f'test-stats-{i}',
|
||||
resource_type='temp_file',
|
||||
size_bytes=1000
|
||||
)
|
||||
|
||||
stats = self.manager.get_stats()
|
||||
self.assertEqual(stats['active_sessions'], 3)
|
||||
self.assertEqual(stats['active_resources'], 3)
|
||||
self.assertEqual(stats['active_bytes'], 3000)
|
||||
self.assertEqual(stats['total_sessions_created'], 3)
|
||||
|
||||
def test_metrics_export(self):
|
||||
"""Test metrics export"""
|
||||
self.manager.create_session(session_id='test-metrics')
|
||||
metrics = self.manager.export_metrics()
|
||||
|
||||
self.assertIn('sessions', metrics)
|
||||
self.assertIn('resources', metrics)
|
||||
self.assertIn('limits', metrics)
|
||||
self.assertEqual(metrics['sessions']['active'], 1)
|
||||
|
||||
class TestFlaskIntegration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up Flask app for testing"""
|
||||
self.app = Flask(__name__)
|
||||
self.app.config['TESTING'] = True
|
||||
self.app.config['SECRET_KEY'] = 'test-secret'
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.app.config['UPLOAD_FOLDER'] = self.temp_dir
|
||||
|
||||
# Initialize session manager
|
||||
from session_manager import init_app
|
||||
init_app(self.app)
|
||||
|
||||
self.client = self.app.test_client()
|
||||
self.ctx = self.app.test_request_context()
|
||||
self.ctx.push()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up"""
|
||||
self.ctx.pop()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_before_request_handler(self):
|
||||
"""Test Flask before_request integration"""
|
||||
with self.client:
|
||||
# Make a request
|
||||
response = self.client.get('/')
|
||||
|
||||
# Session should be created
|
||||
with self.client.session_transaction() as sess:
|
||||
self.assertIn('session_id', sess)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for request size limits
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
|
||||
BASE_URL = "http://localhost:5005"
|
||||
|
||||
def test_json_size_limit():
|
||||
"""Test JSON payload size limit"""
|
||||
print("\n=== Testing JSON Size Limit ===")
|
||||
|
||||
# Create a large JSON payload (over 1MB)
|
||||
large_data = {
|
||||
"text": "x" * (2 * 1024 * 1024), # 2MB of text
|
||||
"source_lang": "English",
|
||||
"target_lang": "Spanish"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(f"{BASE_URL}/translate", json=large_data)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 413:
|
||||
print(f"✓ Correctly rejected large JSON: {response.json()}")
|
||||
else:
|
||||
print(f"✗ Should have rejected large JSON")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
def test_audio_size_limit():
|
||||
"""Test audio file size limit"""
|
||||
print("\n=== Testing Audio Size Limit ===")
|
||||
|
||||
# Create a fake large audio file (over 25MB)
|
||||
large_audio = io.BytesIO(b"x" * (30 * 1024 * 1024)) # 30MB
|
||||
|
||||
files = {
|
||||
'audio': ('large_audio.wav', large_audio, 'audio/wav')
|
||||
}
|
||||
data = {
|
||||
'source_lang': 'English'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(f"{BASE_URL}/transcribe", files=files, data=data)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 413:
|
||||
print(f"✓ Correctly rejected large audio: {response.json()}")
|
||||
else:
|
||||
print(f"✗ Should have rejected large audio")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
def test_valid_requests():
|
||||
"""Test that valid-sized requests are accepted"""
|
||||
print("\n=== Testing Valid Size Requests ===")
|
||||
|
||||
# Small JSON payload
|
||||
small_data = {
|
||||
"text": "Hello world",
|
||||
"source_lang": "English",
|
||||
"target_lang": "Spanish"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(f"{BASE_URL}/translate", json=small_data)
|
||||
print(f"Small JSON - Status: {response.status_code}")
|
||||
if response.status_code != 413:
|
||||
print("✓ Small JSON accepted")
|
||||
else:
|
||||
print("✗ Small JSON should be accepted")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Small audio file
|
||||
small_audio = io.BytesIO(b"RIFF" + b"x" * 1000) # 1KB fake WAV
|
||||
files = {
|
||||
'audio': ('small_audio.wav', small_audio, 'audio/wav')
|
||||
}
|
||||
data = {
|
||||
'source_lang': 'English'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(f"{BASE_URL}/transcribe", files=files, data=data)
|
||||
print(f"Small audio - Status: {response.status_code}")
|
||||
if response.status_code != 413:
|
||||
print("✓ Small audio accepted")
|
||||
else:
|
||||
print("✗ Small audio should be accepted")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
def test_admin_endpoints():
|
||||
"""Test admin endpoints for size limits"""
|
||||
print("\n=== Testing Admin Endpoints ===")
|
||||
|
||||
headers = {'X-Admin-Token': os.environ.get('ADMIN_TOKEN', 'default-admin-token')}
|
||||
|
||||
# Get current limits
|
||||
try:
|
||||
response = requests.get(f"{BASE_URL}/admin/size-limits", headers=headers)
|
||||
print(f"Get limits - Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
limits = response.json()
|
||||
print(f"✓ Current limits: {limits['limits_human']}")
|
||||
else:
|
||||
print(f"✗ Failed to get limits: {response.text}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Update limits
|
||||
new_limits = {
|
||||
"max_audio_size": "30MB",
|
||||
"max_json_size": 2097152 # 2MB in bytes
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(f"{BASE_URL}/admin/size-limits",
|
||||
json=new_limits, headers=headers)
|
||||
print(f"\nUpdate limits - Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f"✓ Updated limits: {result['new_limits_human']}")
|
||||
else:
|
||||
print(f"✗ Failed to update limits: {response.text}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Request Size Limit Tests")
|
||||
print("========================")
|
||||
print(f"Testing against: {BASE_URL}")
|
||||
print("\nMake sure the Flask app is running on port 5005")
|
||||
|
||||
input("\nPress Enter to start tests...")
|
||||
|
||||
test_valid_requests()
|
||||
test_json_size_limit()
|
||||
test_audio_size_limit()
|
||||
test_admin_endpoints()
|
||||
|
||||
print("\n✅ All tests completed!")
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
TTS Debug Script - Tests connection to the OpenAI TTS server
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
from argparse import ArgumentParser
|
||||
|
||||
def test_tts_connection(server_url, api_key, text="Hello, this is a test message"):
|
||||
"""Test connection to the TTS server"""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"input": text,
|
||||
"voice": "echo",
|
||||
"response_format": "mp3",
|
||||
"speed": 1.0
|
||||
}
|
||||
|
||||
print(f"Sending request to: {server_url}")
|
||||
print(f"Headers: {headers}")
|
||||
print(f"Payload: {json.dumps(payload, indent=2)}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
server_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
print(f"Response status code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
print("Success! Received audio data")
|
||||
# Save to file
|
||||
output_file = "tts_test_output.mp3"
|
||||
with open(output_file, "wb") as f:
|
||||
f.write(response.content)
|
||||
print(f"Saved audio to {output_file}")
|
||||
return True
|
||||
else:
|
||||
print("Error in response")
|
||||
try:
|
||||
error_data = response.json()
|
||||
print(f"Error details: {json.dumps(error_data, indent=2)}")
|
||||
except:
|
||||
print(f"Raw response: {response.text[:500]}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during request: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser(description="Test connection to OpenAI TTS server")
|
||||
parser.add_argument("--url", default="http://localhost:5050/v1/audio/speech", help="TTS server URL")
|
||||
parser.add_argument("--key", default=os.environ.get("TTS_API_KEY", ""), help="API key")
|
||||
parser.add_argument("--text", default="Hello, this is a test message", help="Text to synthesize")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.key:
|
||||
print("Error: API key is required. Use --key argument or set TTS_API_KEY environment variable.")
|
||||
return 1
|
||||
|
||||
success = test_tts_connection(args.url, args.key, args.text)
|
||||
return 0 if success else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
352
user_rate_limiter.py
Normal file
352
user_rate_limiter.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""User-specific rate limiting that integrates with authentication"""
|
||||
import time
|
||||
import logging
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, g
|
||||
from collections import defaultdict, deque
|
||||
from threading import Lock
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from auth import get_current_user
|
||||
from auth_models import User
|
||||
from database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserRateLimiter:
|
||||
"""Enhanced rate limiter with user-specific limits"""
|
||||
|
||||
def __init__(self, default_limiter):
|
||||
self.default_limiter = default_limiter
|
||||
self.user_buckets = defaultdict(lambda: {
|
||||
'tokens': 0,
|
||||
'last_update': time.time(),
|
||||
'requests': deque(maxlen=1000)
|
||||
})
|
||||
self.lock = Lock()
|
||||
|
||||
def get_user_limits(self, user: User, endpoint: str):
|
||||
"""Get rate limits for a specific user"""
|
||||
# Start with endpoint-specific or default limits
|
||||
base_limits = self.default_limiter.get_limits(endpoint)
|
||||
|
||||
if not user:
|
||||
return base_limits
|
||||
|
||||
# Override with user-specific limits
|
||||
user_limits = {
|
||||
'requests_per_minute': user.rate_limit_per_minute,
|
||||
'requests_per_hour': user.rate_limit_per_hour,
|
||||
'requests_per_day': user.rate_limit_per_day,
|
||||
'burst_size': base_limits.get('burst_size', 10),
|
||||
'token_refresh_rate': user.rate_limit_per_minute / 60.0 # Convert to per-second
|
||||
}
|
||||
|
||||
# Admin users get higher limits
|
||||
if user.is_admin:
|
||||
user_limits['requests_per_minute'] *= 10
|
||||
user_limits['requests_per_hour'] *= 10
|
||||
user_limits['requests_per_day'] *= 10
|
||||
user_limits['burst_size'] *= 5
|
||||
|
||||
return user_limits
|
||||
|
||||
def check_user_rate_limit(self, user: User, endpoint: str, request_size: int = 0):
|
||||
"""Check rate limit for authenticated user"""
|
||||
if not user:
|
||||
# Fall back to IP-based limiting
|
||||
client_id = self.default_limiter.get_client_id(request)
|
||||
return self.default_limiter.check_rate_limit(client_id, endpoint, request_size)
|
||||
|
||||
with self.lock:
|
||||
user_id = str(user.id)
|
||||
limits = self.get_user_limits(user, endpoint)
|
||||
|
||||
# Get or create bucket for user
|
||||
bucket = self.user_buckets[user_id]
|
||||
now = time.time()
|
||||
|
||||
# Update tokens based on time passed
|
||||
time_passed = now - bucket['last_update']
|
||||
new_tokens = time_passed * limits['token_refresh_rate']
|
||||
bucket['tokens'] = min(
|
||||
limits['burst_size'],
|
||||
bucket['tokens'] + new_tokens
|
||||
)
|
||||
bucket['last_update'] = now
|
||||
|
||||
# Clean old requests from sliding windows
|
||||
minute_ago = now - 60
|
||||
hour_ago = now - 3600
|
||||
day_ago = now - 86400
|
||||
|
||||
bucket['requests'] = deque(
|
||||
(t for t in bucket['requests'] if t > day_ago),
|
||||
maxlen=1000
|
||||
)
|
||||
|
||||
# Count requests in windows
|
||||
requests_last_minute = sum(1 for t in bucket['requests'] if t > minute_ago)
|
||||
requests_last_hour = sum(1 for t in bucket['requests'] if t > hour_ago)
|
||||
requests_last_day = len(bucket['requests'])
|
||||
|
||||
# Check limits
|
||||
if requests_last_minute >= limits['requests_per_minute']:
|
||||
return False, "Rate limit exceeded (per minute)", {
|
||||
'retry_after': 60,
|
||||
'limit': limits['requests_per_minute'],
|
||||
'remaining': 0,
|
||||
'reset': int(minute_ago + 60),
|
||||
'scope': 'user'
|
||||
}
|
||||
|
||||
if requests_last_hour >= limits['requests_per_hour']:
|
||||
return False, "Rate limit exceeded (per hour)", {
|
||||
'retry_after': 3600,
|
||||
'limit': limits['requests_per_hour'],
|
||||
'remaining': 0,
|
||||
'reset': int(hour_ago + 3600),
|
||||
'scope': 'user'
|
||||
}
|
||||
|
||||
if requests_last_day >= limits['requests_per_day']:
|
||||
return False, "Rate limit exceeded (per day)", {
|
||||
'retry_after': 86400,
|
||||
'limit': limits['requests_per_day'],
|
||||
'remaining': 0,
|
||||
'reset': int(day_ago + 86400),
|
||||
'scope': 'user'
|
||||
}
|
||||
|
||||
# Check token bucket
|
||||
if bucket['tokens'] < 1:
|
||||
retry_after = int(1 / limits['token_refresh_rate'])
|
||||
return False, "Rate limit exceeded (burst)", {
|
||||
'retry_after': retry_after,
|
||||
'limit': limits['burst_size'],
|
||||
'remaining': 0,
|
||||
'reset': int(now + retry_after),
|
||||
'scope': 'user'
|
||||
}
|
||||
|
||||
# Request allowed
|
||||
bucket['tokens'] -= 1
|
||||
bucket['requests'].append(now)
|
||||
|
||||
# Calculate remaining
|
||||
remaining_minute = limits['requests_per_minute'] - requests_last_minute - 1
|
||||
remaining_hour = limits['requests_per_hour'] - requests_last_hour - 1
|
||||
remaining_day = limits['requests_per_day'] - requests_last_day - 1
|
||||
|
||||
return True, None, {
|
||||
'limit_minute': limits['requests_per_minute'],
|
||||
'limit_hour': limits['requests_per_hour'],
|
||||
'limit_day': limits['requests_per_day'],
|
||||
'remaining_minute': remaining_minute,
|
||||
'remaining_hour': remaining_hour,
|
||||
'remaining_day': remaining_day,
|
||||
'reset': int(minute_ago + 60),
|
||||
'scope': 'user'
|
||||
}
|
||||
|
||||
def get_user_usage_stats(self, user: User):
|
||||
"""Get usage statistics for a user"""
|
||||
if not user:
|
||||
return None
|
||||
|
||||
with self.lock:
|
||||
user_id = str(user.id)
|
||||
if user_id not in self.user_buckets:
|
||||
return {
|
||||
'requests_last_minute': 0,
|
||||
'requests_last_hour': 0,
|
||||
'requests_last_day': 0,
|
||||
'tokens_available': 0
|
||||
}
|
||||
|
||||
bucket = self.user_buckets[user_id]
|
||||
now = time.time()
|
||||
minute_ago = now - 60
|
||||
hour_ago = now - 3600
|
||||
day_ago = now - 86400
|
||||
|
||||
requests_last_minute = sum(1 for t in bucket['requests'] if t > minute_ago)
|
||||
requests_last_hour = sum(1 for t in bucket['requests'] if t > hour_ago)
|
||||
requests_last_day = sum(1 for t in bucket['requests'] if t > day_ago)
|
||||
|
||||
return {
|
||||
'requests_last_minute': requests_last_minute,
|
||||
'requests_last_hour': requests_last_hour,
|
||||
'requests_last_day': requests_last_day,
|
||||
'tokens_available': bucket['tokens'],
|
||||
'last_request': bucket['last_update']
|
||||
}
|
||||
|
||||
def reset_user_limits(self, user: User):
|
||||
"""Reset rate limits for a user (admin action)"""
|
||||
if not user:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
user_id = str(user.id)
|
||||
if user_id in self.user_buckets:
|
||||
del self.user_buckets[user_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Global user rate limiter instance
|
||||
from rate_limiter import rate_limiter as default_rate_limiter
|
||||
user_rate_limiter = UserRateLimiter(default_rate_limiter)
|
||||
|
||||
|
||||
def user_aware_rate_limit(endpoint=None,
|
||||
requests_per_minute=None,
|
||||
requests_per_hour=None,
|
||||
requests_per_day=None,
|
||||
burst_size=None,
|
||||
check_size=False,
|
||||
require_auth=False):
|
||||
"""
|
||||
Enhanced rate limiting decorator that considers user authentication
|
||||
|
||||
Usage:
|
||||
@app.route('/api/endpoint')
|
||||
@user_aware_rate_limit(requests_per_minute=10)
|
||||
def endpoint():
|
||||
return jsonify({'status': 'ok'})
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get current user (if authenticated)
|
||||
user = get_current_user()
|
||||
|
||||
# If auth is required but no user, return 401
|
||||
if require_auth and not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Authentication required',
|
||||
'code': 'auth_required'
|
||||
}), 401
|
||||
|
||||
# Get endpoint
|
||||
endpoint_path = endpoint or request.endpoint
|
||||
|
||||
# Check request size if needed
|
||||
request_size = 0
|
||||
if check_size:
|
||||
request_size = request.content_length or 0
|
||||
|
||||
# Check rate limit
|
||||
if user:
|
||||
# User-specific rate limiting
|
||||
allowed, message, headers = user_rate_limiter.check_user_rate_limit(
|
||||
user, endpoint_path, request_size
|
||||
)
|
||||
else:
|
||||
# Fall back to IP-based rate limiting
|
||||
client_id = default_rate_limiter.get_client_id(request)
|
||||
allowed, message, headers = default_rate_limiter.check_rate_limit(
|
||||
client_id, endpoint_path, request_size
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
# Log excessive requests
|
||||
identifier = f"user:{user.username}" if user else f"ip:{request.remote_addr}"
|
||||
logger.warning(f"Rate limit exceeded for {identifier} on {endpoint_path}: {message}")
|
||||
|
||||
# Update user stats if authenticated
|
||||
if user:
|
||||
user.last_active_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
response = jsonify({
|
||||
'success': False,
|
||||
'error': message,
|
||||
'retry_after': headers.get('retry_after') if headers else 60
|
||||
})
|
||||
response.status_code = 429
|
||||
|
||||
# Add rate limit headers
|
||||
if headers:
|
||||
if headers.get('scope') == 'user':
|
||||
response.headers['X-RateLimit-Limit'] = str(headers.get('limit_minute', 60))
|
||||
response.headers['X-RateLimit-Remaining'] = str(headers.get('remaining_minute', 0))
|
||||
response.headers['X-RateLimit-Limit-Hour'] = str(headers.get('limit_hour', 1000))
|
||||
response.headers['X-RateLimit-Remaining-Hour'] = str(headers.get('remaining_hour', 0))
|
||||
response.headers['X-RateLimit-Limit-Day'] = str(headers.get('limit_day', 10000))
|
||||
response.headers['X-RateLimit-Remaining-Day'] = str(headers.get('remaining_day', 0))
|
||||
else:
|
||||
response.headers['X-RateLimit-Limit'] = str(headers['limit'])
|
||||
response.headers['X-RateLimit-Remaining'] = str(headers['remaining'])
|
||||
response.headers['X-RateLimit-Reset'] = str(headers['reset'])
|
||||
response.headers['Retry-After'] = str(headers['retry_after'])
|
||||
|
||||
return response
|
||||
|
||||
# Track concurrent requests
|
||||
default_rate_limiter.increment_concurrent()
|
||||
|
||||
try:
|
||||
# Store user in g if authenticated
|
||||
if user:
|
||||
g.current_user = user
|
||||
|
||||
# Add rate limit info to response
|
||||
g.rate_limit_headers = headers
|
||||
response = f(*args, **kwargs)
|
||||
|
||||
# Add headers to successful response
|
||||
if headers and hasattr(response, 'headers'):
|
||||
if headers.get('scope') == 'user':
|
||||
response.headers['X-RateLimit-Limit'] = str(headers.get('limit_minute', 60))
|
||||
response.headers['X-RateLimit-Remaining'] = str(headers.get('remaining_minute', 0))
|
||||
response.headers['X-RateLimit-Limit-Hour'] = str(headers.get('limit_hour', 1000))
|
||||
response.headers['X-RateLimit-Remaining-Hour'] = str(headers.get('remaining_hour', 0))
|
||||
response.headers['X-RateLimit-Limit-Day'] = str(headers.get('limit_day', 10000))
|
||||
response.headers['X-RateLimit-Remaining-Day'] = str(headers.get('remaining_day', 0))
|
||||
else:
|
||||
response.headers['X-RateLimit-Limit'] = str(headers.get('limit', 60))
|
||||
response.headers['X-RateLimit-Remaining'] = str(headers.get('remaining', 0))
|
||||
response.headers['X-RateLimit-Reset'] = str(headers['reset'])
|
||||
|
||||
return response
|
||||
finally:
|
||||
default_rate_limiter.decrement_concurrent()
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def get_user_rate_limit_status(user: User = None):
|
||||
"""Get current rate limit status for a user or IP"""
|
||||
if not user:
|
||||
user = get_current_user()
|
||||
|
||||
if user:
|
||||
stats = user_rate_limiter.get_user_usage_stats(user)
|
||||
limits = user_rate_limiter.get_user_limits(user, request.endpoint or '/')
|
||||
|
||||
return {
|
||||
'type': 'user',
|
||||
'identifier': user.username,
|
||||
'limits': {
|
||||
'per_minute': limits['requests_per_minute'],
|
||||
'per_hour': limits['requests_per_hour'],
|
||||
'per_day': limits['requests_per_day']
|
||||
},
|
||||
'usage': stats
|
||||
}
|
||||
else:
|
||||
# IP-based stats
|
||||
client_id = default_rate_limiter.get_client_id(request)
|
||||
stats = default_rate_limiter.get_client_stats(client_id)
|
||||
|
||||
return {
|
||||
'type': 'ip',
|
||||
'identifier': request.remote_addr,
|
||||
'limits': default_rate_limiter.default_limits,
|
||||
'usage': stats
|
||||
}
|
||||
121
validate-pwa.html
Normal file
121
validate-pwa.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PWA Validation - Talk2Me</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
.status { padding: 10px; margin: 10px 0; border-radius: 5px; }
|
||||
.success { background: #d4edda; color: #155724; }
|
||||
.error { background: #f8d7da; color: #721c24; }
|
||||
.info { background: #d1ecf1; color: #0c5460; }
|
||||
img { max-width: 100px; height: auto; margin: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Talk2Me PWA Validation</h1>
|
||||
|
||||
<h2>Manifest Check</h2>
|
||||
<div id="manifest-status"></div>
|
||||
|
||||
<h2>Icon Check</h2>
|
||||
<div id="icon-status"></div>
|
||||
|
||||
<h2>Service Worker Check</h2>
|
||||
<div id="sw-status"></div>
|
||||
|
||||
<h2>Installation Test</h2>
|
||||
<button id="install-btn" style="display:none; padding: 10px 20px; font-size: 16px;">Install Talk2Me</button>
|
||||
<div id="install-status"></div>
|
||||
|
||||
<script>
|
||||
// Check manifest
|
||||
fetch('/static/manifest.json')
|
||||
.then(res => res.json())
|
||||
.then(manifest => {
|
||||
const status = document.getElementById('manifest-status');
|
||||
status.innerHTML = `
|
||||
<div class="status success">✓ Manifest loaded successfully</div>
|
||||
<div class="status info">Name: ${manifest.name}</div>
|
||||
<div class="status info">Short Name: ${manifest.short_name}</div>
|
||||
<div class="status info">Icons: ${manifest.icons.length} defined</div>
|
||||
`;
|
||||
|
||||
// Check icons
|
||||
const iconStatus = document.getElementById('icon-status');
|
||||
manifest.icons.forEach(icon => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'status success';
|
||||
div.innerHTML = `✓ ${icon.src} (${icon.sizes}) - ${icon.purpose}`;
|
||||
iconStatus.appendChild(div);
|
||||
iconStatus.appendChild(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'status error';
|
||||
div.innerHTML = `✗ ${icon.src} (${icon.sizes}) - Failed to load`;
|
||||
iconStatus.appendChild(div);
|
||||
};
|
||||
img.src = icon.src;
|
||||
img.style.maxWidth = '50px';
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('manifest-status').innerHTML =
|
||||
`<div class="status error">✗ Failed to load manifest: ${err.message}</div>`;
|
||||
});
|
||||
|
||||
// Check service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistration()
|
||||
.then(reg => {
|
||||
const status = document.getElementById('sw-status');
|
||||
if (reg) {
|
||||
status.innerHTML = `
|
||||
<div class="status success">✓ Service Worker is registered</div>
|
||||
<div class="status info">Scope: ${reg.scope}</div>
|
||||
<div class="status info">State: ${reg.active ? 'Active' : 'Not Active'}</div>
|
||||
`;
|
||||
} else {
|
||||
status.innerHTML = '<div class="status error">✗ Service Worker not registered</div>';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('sw-status').innerHTML =
|
||||
`<div class="status error">✗ Service Worker error: ${err.message}</div>`;
|
||||
});
|
||||
} else {
|
||||
document.getElementById('sw-status').innerHTML =
|
||||
'<div class="status error">✗ Service Workers not supported</div>';
|
||||
}
|
||||
|
||||
// Check install prompt
|
||||
let deferredPrompt;
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
document.getElementById('install-btn').style.display = 'block';
|
||||
document.getElementById('install-status').innerHTML =
|
||||
'<div class="status success">✓ App is installable</div>';
|
||||
});
|
||||
|
||||
document.getElementById('install-btn').addEventListener('click', async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
document.getElementById('install-status').innerHTML +=
|
||||
`<div class="status info">User ${outcome} the install</div>`;
|
||||
deferredPrompt = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
document.getElementById('install-status').innerHTML =
|
||||
'<div class="status success">✓ App is already installed</div>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -90,6 +90,19 @@ class Validators:
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def validate_email(email: str) -> bool:
|
||||
"""Validate email address format"""
|
||||
if not email or not isinstance(email, str):
|
||||
return False
|
||||
|
||||
# Basic email pattern
|
||||
email_pattern = re.compile(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
)
|
||||
|
||||
return bool(email_pattern.match(email))
|
||||
|
||||
@staticmethod
|
||||
def validate_url(url: str) -> Optional[str]:
|
||||
"""Validate and sanitize URL"""
|
||||
|
||||
23
webpack.config.js
Normal file
23
webpack.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: './static/js/src/app.ts',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: 'app.bundle.js',
|
||||
path: path.resolve(__dirname, 'static/js/dist'),
|
||||
},
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
};
|
||||
Reference in New Issue
Block a user