Compare commits
22 Commits
main
...
01-2025-06
Author | SHA1 | Date | |
---|---|---|---|
77f31cd694 | |||
92fd390866 | |||
1b9ad03400 | |||
92b7c41f61 | |||
aec2d3b0aa | |||
30edf8d272 | |||
eb4f5752ee | |||
9170198c6c | |||
a4ef775731 | |||
d010ae9b74 | |||
17e0f2f03d | |||
b08574efe5 | |||
dc3e67e17b | |||
343bfbf1de | |||
fed54259ca | |||
aedface2a9 | |||
3804897e2b | |||
0c9186e57e | |||
829e8c3978 | |||
08791d2fed | |||
05ad940079 | |||
80e724cf86 |
71
.dockerignore
Normal file
71
.dockerignore
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.production
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
/tmp
|
||||||
|
/temp
|
||||||
|
test_*.py
|
||||||
|
tests/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github/
|
||||||
|
.gitlab-ci.yml
|
||||||
|
.travis.yml
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
deploy.sh
|
||||||
|
Makefile
|
||||||
|
docker-compose.override.yml
|
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Example environment configuration for Talk2Me
|
||||||
|
# Copy this file to .env and update with your actual values
|
||||||
|
|
||||||
|
# Flask Configuration
|
||||||
|
SECRET_KEY=your-secret-key-here-change-this
|
||||||
|
|
||||||
|
# Upload Configuration
|
||||||
|
UPLOAD_FOLDER=/path/to/secure/upload/folder
|
||||||
|
|
||||||
|
# TTS Server Configuration
|
||||||
|
TTS_SERVER_URL=http://localhost:5050/v1/audio/speech
|
||||||
|
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
|
||||||
|
|
||||||
|
# Admin Token (for admin endpoints)
|
||||||
|
ADMIN_TOKEN=your-secure-admin-token-here
|
||||||
|
|
||||||
|
# Optional: GPU Configuration
|
||||||
|
# CUDA_VISIBLE_DEVICES=0
|
68
.gitignore
vendored
68
.gitignore
vendored
@ -1 +1,69 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
venv/
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
static/js/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
temp/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Audio files (for testing)
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.ogg
|
||||||
|
|
||||||
|
# Local environment
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
.env.staging
|
||||||
|
|
||||||
|
# VAPID keys
|
||||||
|
vapid_private.pem
|
||||||
|
vapid_public.pem
|
||||||
|
|
||||||
|
# Secrets management
|
||||||
|
.secrets.json
|
||||||
|
.master_key
|
||||||
|
secrets.db
|
||||||
|
*.key
|
||||||
|
173
CONNECTION_RETRY.md
Normal file
173
CONNECTION_RETRY.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# 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
Normal file
152
CORS_CONFIG.md
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
# 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/)
|
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Production Dockerfile for Talk2Me
|
||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
ffmpeg \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -u 1000 talk2me
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt requirements-prod.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements-prod.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY --chown=talk2me:talk2me . .
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p logs /tmp/talk2me_uploads && \
|
||||||
|
chown -R talk2me:talk2me logs /tmp/talk2me_uploads
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER talk2me
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV FLASK_ENV=production \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
UPLOAD_FOLDER=/tmp/talk2me_uploads \
|
||||||
|
LOGS_DIR=/app/logs
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:5005/health || exit 1
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5005
|
||||||
|
|
||||||
|
# Run with gunicorn
|
||||||
|
CMD ["gunicorn", "--config", "gunicorn_config.py", "wsgi:application"]
|
460
ERROR_LOGGING.md
Normal file
460
ERROR_LOGGING.md
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
# 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
|
68
GPU_SUPPORT.md
Normal file
68
GPU_SUPPORT.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# 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())"`
|
285
MEMORY_MANAGEMENT.md
Normal file
285
MEMORY_MANAGEMENT.md
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
# 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
|
435
PRODUCTION_DEPLOYMENT.md
Normal file
435
PRODUCTION_DEPLOYMENT.md
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
# 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
Normal file
235
RATE_LIMITING.md
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# 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
|
119
README.md
119
README.md
@ -29,19 +29,34 @@ A mobile-friendly web application that translates spoken language between multip
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Make sure you have Ollama installed and the Gemma 3 model loaded:
|
2. Configure secrets and environment:
|
||||||
|
```bash
|
||||||
|
# Initialize secure secrets management
|
||||||
|
python manage_secrets.py init
|
||||||
|
|
||||||
|
# Set required secrets
|
||||||
|
python manage_secrets.py set TTS_API_KEY
|
||||||
|
|
||||||
|
# Or use traditional .env file
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ 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
|
ollama pull gemma3
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Ensure your OpenAI Edge TTS server is running on port 5050.
|
4. Ensure your OpenAI Edge TTS server is running on port 5050.
|
||||||
|
|
||||||
4. Run the application:
|
5. Run the application:
|
||||||
```
|
```
|
||||||
python app.py
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Open your browser and navigate to:
|
6. Open your browser and navigate to:
|
||||||
```
|
```
|
||||||
http://localhost:8000
|
http://localhost:8000
|
||||||
```
|
```
|
||||||
@ -64,6 +79,102 @@ A mobile-friendly web application that translates spoken language between multip
|
|||||||
- Ollama provides access to the Gemma 3 model for translation
|
- Ollama provides access to the Gemma 3 model for translation
|
||||||
- OpenAI Edge TTS delivers natural-sounding speech output
|
- OpenAI Edge TTS delivers natural-sounding speech output
|
||||||
|
|
||||||
|
## CORS Configuration
|
||||||
|
|
||||||
|
The application supports Cross-Origin Resource Sharing (CORS) for secure cross-origin usage. See [CORS_CONFIG.md](CORS_CONFIG.md) for detailed configuration instructions.
|
||||||
|
|
||||||
|
Quick setup:
|
||||||
|
```bash
|
||||||
|
# Development (allow all origins)
|
||||||
|
export CORS_ORIGINS="*"
|
||||||
|
|
||||||
|
# Production (restrict to specific domains)
|
||||||
|
export CORS_ORIGINS="https://yourdomain.com,https://app.yourdomain.com"
|
||||||
|
export ADMIN_CORS_ORIGINS="https://admin.yourdomain.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connection Retry & Offline Support
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
See [CONNECTION_RETRY.md](CONNECTION_RETRY.md) for detailed documentation.
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Comprehensive rate limiting protects against DoS attacks and resource exhaustion:
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
See [SESSION_MANAGEMENT.md](SESSION_MANAGEMENT.md) for detailed documentation.
|
||||||
|
|
||||||
|
## Request Size Limits
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
See [REQUEST_SIZE_LIMITS.md](REQUEST_SIZE_LIMITS.md) for detailed documentation.
|
||||||
|
|
||||||
|
## Error Logging
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
See [ERROR_LOGGING.md](ERROR_LOGGING.md) for detailed documentation.
|
||||||
|
|
||||||
|
## Memory Management
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
See [MEMORY_MANAGEMENT.md](MEMORY_MANAGEMENT.md) for detailed documentation.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Quick start:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
See [PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md) for detailed deployment instructions.
|
||||||
|
|
||||||
## Mobile Support
|
## Mobile Support
|
||||||
|
|
||||||
The interface is fully responsive and designed to work well on mobile devices.
|
The interface is fully responsive and designed to work well on mobile devices.
|
||||||
|
54
README_TYPESCRIPT.md
Normal file
54
README_TYPESCRIPT.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
332
REQUEST_SIZE_LIMITS.md
Normal file
332
REQUEST_SIZE_LIMITS.md
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
# 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
|
411
SECRETS_MANAGEMENT.md
Normal file
411
SECRETS_MANAGEMENT.md
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
# 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
Normal file
173
SECURITY.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# 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.
|
366
SESSION_MANAGEMENT.md
Normal file
366
SESSION_MANAGEMENT.md
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
# 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
|
203
config.py
Normal file
203
config.py
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
# Configuration management with secrets integration
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from secrets_manager import get_secret, get_secrets_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Base configuration with secrets management"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.secrets_manager = get_secrets_manager()
|
||||||
|
self._load_config()
|
||||||
|
|
||||||
|
def _load_config(self):
|
||||||
|
"""Load configuration from environment and secrets"""
|
||||||
|
# Flask configuration
|
||||||
|
self.SECRET_KEY = self._get_secret('FLASK_SECRET_KEY',
|
||||||
|
os.environ.get('SECRET_KEY', 'dev-key-change-this'))
|
||||||
|
|
||||||
|
# Security
|
||||||
|
self.SESSION_COOKIE_SECURE = self._get_bool('SESSION_COOKIE_SECURE', True)
|
||||||
|
self.SESSION_COOKIE_HTTPONLY = True
|
||||||
|
self.SESSION_COOKIE_SAMESITE = 'Lax'
|
||||||
|
self.PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
||||||
|
|
||||||
|
# TTS Configuration
|
||||||
|
self.TTS_SERVER_URL = os.environ.get('TTS_SERVER_URL', 'http://localhost:5050/v1/audio/speech')
|
||||||
|
self.TTS_API_KEY = self._get_secret('TTS_API_KEY', os.environ.get('TTS_API_KEY', ''))
|
||||||
|
|
||||||
|
# Upload configuration
|
||||||
|
self.UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', None)
|
||||||
|
|
||||||
|
# Request size limits (in bytes)
|
||||||
|
self.MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 50 * 1024 * 1024)) # 50MB
|
||||||
|
self.MAX_AUDIO_SIZE = int(os.environ.get('MAX_AUDIO_SIZE', 25 * 1024 * 1024)) # 25MB
|
||||||
|
self.MAX_JSON_SIZE = int(os.environ.get('MAX_JSON_SIZE', 1 * 1024 * 1024)) # 1MB
|
||||||
|
self.MAX_IMAGE_SIZE = int(os.environ.get('MAX_IMAGE_SIZE', 10 * 1024 * 1024)) # 10MB
|
||||||
|
|
||||||
|
# CORS configuration
|
||||||
|
self.CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*').split(',')
|
||||||
|
self.ADMIN_CORS_ORIGINS = os.environ.get('ADMIN_CORS_ORIGINS', 'http://localhost:*').split(',')
|
||||||
|
|
||||||
|
# Admin configuration
|
||||||
|
self.ADMIN_TOKEN = self._get_secret('ADMIN_TOKEN',
|
||||||
|
os.environ.get('ADMIN_TOKEN', 'default-admin-token'))
|
||||||
|
|
||||||
|
# Database configuration (for future use)
|
||||||
|
self.DATABASE_URL = self._get_secret('DATABASE_URL',
|
||||||
|
os.environ.get('DATABASE_URL', 'sqlite:///talk2me.db'))
|
||||||
|
|
||||||
|
# Redis configuration (for future use)
|
||||||
|
self.REDIS_URL = self._get_secret('REDIS_URL',
|
||||||
|
os.environ.get('REDIS_URL', 'redis://localhost:6379/0'))
|
||||||
|
|
||||||
|
# Whisper configuration
|
||||||
|
self.WHISPER_MODEL_SIZE = os.environ.get('WHISPER_MODEL_SIZE', 'base')
|
||||||
|
self.WHISPER_DEVICE = os.environ.get('WHISPER_DEVICE', 'auto')
|
||||||
|
|
||||||
|
# Ollama configuration
|
||||||
|
self.OLLAMA_HOST = os.environ.get('OLLAMA_HOST', 'http://localhost:11434')
|
||||||
|
self.OLLAMA_MODEL = os.environ.get('OLLAMA_MODEL', 'gemma3:27b')
|
||||||
|
|
||||||
|
# Rate limiting configuration
|
||||||
|
self.RATE_LIMIT_ENABLED = self._get_bool('RATE_LIMIT_ENABLED', True)
|
||||||
|
self.RATE_LIMIT_STORAGE_URL = self._get_secret('RATE_LIMIT_STORAGE_URL',
|
||||||
|
os.environ.get('RATE_LIMIT_STORAGE_URL', 'memory://'))
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
self.LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
|
||||||
|
self.LOG_FILE = os.environ.get('LOG_FILE', 'talk2me.log')
|
||||||
|
|
||||||
|
# Feature flags
|
||||||
|
self.ENABLE_PUSH_NOTIFICATIONS = self._get_bool('ENABLE_PUSH_NOTIFICATIONS', True)
|
||||||
|
self.ENABLE_OFFLINE_MODE = self._get_bool('ENABLE_OFFLINE_MODE', True)
|
||||||
|
self.ENABLE_STREAMING = self._get_bool('ENABLE_STREAMING', True)
|
||||||
|
self.ENABLE_MULTI_SPEAKER = self._get_bool('ENABLE_MULTI_SPEAKER', True)
|
||||||
|
|
||||||
|
# Performance tuning
|
||||||
|
self.WORKER_CONNECTIONS = int(os.environ.get('WORKER_CONNECTIONS', '1000'))
|
||||||
|
self.WORKER_TIMEOUT = int(os.environ.get('WORKER_TIMEOUT', '120'))
|
||||||
|
|
||||||
|
# Validate configuration
|
||||||
|
self._validate_config()
|
||||||
|
|
||||||
|
def _get_secret(self, key: str, default: str = None) -> str:
|
||||||
|
"""Get secret from secrets manager or environment"""
|
||||||
|
value = self.secrets_manager.get(key)
|
||||||
|
if value is None:
|
||||||
|
value = default
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
logger.warning(f"Configuration {key} not set")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _get_bool(self, key: str, default: bool = False) -> bool:
|
||||||
|
"""Get boolean configuration value"""
|
||||||
|
value = os.environ.get(key, '').lower()
|
||||||
|
if value in ('true', '1', 'yes', 'on'):
|
||||||
|
return True
|
||||||
|
elif value in ('false', '0', 'no', 'off'):
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _validate_config(self):
|
||||||
|
"""Validate configuration values"""
|
||||||
|
# Check required secrets
|
||||||
|
if not self.SECRET_KEY or self.SECRET_KEY == 'dev-key-change-this':
|
||||||
|
logger.warning("Using default SECRET_KEY - this is insecure for production!")
|
||||||
|
|
||||||
|
if not self.TTS_API_KEY:
|
||||||
|
logger.warning("TTS_API_KEY not configured - TTS functionality may not work")
|
||||||
|
|
||||||
|
if self.ADMIN_TOKEN == 'default-admin-token':
|
||||||
|
logger.warning("Using default ADMIN_TOKEN - this is insecure for production!")
|
||||||
|
|
||||||
|
# Validate URLs
|
||||||
|
if not self._is_valid_url(self.TTS_SERVER_URL):
|
||||||
|
logger.error(f"Invalid TTS_SERVER_URL: {self.TTS_SERVER_URL}")
|
||||||
|
|
||||||
|
# Check file permissions
|
||||||
|
if self.UPLOAD_FOLDER and not os.access(self.UPLOAD_FOLDER, os.W_OK):
|
||||||
|
logger.warning(f"Upload folder {self.UPLOAD_FOLDER} is not writable")
|
||||||
|
|
||||||
|
def _is_valid_url(self, url: str) -> bool:
|
||||||
|
"""Check if URL is valid"""
|
||||||
|
return url.startswith(('http://', 'https://'))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Export configuration as dictionary (excluding secrets)"""
|
||||||
|
config = {}
|
||||||
|
for key in dir(self):
|
||||||
|
if key.isupper() and not key.startswith('_'):
|
||||||
|
value = getattr(self, key)
|
||||||
|
# Mask sensitive values
|
||||||
|
if any(sensitive in key for sensitive in ['KEY', 'TOKEN', 'PASSWORD', 'SECRET']):
|
||||||
|
config[key] = '***MASKED***'
|
||||||
|
else:
|
||||||
|
config[key] = value
|
||||||
|
return config
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
"""Development configuration"""
|
||||||
|
def _load_config(self):
|
||||||
|
super()._load_config()
|
||||||
|
self.DEBUG = True
|
||||||
|
self.TESTING = False
|
||||||
|
self.SESSION_COOKIE_SECURE = False # Allow HTTP in development
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
"""Production configuration"""
|
||||||
|
def _load_config(self):
|
||||||
|
super()._load_config()
|
||||||
|
self.DEBUG = False
|
||||||
|
self.TESTING = False
|
||||||
|
|
||||||
|
# Enforce security in production
|
||||||
|
if not self.SECRET_KEY or self.SECRET_KEY == 'dev-key-change-this':
|
||||||
|
raise ValueError("SECRET_KEY must be set in production")
|
||||||
|
|
||||||
|
if self.ADMIN_TOKEN == 'default-admin-token':
|
||||||
|
raise ValueError("ADMIN_TOKEN must be changed in production")
|
||||||
|
|
||||||
|
class TestingConfig(Config):
|
||||||
|
"""Testing configuration"""
|
||||||
|
def _load_config(self):
|
||||||
|
super()._load_config()
|
||||||
|
self.DEBUG = True
|
||||||
|
self.TESTING = True
|
||||||
|
self.WTF_CSRF_ENABLED = False
|
||||||
|
self.RATE_LIMIT_ENABLED = False
|
||||||
|
|
||||||
|
# Configuration factory
|
||||||
|
def get_config(env: str = None) -> Config:
|
||||||
|
"""Get configuration based on environment"""
|
||||||
|
if env is None:
|
||||||
|
env = os.environ.get('FLASK_ENV', 'development')
|
||||||
|
|
||||||
|
configs = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
'testing': TestingConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
config_class = configs.get(env, DevelopmentConfig)
|
||||||
|
return config_class()
|
||||||
|
|
||||||
|
# Convenience function for Flask app
|
||||||
|
def init_app(app):
|
||||||
|
"""Initialize Flask app with configuration"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Apply configuration to app
|
||||||
|
for key in dir(config):
|
||||||
|
if key.isupper():
|
||||||
|
app.config[key] = getattr(config, key)
|
||||||
|
|
||||||
|
# Store config object
|
||||||
|
app.app_config = config
|
||||||
|
|
||||||
|
logger.info(f"Configuration loaded for environment: {os.environ.get('FLASK_ENV', 'development')}")
|
208
deploy.sh
Executable file
208
deploy.sh
Executable file
@ -0,0 +1,208 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Production deployment script for Talk2Me
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
APP_NAME="talk2me"
|
||||||
|
APP_USER="talk2me"
|
||||||
|
APP_DIR="/opt/talk2me"
|
||||||
|
VENV_DIR="$APP_DIR/venv"
|
||||||
|
LOG_DIR="/var/log/talk2me"
|
||||||
|
PID_FILE="/var/run/talk2me.pid"
|
||||||
|
WORKERS=${WORKERS:-4}
|
||||||
|
|
||||||
|
# Functions
|
||||||
|
print_status() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
print_error "This script must be run as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create application user if doesn't exist
|
||||||
|
if ! id "$APP_USER" &>/dev/null; then
|
||||||
|
print_status "Creating application user: $APP_USER"
|
||||||
|
useradd -m -s /bin/bash $APP_USER
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
print_status "Creating application directories"
|
||||||
|
mkdir -p $APP_DIR $LOG_DIR
|
||||||
|
chown -R $APP_USER:$APP_USER $APP_DIR $LOG_DIR
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
print_status "Copying application files"
|
||||||
|
rsync -av --exclude='venv' --exclude='__pycache__' --exclude='*.pyc' \
|
||||||
|
--exclude='logs' --exclude='.git' --exclude='node_modules' \
|
||||||
|
./ $APP_DIR/
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
print_status "Setting up Python virtual environment"
|
||||||
|
su - $APP_USER -c "cd $APP_DIR && python3 -m venv $VENV_DIR"
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
print_status "Installing Python dependencies"
|
||||||
|
su - $APP_USER -c "cd $APP_DIR && $VENV_DIR/bin/pip install --upgrade pip"
|
||||||
|
su - $APP_USER -c "cd $APP_DIR && $VENV_DIR/bin/pip install -r requirements-prod.txt"
|
||||||
|
|
||||||
|
# Install Whisper model
|
||||||
|
print_status "Downloading Whisper model (this may take a while)"
|
||||||
|
su - $APP_USER -c "cd $APP_DIR && $VENV_DIR/bin/python -c 'import whisper; whisper.load_model(\"base\")'"
|
||||||
|
|
||||||
|
# Build frontend assets
|
||||||
|
if [ -f "package.json" ]; then
|
||||||
|
print_status "Building frontend assets"
|
||||||
|
cd $APP_DIR
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
print_status "Creating systemd service"
|
||||||
|
cat > /etc/systemd/system/talk2me.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Talk2Me Translation Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
User=$APP_USER
|
||||||
|
Group=$APP_USER
|
||||||
|
WorkingDirectory=$APP_DIR
|
||||||
|
Environment="PATH=$VENV_DIR/bin"
|
||||||
|
Environment="FLASK_ENV=production"
|
||||||
|
Environment="UPLOAD_FOLDER=/tmp/talk2me_uploads"
|
||||||
|
Environment="LOGS_DIR=$LOG_DIR"
|
||||||
|
ExecStart=$VENV_DIR/bin/gunicorn --config gunicorn_config.py wsgi:application
|
||||||
|
ExecReload=/bin/kill -s HUP \$MAINPID
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=5
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=$LOG_DIR /tmp
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create nginx configuration
|
||||||
|
print_status "Creating nginx configuration"
|
||||||
|
cat > /etc/nginx/sites-available/talk2me <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _; # Replace with your domain
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-Frame-Options DENY;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||||
|
|
||||||
|
# File upload size limit
|
||||||
|
client_max_body_size 50M;
|
||||||
|
client_body_buffer_size 1M;
|
||||||
|
|
||||||
|
# Timeouts for long audio processing
|
||||||
|
proxy_connect_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5005;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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_cache_bypass \$http_upgrade;
|
||||||
|
|
||||||
|
# Don't buffer responses
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
alias $APP_DIR/static;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:5005/health;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Enable nginx site
|
||||||
|
if [ -f /etc/nginx/sites-enabled/default ]; then
|
||||||
|
rm /etc/nginx/sites-enabled/default
|
||||||
|
fi
|
||||||
|
ln -sf /etc/nginx/sites-available/talk2me /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
chown -R $APP_USER:$APP_USER $APP_DIR
|
||||||
|
|
||||||
|
# Reload systemd
|
||||||
|
print_status "Reloading systemd"
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
print_status "Starting services"
|
||||||
|
systemctl enable talk2me
|
||||||
|
systemctl restart talk2me
|
||||||
|
systemctl restart nginx
|
||||||
|
|
||||||
|
# Wait for service to start
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Check service status
|
||||||
|
if systemctl is-active --quiet talk2me; then
|
||||||
|
print_status "Talk2Me service is running"
|
||||||
|
else
|
||||||
|
print_error "Talk2Me service failed to start"
|
||||||
|
journalctl -u talk2me -n 50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
if curl -s http://localhost:5005/health | grep -q "healthy"; then
|
||||||
|
print_status "Health check passed"
|
||||||
|
else
|
||||||
|
print_error "Health check failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_status "Deployment complete!"
|
||||||
|
print_status "Talk2Me is now running at http://$(hostname -I | awk '{print $1}')"
|
||||||
|
print_status "Check logs at: $LOG_DIR"
|
||||||
|
print_status "Service status: systemctl status talk2me"
|
92
docker-compose.yml
Normal file
92
docker-compose.yml
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
talk2me:
|
||||||
|
build: .
|
||||||
|
container_name: talk2me
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5005:5005"
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- UPLOAD_FOLDER=/tmp/talk2me_uploads
|
||||||
|
- LOGS_DIR=/app/logs
|
||||||
|
- TTS_SERVER_URL=${TTS_SERVER_URL:-http://localhost:5050/v1/audio/speech}
|
||||||
|
- TTS_API_KEY=${TTS_API_KEY}
|
||||||
|
- ADMIN_TOKEN=${ADMIN_TOKEN:-change-me-in-production}
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
|
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-4}
|
||||||
|
- GUNICORN_THREADS=${GUNICORN_THREADS:-2}
|
||||||
|
- MEMORY_THRESHOLD_MB=${MEMORY_THRESHOLD_MB:-4096}
|
||||||
|
- GPU_MEMORY_THRESHOLD_MB=${GPU_MEMORY_THRESHOLD_MB:-2048}
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- talk2me_uploads:/tmp/talk2me_uploads
|
||||||
|
- talk2me_models:/root/.cache/whisper # Whisper models cache
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4G
|
||||||
|
reservations:
|
||||||
|
memory: 2G
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5005/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- talk2me_network
|
||||||
|
|
||||||
|
# Nginx reverse proxy (optional, for production)
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: talk2me_nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./static:/app/static:ro
|
||||||
|
- nginx_ssl:/etc/nginx/ssl
|
||||||
|
depends_on:
|
||||||
|
- talk2me
|
||||||
|
networks:
|
||||||
|
- talk2me_network
|
||||||
|
|
||||||
|
# Redis for session storage (optional)
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: talk2me_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- talk2me_network
|
||||||
|
|
||||||
|
# PostgreSQL for persistent storage (optional)
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: talk2me_postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=talk2me
|
||||||
|
- POSTGRES_USER=talk2me
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-change-me-in-production}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- talk2me_network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
talk2me_uploads:
|
||||||
|
talk2me_models:
|
||||||
|
redis_data:
|
||||||
|
postgres_data:
|
||||||
|
nginx_ssl:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
talk2me_network:
|
||||||
|
driver: bridge
|
564
error_logger.py
Normal file
564
error_logger.py
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
# Comprehensive error logging system for production debugging
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, Optional, Union
|
||||||
|
from functools import wraps
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from flask import request, g
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# Create logs directory if it doesn't exist
|
||||||
|
LOGS_DIR = os.environ.get('LOGS_DIR', 'logs')
|
||||||
|
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
class StructuredFormatter(logging.Formatter):
|
||||||
|
"""
|
||||||
|
Custom formatter that outputs structured JSON logs
|
||||||
|
"""
|
||||||
|
def __init__(self, app_name='talk2me', environment='development'):
|
||||||
|
super().__init__()
|
||||||
|
self.app_name = app_name
|
||||||
|
self.environment = environment
|
||||||
|
self.hostname = socket.gethostname()
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
# Base log structure
|
||||||
|
log_data = {
|
||||||
|
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||||
|
'level': record.levelname,
|
||||||
|
'logger': record.name,
|
||||||
|
'message': record.getMessage(),
|
||||||
|
'app': self.app_name,
|
||||||
|
'environment': self.environment,
|
||||||
|
'hostname': self.hostname,
|
||||||
|
'thread': threading.current_thread().name,
|
||||||
|
'process': os.getpid()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add exception info if present
|
||||||
|
if record.exc_info:
|
||||||
|
log_data['exception'] = {
|
||||||
|
'type': record.exc_info[0].__name__,
|
||||||
|
'message': str(record.exc_info[1]),
|
||||||
|
'traceback': traceback.format_exception(*record.exc_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add extra fields
|
||||||
|
if hasattr(record, 'extra_fields'):
|
||||||
|
log_data.update(record.extra_fields)
|
||||||
|
|
||||||
|
# Add Flask request context if available
|
||||||
|
if hasattr(record, 'request_id'):
|
||||||
|
log_data['request_id'] = record.request_id
|
||||||
|
|
||||||
|
if hasattr(record, 'user_id'):
|
||||||
|
log_data['user_id'] = record.user_id
|
||||||
|
|
||||||
|
if hasattr(record, 'session_id'):
|
||||||
|
log_data['session_id'] = record.session_id
|
||||||
|
|
||||||
|
# Add performance metrics if available
|
||||||
|
if hasattr(record, 'duration'):
|
||||||
|
log_data['duration_ms'] = record.duration
|
||||||
|
|
||||||
|
if hasattr(record, 'memory_usage'):
|
||||||
|
log_data['memory_usage_mb'] = record.memory_usage
|
||||||
|
|
||||||
|
return json.dumps(log_data, default=str)
|
||||||
|
|
||||||
|
class ErrorLogger:
|
||||||
|
"""
|
||||||
|
Comprehensive error logging system with multiple handlers and features
|
||||||
|
"""
|
||||||
|
def __init__(self, app=None, config=None):
|
||||||
|
self.config = config or {}
|
||||||
|
self.loggers = {}
|
||||||
|
self.error_counts = {}
|
||||||
|
self.error_signatures = {}
|
||||||
|
|
||||||
|
if app:
|
||||||
|
self.init_app(app)
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
"""Initialize error logging for Flask app"""
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
# Get configuration
|
||||||
|
self.log_level = self.config.get('log_level',
|
||||||
|
app.config.get('LOG_LEVEL', 'INFO'))
|
||||||
|
self.log_file = self.config.get('log_file',
|
||||||
|
app.config.get('LOG_FILE',
|
||||||
|
os.path.join(LOGS_DIR, 'talk2me.log')))
|
||||||
|
self.error_log_file = self.config.get('error_log_file',
|
||||||
|
os.path.join(LOGS_DIR, 'errors.log'))
|
||||||
|
self.max_bytes = self.config.get('max_bytes', 50 * 1024 * 1024) # 50MB
|
||||||
|
self.backup_count = self.config.get('backup_count', 10)
|
||||||
|
self.environment = app.config.get('FLASK_ENV', 'development')
|
||||||
|
|
||||||
|
# Set up loggers
|
||||||
|
self._setup_app_logger()
|
||||||
|
self._setup_error_logger()
|
||||||
|
self._setup_access_logger()
|
||||||
|
self._setup_security_logger()
|
||||||
|
self._setup_performance_logger()
|
||||||
|
|
||||||
|
# Add Flask error handlers
|
||||||
|
self._setup_flask_handlers(app)
|
||||||
|
|
||||||
|
# Add request logging
|
||||||
|
app.before_request(self._before_request)
|
||||||
|
app.after_request(self._after_request)
|
||||||
|
|
||||||
|
# Store logger in app
|
||||||
|
app.error_logger = self
|
||||||
|
|
||||||
|
logging.info("Error logging system initialized")
|
||||||
|
|
||||||
|
def _setup_app_logger(self):
|
||||||
|
"""Set up main application logger"""
|
||||||
|
app_logger = logging.getLogger('talk2me')
|
||||||
|
app_logger.setLevel(getattr(logging, self.log_level.upper()))
|
||||||
|
|
||||||
|
# Remove existing handlers
|
||||||
|
app_logger.handlers = []
|
||||||
|
|
||||||
|
# Console handler with color support
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
if sys.stdout.isatty():
|
||||||
|
# Use colored output for terminals
|
||||||
|
from colorlog import ColoredFormatter
|
||||||
|
console_formatter = ColoredFormatter(
|
||||||
|
'%(log_color)s%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
log_colors={
|
||||||
|
'DEBUG': 'cyan',
|
||||||
|
'INFO': 'green',
|
||||||
|
'WARNING': 'yellow',
|
||||||
|
'ERROR': 'red',
|
||||||
|
'CRITICAL': 'red,bg_white',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
else:
|
||||||
|
console_handler.setFormatter(
|
||||||
|
StructuredFormatter('talk2me', self.environment)
|
||||||
|
)
|
||||||
|
app_logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# Rotating file handler
|
||||||
|
file_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
self.log_file,
|
||||||
|
maxBytes=self.max_bytes,
|
||||||
|
backupCount=self.backup_count
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(
|
||||||
|
StructuredFormatter('talk2me', self.environment)
|
||||||
|
)
|
||||||
|
app_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
self.loggers['app'] = app_logger
|
||||||
|
|
||||||
|
def _setup_error_logger(self):
|
||||||
|
"""Set up dedicated error logger"""
|
||||||
|
error_logger = logging.getLogger('talk2me.errors')
|
||||||
|
error_logger.setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
# Error file handler
|
||||||
|
error_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
self.error_log_file,
|
||||||
|
maxBytes=self.max_bytes,
|
||||||
|
backupCount=self.backup_count
|
||||||
|
)
|
||||||
|
error_handler.setFormatter(
|
||||||
|
StructuredFormatter('talk2me', self.environment)
|
||||||
|
)
|
||||||
|
error_logger.addHandler(error_handler)
|
||||||
|
|
||||||
|
# Also send errors to syslog if available
|
||||||
|
try:
|
||||||
|
syslog_handler = logging.handlers.SysLogHandler(
|
||||||
|
address='/dev/log' if os.path.exists('/dev/log') else ('localhost', 514)
|
||||||
|
)
|
||||||
|
syslog_handler.setFormatter(
|
||||||
|
logging.Formatter('talk2me[%(process)d]: %(levelname)s %(message)s')
|
||||||
|
)
|
||||||
|
error_logger.addHandler(syslog_handler)
|
||||||
|
except Exception:
|
||||||
|
pass # Syslog not available
|
||||||
|
|
||||||
|
self.loggers['error'] = error_logger
|
||||||
|
|
||||||
|
def _setup_access_logger(self):
|
||||||
|
"""Set up access logger for HTTP requests"""
|
||||||
|
access_logger = logging.getLogger('talk2me.access')
|
||||||
|
access_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Access log file
|
||||||
|
access_handler = logging.handlers.TimedRotatingFileHandler(
|
||||||
|
os.path.join(LOGS_DIR, 'access.log'),
|
||||||
|
when='midnight',
|
||||||
|
interval=1,
|
||||||
|
backupCount=30
|
||||||
|
)
|
||||||
|
access_handler.setFormatter(
|
||||||
|
StructuredFormatter('talk2me', self.environment)
|
||||||
|
)
|
||||||
|
access_logger.addHandler(access_handler)
|
||||||
|
|
||||||
|
self.loggers['access'] = access_logger
|
||||||
|
|
||||||
|
def _setup_security_logger(self):
|
||||||
|
"""Set up security event logger"""
|
||||||
|
security_logger = logging.getLogger('talk2me.security')
|
||||||
|
security_logger.setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
# Security log file
|
||||||
|
security_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
os.path.join(LOGS_DIR, 'security.log'),
|
||||||
|
maxBytes=self.max_bytes,
|
||||||
|
backupCount=self.backup_count
|
||||||
|
)
|
||||||
|
security_handler.setFormatter(
|
||||||
|
StructuredFormatter('talk2me', self.environment)
|
||||||
|
)
|
||||||
|
security_logger.addHandler(security_handler)
|
||||||
|
|
||||||
|
self.loggers['security'] = security_logger
|
||||||
|
|
||||||
|
def _setup_performance_logger(self):
|
||||||
|
"""Set up performance metrics logger"""
|
||||||
|
perf_logger = logging.getLogger('talk2me.performance')
|
||||||
|
perf_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Performance log file
|
||||||
|
perf_handler = logging.handlers.TimedRotatingFileHandler(
|
||||||
|
os.path.join(LOGS_DIR, 'performance.log'),
|
||||||
|
when='H', # Hourly rotation
|
||||||
|
interval=1,
|
||||||
|
backupCount=168 # 7 days
|
||||||
|
)
|
||||||
|
perf_handler.setFormatter(
|
||||||
|
StructuredFormatter('talk2me', self.environment)
|
||||||
|
)
|
||||||
|
perf_logger.addHandler(perf_handler)
|
||||||
|
|
||||||
|
self.loggers['performance'] = perf_logger
|
||||||
|
|
||||||
|
def _setup_flask_handlers(self, app):
|
||||||
|
"""Set up Flask error handlers"""
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_exception(error):
|
||||||
|
# Get request ID
|
||||||
|
request_id = getattr(g, 'request_id', 'unknown')
|
||||||
|
|
||||||
|
# Create error signature for deduplication
|
||||||
|
error_signature = self._get_error_signature(error)
|
||||||
|
|
||||||
|
# Log the error
|
||||||
|
self.log_error(
|
||||||
|
error,
|
||||||
|
request_id=request_id,
|
||||||
|
endpoint=request.endpoint,
|
||||||
|
method=request.method,
|
||||||
|
path=request.path,
|
||||||
|
ip=request.remote_addr,
|
||||||
|
user_agent=request.headers.get('User-Agent'),
|
||||||
|
signature=error_signature
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track error frequency
|
||||||
|
self._track_error(error_signature)
|
||||||
|
|
||||||
|
# Return appropriate response
|
||||||
|
if hasattr(error, 'code'):
|
||||||
|
return {'error': str(error)}, error.code
|
||||||
|
else:
|
||||||
|
return {'error': 'Internal server error'}, 500
|
||||||
|
|
||||||
|
def _before_request(self):
|
||||||
|
"""Log request start"""
|
||||||
|
# Generate request ID
|
||||||
|
g.request_id = self._generate_request_id()
|
||||||
|
g.request_start_time = time.time()
|
||||||
|
|
||||||
|
# Log access
|
||||||
|
self.log_access(
|
||||||
|
'request_start',
|
||||||
|
request_id=g.request_id,
|
||||||
|
method=request.method,
|
||||||
|
path=request.path,
|
||||||
|
ip=request.remote_addr,
|
||||||
|
user_agent=request.headers.get('User-Agent')
|
||||||
|
)
|
||||||
|
|
||||||
|
def _after_request(self, response):
|
||||||
|
"""Log request completion"""
|
||||||
|
# Calculate duration
|
||||||
|
duration = None
|
||||||
|
if hasattr(g, 'request_start_time'):
|
||||||
|
duration = int((time.time() - g.request_start_time) * 1000)
|
||||||
|
|
||||||
|
# Log access
|
||||||
|
self.log_access(
|
||||||
|
'request_complete',
|
||||||
|
request_id=getattr(g, 'request_id', 'unknown'),
|
||||||
|
method=request.method,
|
||||||
|
path=request.path,
|
||||||
|
status=response.status_code,
|
||||||
|
duration_ms=duration,
|
||||||
|
content_length=response.content_length
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log performance metrics for slow requests
|
||||||
|
if duration and duration > 1000: # Over 1 second
|
||||||
|
self.log_performance(
|
||||||
|
'slow_request',
|
||||||
|
request_id=getattr(g, 'request_id', 'unknown'),
|
||||||
|
endpoint=request.endpoint,
|
||||||
|
duration_ms=duration
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def log_error(self, error: Exception, **kwargs):
|
||||||
|
"""Log an error with context"""
|
||||||
|
error_logger = self.loggers.get('error', logging.getLogger())
|
||||||
|
|
||||||
|
# Create log record with extra fields
|
||||||
|
extra = {
|
||||||
|
'extra_fields': kwargs,
|
||||||
|
'request_id': kwargs.get('request_id'),
|
||||||
|
'user_id': kwargs.get('user_id'),
|
||||||
|
'session_id': kwargs.get('session_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Log with full traceback
|
||||||
|
error_logger.error(
|
||||||
|
f"Error in {kwargs.get('endpoint', 'unknown')}: {str(error)}",
|
||||||
|
exc_info=sys.exc_info(),
|
||||||
|
extra=extra
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_access(self, message: str, **kwargs):
|
||||||
|
"""Log access event"""
|
||||||
|
access_logger = self.loggers.get('access', logging.getLogger())
|
||||||
|
|
||||||
|
extra = {
|
||||||
|
'extra_fields': kwargs,
|
||||||
|
'request_id': kwargs.get('request_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
access_logger.info(message, extra=extra)
|
||||||
|
|
||||||
|
def log_security(self, event: str, severity: str = 'warning', **kwargs):
|
||||||
|
"""Log security event"""
|
||||||
|
security_logger = self.loggers.get('security', logging.getLogger())
|
||||||
|
|
||||||
|
extra = {
|
||||||
|
'extra_fields': {
|
||||||
|
'event': event,
|
||||||
|
'severity': severity,
|
||||||
|
**kwargs
|
||||||
|
},
|
||||||
|
'request_id': kwargs.get('request_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
log_method = getattr(security_logger, severity.lower(), security_logger.warning)
|
||||||
|
log_method(f"Security event: {event}", extra=extra)
|
||||||
|
|
||||||
|
def log_performance(self, metric: str, value: Union[int, float] = None, **kwargs):
|
||||||
|
"""Log performance metric"""
|
||||||
|
perf_logger = self.loggers.get('performance', logging.getLogger())
|
||||||
|
|
||||||
|
extra = {
|
||||||
|
'extra_fields': {
|
||||||
|
'metric': metric,
|
||||||
|
'value': value,
|
||||||
|
**kwargs
|
||||||
|
},
|
||||||
|
'request_id': kwargs.get('request_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
perf_logger.info(f"Performance metric: {metric}", extra=extra)
|
||||||
|
|
||||||
|
def _generate_request_id(self):
|
||||||
|
"""Generate unique request ID"""
|
||||||
|
return f"{int(time.time() * 1000)}-{os.urandom(8).hex()}"
|
||||||
|
|
||||||
|
def _get_error_signature(self, error: Exception):
|
||||||
|
"""Generate signature for error deduplication"""
|
||||||
|
# Create signature from error type and key parts of traceback
|
||||||
|
tb_summary = traceback.format_exception_only(type(error), error)
|
||||||
|
signature_data = f"{type(error).__name__}:{tb_summary[0] if tb_summary else ''}"
|
||||||
|
return hashlib.md5(signature_data.encode()).hexdigest()
|
||||||
|
|
||||||
|
def _track_error(self, signature: str):
|
||||||
|
"""Track error frequency"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if signature not in self.error_counts:
|
||||||
|
self.error_counts[signature] = []
|
||||||
|
|
||||||
|
# Add current timestamp
|
||||||
|
self.error_counts[signature].append(now)
|
||||||
|
|
||||||
|
# Clean old entries (keep last hour)
|
||||||
|
self.error_counts[signature] = [
|
||||||
|
ts for ts in self.error_counts[signature]
|
||||||
|
if now - ts < 3600
|
||||||
|
]
|
||||||
|
|
||||||
|
# Alert if error rate is high
|
||||||
|
error_count = len(self.error_counts[signature])
|
||||||
|
if error_count > 10: # More than 10 in an hour
|
||||||
|
self.log_security(
|
||||||
|
'high_error_rate',
|
||||||
|
severity='error',
|
||||||
|
signature=signature,
|
||||||
|
count=error_count,
|
||||||
|
message="High error rate detected"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_error_summary(self):
|
||||||
|
"""Get summary of recent errors"""
|
||||||
|
summary = {}
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
for signature, timestamps in self.error_counts.items():
|
||||||
|
recent_count = len([ts for ts in timestamps if now - ts < 3600])
|
||||||
|
if recent_count > 0:
|
||||||
|
summary[signature] = {
|
||||||
|
'count_last_hour': recent_count,
|
||||||
|
'last_seen': max(timestamps)
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
# Decorators for easy logging
|
||||||
|
def log_errors(logger_name='talk2me'):
|
||||||
|
"""Decorator to log function errors"""
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
logger.error(
|
||||||
|
f"Error in {func.__name__}: {str(e)}",
|
||||||
|
exc_info=sys.exc_info(),
|
||||||
|
extra={
|
||||||
|
'extra_fields': {
|
||||||
|
'function': func.__name__,
|
||||||
|
'module': func.__module__
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def log_performance(metric_name=None):
|
||||||
|
"""Decorator to log function performance"""
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
duration = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# Log performance
|
||||||
|
logger = logging.getLogger('talk2me.performance')
|
||||||
|
logger.info(
|
||||||
|
f"Performance: {metric_name or func.__name__}",
|
||||||
|
extra={
|
||||||
|
'extra_fields': {
|
||||||
|
'metric': metric_name or func.__name__,
|
||||||
|
'duration_ms': duration,
|
||||||
|
'function': func.__name__,
|
||||||
|
'module': func.__module__
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
duration = int((time.time() - start_time) * 1000)
|
||||||
|
logger = logging.getLogger('talk2me.performance')
|
||||||
|
logger.warning(
|
||||||
|
f"Performance (failed): {metric_name or func.__name__}",
|
||||||
|
extra={
|
||||||
|
'extra_fields': {
|
||||||
|
'metric': metric_name or func.__name__,
|
||||||
|
'duration_ms': duration,
|
||||||
|
'function': func.__name__,
|
||||||
|
'module': func.__module__,
|
||||||
|
'status': 'failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def log_context(**kwargs):
|
||||||
|
"""Context manager to add context to logs"""
|
||||||
|
# Store current context
|
||||||
|
old_context = {}
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(g, key):
|
||||||
|
old_context[key] = getattr(g, key)
|
||||||
|
setattr(g, key, value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
# Restore old context
|
||||||
|
for key in kwargs:
|
||||||
|
if key in old_context:
|
||||||
|
setattr(g, key, old_context[key])
|
||||||
|
else:
|
||||||
|
delattr(g, key)
|
||||||
|
|
||||||
|
# Utility functions
|
||||||
|
def configure_logging(app, **kwargs):
|
||||||
|
"""Configure logging for the application"""
|
||||||
|
config = {
|
||||||
|
'log_level': kwargs.get('log_level', app.config.get('LOG_LEVEL', 'INFO')),
|
||||||
|
'log_file': kwargs.get('log_file', app.config.get('LOG_FILE')),
|
||||||
|
'error_log_file': kwargs.get('error_log_file'),
|
||||||
|
'max_bytes': kwargs.get('max_bytes', 50 * 1024 * 1024),
|
||||||
|
'backup_count': kwargs.get('backup_count', 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
error_logger = ErrorLogger(app, config)
|
||||||
|
return error_logger
|
||||||
|
|
||||||
|
def get_logger(name='talk2me'):
|
||||||
|
"""Get a logger instance"""
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
def log_exception(error, message=None, **kwargs):
|
||||||
|
"""Log an exception with context"""
|
||||||
|
logger = logging.getLogger('talk2me.errors')
|
||||||
|
|
||||||
|
extra = {
|
||||||
|
'extra_fields': kwargs,
|
||||||
|
'request_id': getattr(g, 'request_id', None)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
message or f"Exception: {str(error)}",
|
||||||
|
exc_info=(type(error), error, error.__traceback__),
|
||||||
|
extra=extra
|
||||||
|
)
|
86
gunicorn_config.py
Normal file
86
gunicorn_config.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Gunicorn configuration for production deployment
|
||||||
|
"""
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Server socket
|
||||||
|
bind = os.environ.get('GUNICORN_BIND', '0.0.0.0:5005')
|
||||||
|
backlog = 2048
|
||||||
|
|
||||||
|
# Worker processes
|
||||||
|
# Use 2-4 workers per CPU core
|
||||||
|
workers = int(os.environ.get('GUNICORN_WORKERS', multiprocessing.cpu_count() * 2 + 1))
|
||||||
|
worker_class = 'sync' # Use 'gevent' for async if needed
|
||||||
|
worker_connections = 1000
|
||||||
|
timeout = 120 # Increased for audio processing
|
||||||
|
keepalive = 5
|
||||||
|
|
||||||
|
# Restart workers after this many requests, to help prevent memory leaks
|
||||||
|
max_requests = 1000
|
||||||
|
max_requests_jitter = 50
|
||||||
|
|
||||||
|
# Preload the application
|
||||||
|
preload_app = True
|
||||||
|
|
||||||
|
# Server mechanics
|
||||||
|
daemon = False
|
||||||
|
pidfile = os.environ.get('GUNICORN_PID', '/tmp/talk2me.pid')
|
||||||
|
user = None
|
||||||
|
group = None
|
||||||
|
tmp_upload_dir = None
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
accesslog = os.environ.get('GUNICORN_ACCESS_LOG', '-')
|
||||||
|
errorlog = os.environ.get('GUNICORN_ERROR_LOG', '-')
|
||||||
|
loglevel = os.environ.get('GUNICORN_LOG_LEVEL', 'info')
|
||||||
|
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
|
||||||
|
|
||||||
|
# Process naming
|
||||||
|
proc_name = 'talk2me'
|
||||||
|
|
||||||
|
# Server hooks
|
||||||
|
def when_ready(server):
|
||||||
|
"""Called just after the server is started."""
|
||||||
|
server.log.info("Server is ready. Spawning workers")
|
||||||
|
|
||||||
|
def worker_int(worker):
|
||||||
|
"""Called just after a worker exited on SIGINT or SIGQUIT."""
|
||||||
|
worker.log.info("Worker received INT or QUIT signal")
|
||||||
|
|
||||||
|
def pre_fork(server, worker):
|
||||||
|
"""Called just before a worker is forked."""
|
||||||
|
server.log.info(f"Forking worker {worker}")
|
||||||
|
|
||||||
|
def post_fork(server, worker):
|
||||||
|
"""Called just after a worker has been forked."""
|
||||||
|
server.log.info(f"Worker spawned (pid: {worker.pid})")
|
||||||
|
|
||||||
|
def worker_exit(server, worker):
|
||||||
|
"""Called just after a worker has been killed."""
|
||||||
|
server.log.info(f"Worker exit (pid: {worker.pid})")
|
||||||
|
|
||||||
|
def pre_request(worker, req):
|
||||||
|
"""Called just before a worker processes the request."""
|
||||||
|
worker.log.debug(f"{req.method} {req.path}")
|
||||||
|
|
||||||
|
def post_request(worker, req, environ, resp):
|
||||||
|
"""Called after a worker processes the request."""
|
||||||
|
worker.log.debug(f"{req.method} {req.path} - {resp.status}")
|
||||||
|
|
||||||
|
# SSL/TLS (uncomment if using HTTPS directly)
|
||||||
|
# keyfile = '/path/to/keyfile'
|
||||||
|
# certfile = '/path/to/certfile'
|
||||||
|
# ssl_version = 'TLSv1_2'
|
||||||
|
# cert_reqs = 'required'
|
||||||
|
# ca_certs = '/path/to/ca_certs'
|
||||||
|
|
||||||
|
# Thread option (if using threaded workers)
|
||||||
|
threads = int(os.environ.get('GUNICORN_THREADS', 1))
|
||||||
|
|
||||||
|
# Silent health checks in logs
|
||||||
|
def pre_request(worker, req):
|
||||||
|
if req.path in ['/health', '/health/live']:
|
||||||
|
# Don't log health checks
|
||||||
|
return
|
||||||
|
worker.log.debug(f"{req.method} {req.path}")
|
91
health-monitor.py
Executable file
91
health-monitor.py
Executable file
@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Health monitoring script for Talk2Me application
|
||||||
|
Usage: python health-monitor.py [--detailed] [--interval SECONDS]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def check_health(url, detailed=False):
|
||||||
|
"""Check health of the Talk2Me service"""
|
||||||
|
endpoint = f"{url}/health/detailed" if detailed else f"{url}/health"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(endpoint, timeout=5)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if detailed:
|
||||||
|
print(f"\n=== Health Check at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ===")
|
||||||
|
print(f"Overall Status: {data['status'].upper()}")
|
||||||
|
print("\nComponent Status:")
|
||||||
|
for component, status in data['components'].items():
|
||||||
|
status_icon = "✅" if status.get('status') == 'healthy' else "❌"
|
||||||
|
print(f" {status_icon} {component}: {status.get('status', 'unknown')}")
|
||||||
|
if 'error' in status:
|
||||||
|
print(f" Error: {status['error']}")
|
||||||
|
if 'device' in status:
|
||||||
|
print(f" Device: {status['device']}")
|
||||||
|
if 'model_size' in status:
|
||||||
|
print(f" Model: {status['model_size']}")
|
||||||
|
|
||||||
|
if 'metrics' in data:
|
||||||
|
print("\nMetrics:")
|
||||||
|
uptime = data['metrics'].get('uptime', 0)
|
||||||
|
hours = int(uptime // 3600)
|
||||||
|
minutes = int((uptime % 3600) // 60)
|
||||||
|
print(f" Uptime: {hours}h {minutes}m")
|
||||||
|
print(f" Request Count: {data['metrics'].get('request_count', 0)}")
|
||||||
|
else:
|
||||||
|
status_icon = "✅" if response.status_code == 200 else "❌"
|
||||||
|
print(f"{status_icon} {datetime.now().strftime('%H:%M:%S')} - Status: {data.get('status', 'unknown')}")
|
||||||
|
|
||||||
|
return response.status_code == 200
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f"❌ {datetime.now().strftime('%H:%M:%S')} - Connection failed")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
print(f"❌ {datetime.now().strftime('%H:%M:%S')} - Request timeout")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {datetime.now().strftime('%H:%M:%S')} - Error: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Monitor Talk2Me service health')
|
||||||
|
parser.add_argument('--url', default='http://localhost:5005', help='Service URL')
|
||||||
|
parser.add_argument('--detailed', action='store_true', help='Show detailed health info')
|
||||||
|
parser.add_argument('--interval', type=int, default=30, help='Check interval in seconds')
|
||||||
|
parser.add_argument('--once', action='store_true', help='Run once and exit')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"Monitoring {args.url}")
|
||||||
|
print("Press Ctrl+C to stop\n")
|
||||||
|
|
||||||
|
consecutive_failures = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
success = check_health(args.url, args.detailed)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
consecutive_failures += 1
|
||||||
|
if consecutive_failures >= 3:
|
||||||
|
print(f"\n⚠️ ALERT: Service has been down for {consecutive_failures} consecutive checks!")
|
||||||
|
else:
|
||||||
|
consecutive_failures = 0
|
||||||
|
|
||||||
|
if args.once:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(args.interval)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nMonitoring stopped.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
117
maintenance.sh
Executable file
117
maintenance.sh
Executable file
@ -0,0 +1,117 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Maintenance script for Talk2Me application
|
||||||
|
# This script helps manage temporary files and disk space
|
||||||
|
|
||||||
|
UPLOAD_FOLDER="${UPLOAD_FOLDER:-/tmp/talk2me_uploads}"
|
||||||
|
MAX_AGE_MINUTES=5
|
||||||
|
|
||||||
|
echo "Talk2Me Maintenance Script"
|
||||||
|
echo "========================="
|
||||||
|
|
||||||
|
# Function to check disk usage
|
||||||
|
check_disk_usage() {
|
||||||
|
echo -e "\nDisk Usage:"
|
||||||
|
df -h "$UPLOAD_FOLDER" 2>/dev/null || df -h /tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to show temp file stats
|
||||||
|
show_temp_stats() {
|
||||||
|
echo -e "\nTemporary File Statistics:"
|
||||||
|
if [ -d "$UPLOAD_FOLDER" ]; then
|
||||||
|
file_count=$(find "$UPLOAD_FOLDER" -type f 2>/dev/null | wc -l)
|
||||||
|
total_size=$(du -sh "$UPLOAD_FOLDER" 2>/dev/null | cut -f1)
|
||||||
|
echo " Upload folder: $UPLOAD_FOLDER"
|
||||||
|
echo " File count: $file_count"
|
||||||
|
echo " Total size: ${total_size:-0}"
|
||||||
|
|
||||||
|
if [ $file_count -gt 0 ]; then
|
||||||
|
echo -e "\n Oldest files:"
|
||||||
|
find "$UPLOAD_FOLDER" -type f -printf '%T+ %p\n' 2>/dev/null | sort | head -5
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Upload folder does not exist: $UPLOAD_FOLDER"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to clean old temp files
|
||||||
|
clean_temp_files() {
|
||||||
|
echo -e "\nCleaning temporary files older than $MAX_AGE_MINUTES minutes..."
|
||||||
|
if [ -d "$UPLOAD_FOLDER" ]; then
|
||||||
|
# Count files before cleanup
|
||||||
|
before_count=$(find "$UPLOAD_FOLDER" -type f 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
# Remove old files
|
||||||
|
find "$UPLOAD_FOLDER" -type f -mmin +$MAX_AGE_MINUTES -delete 2>/dev/null
|
||||||
|
|
||||||
|
# Count files after cleanup
|
||||||
|
after_count=$(find "$UPLOAD_FOLDER" -type f 2>/dev/null | wc -l)
|
||||||
|
removed=$((before_count - after_count))
|
||||||
|
|
||||||
|
echo " Removed $removed files"
|
||||||
|
else
|
||||||
|
echo " Upload folder does not exist: $UPLOAD_FOLDER"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to setup upload folder
|
||||||
|
setup_upload_folder() {
|
||||||
|
echo -e "\nSetting up upload folder..."
|
||||||
|
if [ ! -d "$UPLOAD_FOLDER" ]; then
|
||||||
|
mkdir -p "$UPLOAD_FOLDER"
|
||||||
|
chmod 755 "$UPLOAD_FOLDER"
|
||||||
|
echo " Created: $UPLOAD_FOLDER"
|
||||||
|
else
|
||||||
|
echo " Exists: $UPLOAD_FOLDER"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to monitor in real-time
|
||||||
|
monitor_realtime() {
|
||||||
|
echo -e "\nMonitoring temporary files (Press Ctrl+C to stop)..."
|
||||||
|
while true; do
|
||||||
|
clear
|
||||||
|
echo "Talk2Me File Monitor - $(date)"
|
||||||
|
echo "================================"
|
||||||
|
show_temp_stats
|
||||||
|
check_disk_usage
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main menu
|
||||||
|
case "${1:-help}" in
|
||||||
|
status)
|
||||||
|
show_temp_stats
|
||||||
|
check_disk_usage
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
clean_temp_files
|
||||||
|
show_temp_stats
|
||||||
|
;;
|
||||||
|
setup)
|
||||||
|
setup_upload_folder
|
||||||
|
;;
|
||||||
|
monitor)
|
||||||
|
monitor_realtime
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
setup_upload_folder
|
||||||
|
clean_temp_files
|
||||||
|
show_temp_stats
|
||||||
|
check_disk_usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {status|clean|setup|monitor|all}"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " status - Show current temp file statistics"
|
||||||
|
echo " clean - Clean old temporary files"
|
||||||
|
echo " setup - Create upload folder if needed"
|
||||||
|
echo " monitor - Real-time monitoring"
|
||||||
|
echo " all - Run setup, clean, and show status"
|
||||||
|
echo ""
|
||||||
|
echo "Environment Variables:"
|
||||||
|
echo " UPLOAD_FOLDER - Set custom upload folder (default: /tmp/talk2me_uploads)"
|
||||||
|
;;
|
||||||
|
esac
|
271
manage_secrets.py
Executable file
271
manage_secrets.py
Executable file
@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Secret management CLI tool for Talk2Me
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage_secrets.py list
|
||||||
|
python manage_secrets.py get <key>
|
||||||
|
python manage_secrets.py set <key> <value>
|
||||||
|
python manage_secrets.py rotate <key>
|
||||||
|
python manage_secrets.py delete <key>
|
||||||
|
python manage_secrets.py check-rotation
|
||||||
|
python manage_secrets.py verify
|
||||||
|
python manage_secrets.py migrate
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import click
|
||||||
|
import getpass
|
||||||
|
from datetime import datetime
|
||||||
|
from secrets_manager import get_secrets_manager, SecretsManager
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Initialize secrets manager
|
||||||
|
manager = get_secrets_manager()
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
"""Talk2Me Secrets Management Tool"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def list():
|
||||||
|
"""List all secrets (without values)"""
|
||||||
|
secrets = manager.list_secrets()
|
||||||
|
|
||||||
|
if not secrets:
|
||||||
|
click.echo("No secrets found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f"\nFound {len(secrets)} secrets:\n")
|
||||||
|
|
||||||
|
# Format as table
|
||||||
|
click.echo(f"{'Key':<30} {'Created':<20} {'Last Rotated':<20} {'Has Value'}")
|
||||||
|
click.echo("-" * 90)
|
||||||
|
|
||||||
|
for secret in secrets:
|
||||||
|
created = secret['created'][:10] if secret['created'] else 'Unknown'
|
||||||
|
rotated = secret['rotated'][:10] if secret['rotated'] else 'Never'
|
||||||
|
has_value = '✓' if secret['has_value'] else '✗'
|
||||||
|
|
||||||
|
click.echo(f"{secret['key']:<30} {created:<20} {rotated:<20} {has_value}")
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument('key')
|
||||||
|
def get(key):
|
||||||
|
"""Get a secret value (requires confirmation)"""
|
||||||
|
if not click.confirm(f"Are you sure you want to display the value of '{key}'?"):
|
||||||
|
return
|
||||||
|
|
||||||
|
value = manager.get(key)
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
click.echo(f"Secret '{key}' not found.")
|
||||||
|
else:
|
||||||
|
click.echo(f"\nSecret '{key}':")
|
||||||
|
click.echo(f"Value: {value}")
|
||||||
|
|
||||||
|
# Show metadata
|
||||||
|
secrets = manager.list_secrets()
|
||||||
|
for secret in secrets:
|
||||||
|
if secret['key'] == key:
|
||||||
|
if secret.get('metadata'):
|
||||||
|
click.echo(f"Metadata: {json.dumps(secret['metadata'], indent=2)}")
|
||||||
|
break
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument('key')
|
||||||
|
@click.option('--value', help='Secret value (will prompt if not provided)')
|
||||||
|
@click.option('--metadata', help='JSON metadata')
|
||||||
|
def set(key, value, metadata):
|
||||||
|
"""Set a secret value"""
|
||||||
|
if not value:
|
||||||
|
value = getpass.getpass(f"Enter value for '{key}': ")
|
||||||
|
confirm = getpass.getpass(f"Confirm value for '{key}': ")
|
||||||
|
|
||||||
|
if value != confirm:
|
||||||
|
click.echo("Values do not match. Aborted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse metadata if provided
|
||||||
|
metadata_dict = None
|
||||||
|
if metadata:
|
||||||
|
try:
|
||||||
|
metadata_dict = json.loads(metadata)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
click.echo("Invalid JSON metadata")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate the secret if validator exists
|
||||||
|
if not manager.validate(key, value):
|
||||||
|
click.echo(f"Validation failed for '{key}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
manager.set(key, value, metadata_dict, user='cli')
|
||||||
|
click.echo(f"Secret '{key}' set successfully.")
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument('key')
|
||||||
|
@click.option('--new-value', help='New secret value (will auto-generate if not provided)')
|
||||||
|
def rotate(key):
|
||||||
|
"""Rotate a secret"""
|
||||||
|
try:
|
||||||
|
if not click.confirm(f"Are you sure you want to rotate '{key}'?"):
|
||||||
|
return
|
||||||
|
|
||||||
|
old_value, new_value = manager.rotate(key, new_value, user='cli')
|
||||||
|
|
||||||
|
click.echo(f"\nSecret '{key}' rotated successfully.")
|
||||||
|
click.echo(f"New value: {new_value}")
|
||||||
|
|
||||||
|
if click.confirm("Do you want to see the old value?"):
|
||||||
|
click.echo(f"Old value: {old_value}")
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
click.echo(f"Secret '{key}' not found.")
|
||||||
|
except ValueError as e:
|
||||||
|
click.echo(f"Error: {e}")
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument('key')
|
||||||
|
def delete(key):
|
||||||
|
"""Delete a secret"""
|
||||||
|
if not click.confirm(f"Are you sure you want to delete '{key}'? This cannot be undone."):
|
||||||
|
return
|
||||||
|
|
||||||
|
if manager.delete(key, user='cli'):
|
||||||
|
click.echo(f"Secret '{key}' deleted successfully.")
|
||||||
|
else:
|
||||||
|
click.echo(f"Secret '{key}' not found.")
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def check_rotation():
|
||||||
|
"""Check which secrets need rotation"""
|
||||||
|
needs_rotation = manager.check_rotation_needed()
|
||||||
|
|
||||||
|
if not needs_rotation:
|
||||||
|
click.echo("No secrets need rotation.")
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f"\n{len(needs_rotation)} secrets need rotation:")
|
||||||
|
for key in needs_rotation:
|
||||||
|
click.echo(f" - {key}")
|
||||||
|
|
||||||
|
if click.confirm("\nDo you want to rotate all of them now?"):
|
||||||
|
for key in needs_rotation:
|
||||||
|
try:
|
||||||
|
old_value, new_value = manager.rotate(key, user='cli')
|
||||||
|
click.echo(f"✓ Rotated {key}")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"✗ Failed to rotate {key}: {e}")
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def verify():
|
||||||
|
"""Verify integrity of all secrets"""
|
||||||
|
click.echo("Verifying secrets integrity...")
|
||||||
|
|
||||||
|
if manager.verify_integrity():
|
||||||
|
click.echo("✓ All secrets passed integrity check")
|
||||||
|
else:
|
||||||
|
click.echo("✗ Integrity check failed!")
|
||||||
|
click.echo("Some secrets may be corrupted. Check logs for details.")
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def migrate():
|
||||||
|
"""Migrate secrets from environment variables"""
|
||||||
|
click.echo("Migrating secrets from environment variables...")
|
||||||
|
|
||||||
|
# List of known secrets to migrate
|
||||||
|
secrets_to_migrate = [
|
||||||
|
('TTS_API_KEY', 'TTS API Key'),
|
||||||
|
('SECRET_KEY', 'Flask Secret Key'),
|
||||||
|
('ADMIN_TOKEN', 'Admin Token'),
|
||||||
|
('DATABASE_URL', 'Database URL'),
|
||||||
|
('REDIS_URL', 'Redis URL'),
|
||||||
|
]
|
||||||
|
|
||||||
|
migrated = 0
|
||||||
|
|
||||||
|
for env_key, description in secrets_to_migrate:
|
||||||
|
value = os.environ.get(env_key)
|
||||||
|
if value and value != manager.get(env_key):
|
||||||
|
if click.confirm(f"Migrate {description} from environment?"):
|
||||||
|
manager.set(env_key, value, {'migrated_from': 'environment'}, user='migration')
|
||||||
|
click.echo(f"✓ Migrated {env_key}")
|
||||||
|
migrated += 1
|
||||||
|
|
||||||
|
click.echo(f"\nMigrated {migrated} secrets.")
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument('key')
|
||||||
|
@click.argument('days', type=int)
|
||||||
|
def schedule_rotation(key, days):
|
||||||
|
"""Schedule automatic rotation for a secret"""
|
||||||
|
manager.schedule_rotation(key, days)
|
||||||
|
click.echo(f"Scheduled rotation for '{key}' every {days} days.")
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument('key', required=False)
|
||||||
|
@click.option('--limit', default=20, help='Number of entries to show')
|
||||||
|
def audit(key, limit):
|
||||||
|
"""Show audit log"""
|
||||||
|
logs = manager.get_audit_log(key, limit)
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
click.echo("No audit log entries found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f"\nShowing last {len(logs)} audit log entries:\n")
|
||||||
|
|
||||||
|
for entry in logs:
|
||||||
|
timestamp = entry['timestamp'][:19] # Trim microseconds
|
||||||
|
action = entry['action'].ljust(15)
|
||||||
|
key_str = entry['key'].ljust(20)
|
||||||
|
user = entry['user']
|
||||||
|
|
||||||
|
click.echo(f"{timestamp} | {action} | {key_str} | {user}")
|
||||||
|
|
||||||
|
if entry.get('details'):
|
||||||
|
click.echo(f"{'':>20} Details: {json.dumps(entry['details'])}")
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def init():
|
||||||
|
"""Initialize secrets configuration"""
|
||||||
|
click.echo("Initializing Talk2Me secrets configuration...")
|
||||||
|
|
||||||
|
# Check if already initialized
|
||||||
|
if os.path.exists('.secrets.json'):
|
||||||
|
if not click.confirm(".secrets.json already exists. Overwrite?"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate initial secrets
|
||||||
|
import secrets as py_secrets
|
||||||
|
|
||||||
|
initial_secrets = {
|
||||||
|
'FLASK_SECRET_KEY': py_secrets.token_hex(32),
|
||||||
|
'ADMIN_TOKEN': py_secrets.token_urlsafe(32),
|
||||||
|
}
|
||||||
|
|
||||||
|
click.echo("\nGenerating initial secrets...")
|
||||||
|
|
||||||
|
for key, value in initial_secrets.items():
|
||||||
|
manager.set(key, value, {'generated': True}, user='init')
|
||||||
|
click.echo(f"✓ Generated {key}")
|
||||||
|
|
||||||
|
# Prompt for required secrets
|
||||||
|
click.echo("\nPlease provide the following secrets:")
|
||||||
|
|
||||||
|
tts_api_key = getpass.getpass("TTS API Key (press Enter to skip): ")
|
||||||
|
if tts_api_key:
|
||||||
|
manager.set('TTS_API_KEY', tts_api_key, user='init')
|
||||||
|
click.echo("✓ Set TTS_API_KEY")
|
||||||
|
|
||||||
|
click.echo("\nSecrets initialized successfully!")
|
||||||
|
click.echo("\nIMPORTANT:")
|
||||||
|
click.echo("1. Keep .secrets.json secure and never commit it to version control")
|
||||||
|
click.echo("2. Back up your master key from .master_key")
|
||||||
|
click.echo("3. Set appropriate file permissions (owner read/write only)")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli()
|
403
memory_manager.py
Normal file
403
memory_manager.py
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
# Memory management system to prevent leaks and monitor usage
|
||||||
|
import gc
|
||||||
|
import os
|
||||||
|
import psutil
|
||||||
|
import torch
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Dict, Optional, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
import weakref
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
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
|
||||||
|
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 {}
|
||||||
|
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.whisper_model = None
|
||||||
|
self.model_reload_count = 0
|
||||||
|
self.last_model_reload = time.time()
|
||||||
|
|
||||||
|
if app:
|
||||||
|
self.init_app(app)
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
"""Initialize memory management for Flask app"""
|
||||||
|
self.app = app
|
||||||
|
app.memory_manager = self
|
||||||
|
|
||||||
|
# Start monitoring thread
|
||||||
|
self._start_monitoring()
|
||||||
|
|
||||||
|
# Register cleanup on shutdown
|
||||||
|
import atexit
|
||||||
|
atexit.register(self.shutdown)
|
||||||
|
|
||||||
|
logger.info("Memory manager initialized")
|
||||||
|
|
||||||
|
def set_whisper_model(self, model):
|
||||||
|
"""Register the Whisper model for 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")
|
||||||
|
|
||||||
|
while not self._shutdown:
|
||||||
|
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")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in memory monitoring: {e}")
|
||||||
|
|
||||||
|
time.sleep(self.cleanup_interval)
|
||||||
|
|
||||||
|
def _should_cleanup(self, stats: MemoryStats) -> bool:
|
||||||
|
"""Determine if memory cleanup is needed"""
|
||||||
|
# Check process memory
|
||||||
|
if stats.process_memory_mb > self.memory_threshold_mb:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check system memory
|
||||||
|
if stats.system_memory_percent > 85:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check GPU memory
|
||||||
|
if stats.gpu_memory_mb > self.gpu_memory_threshold_mb:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_memory_stats(self) -> MemoryStats:
|
||||||
|
"""Get current memory statistics"""
|
||||||
|
stats = MemoryStats()
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
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
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
|
||||||
|
def register_cleanup_callback(self, callback: Callable):
|
||||||
|
"""Register a callback to be called during cleanup"""
|
||||||
|
self._cleanup_callbacks.append(callback)
|
||||||
|
|
||||||
|
def register_resource(self, resource, name: str = None):
|
||||||
|
"""Register a resource for tracking"""
|
||||||
|
if name:
|
||||||
|
self._resource_registry[name] = resource
|
||||||
|
|
||||||
|
def release_resource(self, name: str):
|
||||||
|
"""Release a tracked resource"""
|
||||||
|
if name in self._resource_registry:
|
||||||
|
del self._resource_registry[name]
|
||||||
|
|
||||||
|
def get_metrics(self) -> Dict:
|
||||||
|
"""Get memory management metrics"""
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
'thresholds': {
|
||||||
|
'memory_mb': self.memory_threshold_mb,
|
||||||
|
'gpu_mb': self.gpu_memory_threshold_mb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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):
|
||||||
|
self.memory_manager = memory_manager
|
||||||
|
self.name = name or f"audio_{int(time.time() * 1000)}"
|
||||||
|
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()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
# Clean up temp files
|
||||||
|
for filepath in self.temp_files:
|
||||||
|
try:
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to remove temp file {filepath}: {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:
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
def add_temp_file(self, filepath: str):
|
||||||
|
"""Register a temporary file for cleanup"""
|
||||||
|
self.temp_files.append(filepath)
|
||||||
|
|
||||||
|
# Utility functions
|
||||||
|
def with_memory_management(func):
|
||||||
|
"""Decorator to add memory management to functions"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
108
nginx.conf
Normal file
108
nginx.conf
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
upstream talk2me {
|
||||||
|
server talk2me:5005 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Redirect to HTTPS in production
|
||||||
|
# return 301 https://$server_name$request_uri;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; media-src 'self';" always;
|
||||||
|
|
||||||
|
# File upload limits
|
||||||
|
client_max_body_size 50M;
|
||||||
|
client_body_buffer_size 1M;
|
||||||
|
client_body_timeout 120s;
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_connect_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
send_timeout 120s;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
location /static {
|
||||||
|
alias /app/static;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
|
||||||
|
# Gzip static files
|
||||||
|
gzip_static on;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Service worker
|
||||||
|
location /service-worker.js {
|
||||||
|
proxy_pass http://talk2me;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket support for future features
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://talk2me;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
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;
|
||||||
|
|
||||||
|
# WebSocket timeouts
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check (don't log)
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://talk2me/health;
|
||||||
|
access_log off;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main application
|
||||||
|
location / {
|
||||||
|
proxy_pass http://talk2me;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
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 $server_name;
|
||||||
|
|
||||||
|
# Don't buffer responses
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS configuration (uncomment for production)
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# server_name your-domain.com;
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||||
|
# ssl_prefer_server_ciphers off;
|
||||||
|
#
|
||||||
|
# # Include all location blocks from above
|
||||||
|
# }
|
48
package-lock.json
generated
Normal file
48
package-lock.json
generated
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "talk2me",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "talk2me",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.17.57",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz",
|
||||||
|
"integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.19.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.19.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||||
|
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
package.json
Normal file
26
package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "talk2me",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Real-time voice translation web application",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"watch": "tsc --watch",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"clean": "rm -rf static/js/dist",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"translation",
|
||||||
|
"voice",
|
||||||
|
"pwa",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
},
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
408
rate_limiter.py
Normal file
408
rate_limiter.py
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
# Rate limiting implementation for Flask
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from threading import Lock
|
||||||
|
from flask import request, jsonify, g
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
"""
|
||||||
|
Token bucket rate limiter with sliding window and multiple strategies
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.buckets = defaultdict(lambda: {
|
||||||
|
'tokens': 0,
|
||||||
|
'last_update': time.time(),
|
||||||
|
'requests': deque(maxlen=1000) # Track last 1000 requests
|
||||||
|
})
|
||||||
|
self.lock = Lock()
|
||||||
|
|
||||||
|
# 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, # 1 token per 6 seconds
|
||||||
|
'max_request_size': 10 * 1024 * 1024 # 10MB
|
||||||
|
},
|
||||||
|
'/translate': {
|
||||||
|
'requests_per_minute': 20,
|
||||||
|
'requests_per_hour': 300,
|
||||||
|
'burst_size': 5,
|
||||||
|
'token_refresh_rate': 0.333, # 1 token per 3 seconds
|
||||||
|
'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, # 1 token per 4 seconds
|
||||||
|
'max_request_size': 50 * 1024 # 50KB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# IP-based blocking
|
||||||
|
self.blocked_ips = set()
|
||||||
|
self.temp_blocked_ips = {} # IP -> unblock_time
|
||||||
|
|
||||||
|
# Global limits
|
||||||
|
self.global_limits = {
|
||||||
|
'total_requests_per_minute': 1000,
|
||||||
|
'total_requests_per_hour': 10000,
|
||||||
|
'concurrent_requests': 50
|
||||||
|
}
|
||||||
|
self.global_requests = deque(maxlen=10000)
|
||||||
|
self.concurrent_requests = 0
|
||||||
|
|
||||||
|
def get_client_id(self, request):
|
||||||
|
"""Get unique client identifier"""
|
||||||
|
# Use IP address + user agent for better identification
|
||||||
|
ip = request.remote_addr or 'unknown'
|
||||||
|
user_agent = request.headers.get('User-Agent', '')
|
||||||
|
|
||||||
|
# Handle proxied requests
|
||||||
|
forwarded_for = request.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):
|
||||||
|
"""Get rate limits for endpoint"""
|
||||||
|
return self.endpoint_limits.get(endpoint, self.default_limits)
|
||||||
|
|
||||||
|
def is_ip_blocked(self, ip):
|
||||||
|
"""Check if IP is blocked"""
|
||||||
|
# Check permanent blocks
|
||||||
|
if ip in self.blocked_ips:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check temporary blocks
|
||||||
|
if ip in self.temp_blocked_ips:
|
||||||
|
if time.time() < self.temp_blocked_ips[ip]:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# Unblock if time expired
|
||||||
|
del self.temp_blocked_ips[ip]
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def block_ip_temporarily(self, ip, duration=3600):
|
||||||
|
"""Block IP temporarily (default 1 hour)"""
|
||||||
|
self.temp_blocked_ips[ip] = time.time() + duration
|
||||||
|
logger.warning(f"IP {ip} temporarily blocked for {duration} seconds")
|
||||||
|
|
||||||
|
def check_global_limits(self):
|
||||||
|
"""Check global rate limits"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Clean old requests
|
||||||
|
minute_ago = now - 60
|
||||||
|
hour_ago = now - 3600
|
||||||
|
|
||||||
|
self.global_requests = deque(
|
||||||
|
(t for t in self.global_requests if t > hour_ago),
|
||||||
|
maxlen=10000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count requests
|
||||||
|
requests_last_minute = sum(1 for t in self.global_requests if t > minute_ago)
|
||||||
|
requests_last_hour = len(self.global_requests)
|
||||||
|
|
||||||
|
# Check limits
|
||||||
|
if requests_last_minute >= self.global_limits['total_requests_per_minute']:
|
||||||
|
return False, "Global rate limit exceeded (per minute)"
|
||||||
|
|
||||||
|
if requests_last_hour >= self.global_limits['total_requests_per_hour']:
|
||||||
|
return False, "Global rate limit exceeded (per hour)"
|
||||||
|
|
||||||
|
if self.concurrent_requests >= self.global_limits['concurrent_requests']:
|
||||||
|
return False, "Too many concurrent requests"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def check_rate_limit(self, client_id, endpoint, request_size=0):
|
||||||
|
"""Check if request should be allowed"""
|
||||||
|
with self.lock:
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Get or create bucket
|
||||||
|
bucket = self.buckets[client_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 window
|
||||||
|
minute_ago = now - 60
|
||||||
|
hour_ago = now - 3600
|
||||||
|
bucket['requests'] = deque(
|
||||||
|
(t for t in bucket['requests'] if t > hour_ago),
|
||||||
|
maxlen=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count requests in windows
|
||||||
|
requests_last_minute = sum(1 for t in bucket['requests'] if t > minute_ago)
|
||||||
|
requests_last_hour = len(bucket['requests'])
|
||||||
|
|
||||||
|
# Check sliding window 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Request allowed - consume token and record
|
||||||
|
bucket['tokens'] -= 1
|
||||||
|
bucket['requests'].append(now)
|
||||||
|
self.global_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
|
||||||
|
|
||||||
|
return True, None, {
|
||||||
|
'limit': limits['requests_per_minute'],
|
||||||
|
'remaining': remaining_minute,
|
||||||
|
'reset': int(minute_ago + 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
def increment_concurrent(self):
|
||||||
|
"""Increment concurrent request counter"""
|
||||||
|
with self.lock:
|
||||||
|
self.concurrent_requests += 1
|
||||||
|
|
||||||
|
def decrement_concurrent(self):
|
||||||
|
"""Decrement concurrent request counter"""
|
||||||
|
with self.lock:
|
||||||
|
self.concurrent_requests = max(0, self.concurrent_requests - 1)
|
||||||
|
|
||||||
|
def get_client_stats(self, client_id):
|
||||||
|
"""Get statistics for a client"""
|
||||||
|
with self.lock:
|
||||||
|
if client_id not in self.buckets:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bucket = self.buckets[client_id]
|
||||||
|
now = time.time()
|
||||||
|
minute_ago = now - 60
|
||||||
|
hour_ago = now - 3600
|
||||||
|
|
||||||
|
requests_last_minute = sum(1 for t in bucket['requests'] if t > minute_ago)
|
||||||
|
requests_last_hour = len([t for t in bucket['requests'] if t > hour_ago])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'requests_last_minute': requests_last_minute,
|
||||||
|
'requests_last_hour': requests_last_hour,
|
||||||
|
'tokens_available': bucket['tokens'],
|
||||||
|
'last_request': bucket['last_update']
|
||||||
|
}
|
||||||
|
|
||||||
|
def cleanup_old_buckets(self, max_age=86400):
|
||||||
|
"""Clean up old unused buckets (default 24 hours)"""
|
||||||
|
with self.lock:
|
||||||
|
now = time.time()
|
||||||
|
to_remove = []
|
||||||
|
|
||||||
|
for client_id, bucket in self.buckets.items():
|
||||||
|
if now - bucket['last_update'] > max_age:
|
||||||
|
to_remove.append(client_id)
|
||||||
|
|
||||||
|
for client_id in to_remove:
|
||||||
|
del self.buckets[client_id]
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
logger.info(f"Cleaned up {len(to_remove)} old rate limit buckets")
|
||||||
|
|
||||||
|
# Global rate limiter instance
|
||||||
|
rate_limiter = RateLimiter()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.route('/api/endpoint')
|
||||||
|
@rate_limit(requests_per_minute=10, check_size=True)
|
||||||
|
def endpoint():
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
"""
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
# 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
|
||||||
|
|
||||||
|
def cleanup_rate_limiter():
|
||||||
|
"""Cleanup function to be called periodically"""
|
||||||
|
rate_limiter.cleanup_old_buckets()
|
||||||
|
|
||||||
|
# IP whitelist/blacklist management
|
||||||
|
class IPFilter:
|
||||||
|
def __init__(self):
|
||||||
|
self.whitelist = set()
|
||||||
|
self.blacklist = set()
|
||||||
|
|
||||||
|
def add_to_whitelist(self, ip):
|
||||||
|
self.whitelist.add(ip)
|
||||||
|
self.blacklist.discard(ip)
|
||||||
|
|
||||||
|
def add_to_blacklist(self, ip):
|
||||||
|
self.blacklist.add(ip)
|
||||||
|
self.whitelist.discard(ip)
|
||||||
|
|
||||||
|
def is_allowed(self, ip):
|
||||||
|
if ip in self.blacklist:
|
||||||
|
return False
|
||||||
|
if self.whitelist and ip not in self.whitelist:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
ip_filter = IPFilter()
|
||||||
|
|
||||||
|
def ip_filter_check():
|
||||||
|
"""Middleware to check IP filtering"""
|
||||||
|
ip = request.remote_addr
|
||||||
|
if not ip_filter.is_allowed(ip):
|
||||||
|
return jsonify({'error': 'Access denied'}), 403
|
302
request_size_limiter.py
Normal file
302
request_size_limiter.py
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
# Request size limiting middleware for preventing memory exhaustion
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
from flask import request, jsonify, current_app
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default size limits (in bytes)
|
||||||
|
DEFAULT_LIMITS = {
|
||||||
|
'max_content_length': 50 * 1024 * 1024, # 50MB global max
|
||||||
|
'max_audio_size': 25 * 1024 * 1024, # 25MB for audio files
|
||||||
|
'max_json_size': 1 * 1024 * 1024, # 1MB for JSON payloads
|
||||||
|
'max_image_size': 10 * 1024 * 1024, # 10MB for images
|
||||||
|
'max_chunk_size': 1 * 1024 * 1024, # 1MB chunks for streaming
|
||||||
|
}
|
||||||
|
|
||||||
|
# File extension to MIME type mapping
|
||||||
|
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.webm', '.m4a', '.flac', '.aac'}
|
||||||
|
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
|
||||||
|
|
||||||
|
class RequestSizeLimiter:
|
||||||
|
"""
|
||||||
|
Middleware to enforce request size limits and prevent memory exhaustion
|
||||||
|
"""
|
||||||
|
def __init__(self, app=None, config=None):
|
||||||
|
self.config = config or {}
|
||||||
|
self.limits = {**DEFAULT_LIMITS, **self.config}
|
||||||
|
|
||||||
|
if app:
|
||||||
|
self.init_app(app)
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
"""Initialize the Flask application with size limiting"""
|
||||||
|
# Set Flask's MAX_CONTENT_LENGTH
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = self.limits['max_content_length']
|
||||||
|
|
||||||
|
# Store limiter in app
|
||||||
|
app.request_size_limiter = self
|
||||||
|
|
||||||
|
# Add before_request handler
|
||||||
|
app.before_request(self.check_request_size)
|
||||||
|
|
||||||
|
# Add error handler for 413 Request Entity Too Large
|
||||||
|
app.register_error_handler(413, self.handle_413)
|
||||||
|
|
||||||
|
logger.info(f"Request size limiter initialized with max content length: {self.limits['max_content_length'] / 1024 / 1024:.1f}MB")
|
||||||
|
|
||||||
|
def check_request_size(self):
|
||||||
|
"""Check request size before processing"""
|
||||||
|
# Skip size check for GET, HEAD, OPTIONS
|
||||||
|
if request.method in ('GET', 'HEAD', 'OPTIONS'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get content length
|
||||||
|
content_length = request.content_length
|
||||||
|
|
||||||
|
if content_length is None:
|
||||||
|
# No content-length header, check for chunked encoding
|
||||||
|
if request.headers.get('Transfer-Encoding') == 'chunked':
|
||||||
|
logger.warning(f"Chunked request from {request.remote_addr} to {request.endpoint}")
|
||||||
|
# For chunked requests, we'll need to monitor the stream
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# No content, allow it
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check against global limit
|
||||||
|
if content_length > self.limits['max_content_length']:
|
||||||
|
logger.warning(f"Request from {request.remote_addr} exceeds global limit: {content_length} bytes")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Request too large',
|
||||||
|
'max_size': self.limits['max_content_length'],
|
||||||
|
'your_size': content_length
|
||||||
|
}), 413
|
||||||
|
|
||||||
|
# Check endpoint-specific limits
|
||||||
|
endpoint = request.endpoint
|
||||||
|
if endpoint:
|
||||||
|
endpoint_limit = self.get_endpoint_limit(endpoint)
|
||||||
|
if endpoint_limit and content_length > endpoint_limit:
|
||||||
|
logger.warning(f"Request from {request.remote_addr} to {endpoint} exceeds endpoint limit: {content_length} bytes")
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Request too large for {endpoint}',
|
||||||
|
'max_size': endpoint_limit,
|
||||||
|
'your_size': content_length
|
||||||
|
}), 413
|
||||||
|
|
||||||
|
# Check file-specific limits
|
||||||
|
if request.files:
|
||||||
|
for file_key, file_obj in request.files.items():
|
||||||
|
# Check file size
|
||||||
|
file_obj.seek(0, os.SEEK_END)
|
||||||
|
file_size = file_obj.tell()
|
||||||
|
file_obj.seek(0) # Reset position
|
||||||
|
|
||||||
|
# Determine file type
|
||||||
|
filename = file_obj.filename or ''
|
||||||
|
file_ext = os.path.splitext(filename)[1].lower()
|
||||||
|
|
||||||
|
# Apply type-specific limits
|
||||||
|
if file_ext in AUDIO_EXTENSIONS:
|
||||||
|
max_size = self.limits.get('max_audio_size', self.limits['max_content_length'])
|
||||||
|
if file_size > max_size:
|
||||||
|
logger.warning(f"Audio file from {request.remote_addr} exceeds limit: {file_size} bytes")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Audio file too large',
|
||||||
|
'max_size': max_size,
|
||||||
|
'your_size': file_size,
|
||||||
|
'max_size_mb': round(max_size / 1024 / 1024, 1)
|
||||||
|
}), 413
|
||||||
|
|
||||||
|
elif file_ext in IMAGE_EXTENSIONS:
|
||||||
|
max_size = self.limits.get('max_image_size', self.limits['max_content_length'])
|
||||||
|
if file_size > max_size:
|
||||||
|
logger.warning(f"Image file from {request.remote_addr} exceeds limit: {file_size} bytes")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Image file too large',
|
||||||
|
'max_size': max_size,
|
||||||
|
'your_size': file_size,
|
||||||
|
'max_size_mb': round(max_size / 1024 / 1024, 1)
|
||||||
|
}), 413
|
||||||
|
|
||||||
|
# Check JSON payload size
|
||||||
|
if request.is_json:
|
||||||
|
try:
|
||||||
|
# Get raw data size
|
||||||
|
data_size = len(request.get_data())
|
||||||
|
max_json = self.limits.get('max_json_size', self.limits['max_content_length'])
|
||||||
|
|
||||||
|
if data_size > max_json:
|
||||||
|
logger.warning(f"JSON payload from {request.remote_addr} exceeds limit: {data_size} bytes")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'JSON payload too large',
|
||||||
|
'max_size': max_json,
|
||||||
|
'your_size': data_size,
|
||||||
|
'max_size_kb': round(max_json / 1024, 1)
|
||||||
|
}), 413
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking JSON size: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_endpoint_limit(self, endpoint):
|
||||||
|
"""Get size limit for specific endpoint"""
|
||||||
|
endpoint_limits = {
|
||||||
|
'transcribe': self.limits.get('max_audio_size', 25 * 1024 * 1024),
|
||||||
|
'speak': self.limits.get('max_json_size', 1 * 1024 * 1024),
|
||||||
|
'translate': self.limits.get('max_json_size', 1 * 1024 * 1024),
|
||||||
|
'translate_stream': self.limits.get('max_json_size', 1 * 1024 * 1024),
|
||||||
|
}
|
||||||
|
return endpoint_limits.get(endpoint)
|
||||||
|
|
||||||
|
def handle_413(self, error):
|
||||||
|
"""Handle 413 Request Entity Too Large errors"""
|
||||||
|
logger.warning(f"413 error from {request.remote_addr}: {error}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Request entity too large',
|
||||||
|
'message': 'The request payload is too large. Please reduce the size and try again.',
|
||||||
|
'max_size': self.limits['max_content_length'],
|
||||||
|
'max_size_mb': round(self.limits['max_content_length'] / 1024 / 1024, 1)
|
||||||
|
}), 413
|
||||||
|
|
||||||
|
def update_limits(self, **kwargs):
|
||||||
|
"""Update size limits dynamically"""
|
||||||
|
old_limits = self.limits.copy()
|
||||||
|
self.limits.update(kwargs)
|
||||||
|
|
||||||
|
# Update Flask's MAX_CONTENT_LENGTH if changed
|
||||||
|
if 'max_content_length' in kwargs and current_app:
|
||||||
|
current_app.config['MAX_CONTENT_LENGTH'] = kwargs['max_content_length']
|
||||||
|
|
||||||
|
logger.info(f"Updated size limits: {kwargs}")
|
||||||
|
return old_limits
|
||||||
|
|
||||||
|
def limit_request_size(**limit_kwargs):
|
||||||
|
"""
|
||||||
|
Decorator to apply custom size limits to specific routes
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.route('/upload')
|
||||||
|
@limit_request_size(max_size=10*1024*1024) # 10MB limit
|
||||||
|
def upload():
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# Check content length
|
||||||
|
content_length = request.content_length
|
||||||
|
max_size = limit_kwargs.get('max_size', DEFAULT_LIMITS['max_content_length'])
|
||||||
|
|
||||||
|
if content_length and content_length > max_size:
|
||||||
|
logger.warning(f"Request to {request.endpoint} exceeds custom limit: {content_length} bytes")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Request too large',
|
||||||
|
'max_size': max_size,
|
||||||
|
'your_size': content_length,
|
||||||
|
'max_size_mb': round(max_size / 1024 / 1024, 1)
|
||||||
|
}), 413
|
||||||
|
|
||||||
|
# Check specific file types if specified
|
||||||
|
if 'max_audio_size' in limit_kwargs and request.files:
|
||||||
|
for file_obj in request.files.values():
|
||||||
|
if file_obj.filename:
|
||||||
|
ext = os.path.splitext(file_obj.filename)[1].lower()
|
||||||
|
if ext in AUDIO_EXTENSIONS:
|
||||||
|
file_obj.seek(0, os.SEEK_END)
|
||||||
|
file_size = file_obj.tell()
|
||||||
|
file_obj.seek(0)
|
||||||
|
|
||||||
|
if file_size > limit_kwargs['max_audio_size']:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Audio file too large',
|
||||||
|
'max_size': limit_kwargs['max_audio_size'],
|
||||||
|
'your_size': file_size,
|
||||||
|
'max_size_mb': round(limit_kwargs['max_audio_size'] / 1024 / 1024, 1)
|
||||||
|
}), 413
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
class StreamSizeLimiter:
|
||||||
|
"""
|
||||||
|
Helper class to limit streaming request sizes
|
||||||
|
"""
|
||||||
|
def __init__(self, stream, max_size):
|
||||||
|
self.stream = stream
|
||||||
|
self.max_size = max_size
|
||||||
|
self.bytes_read = 0
|
||||||
|
|
||||||
|
def read(self, size=-1):
|
||||||
|
"""Read from stream with size limit enforcement"""
|
||||||
|
if size == -1:
|
||||||
|
# Read all remaining, but respect limit
|
||||||
|
size = self.max_size - self.bytes_read
|
||||||
|
|
||||||
|
# Check if we would exceed limit
|
||||||
|
if self.bytes_read + size > self.max_size:
|
||||||
|
raise ValueError(f"Stream size exceeds limit of {self.max_size} bytes")
|
||||||
|
|
||||||
|
data = self.stream.read(size)
|
||||||
|
self.bytes_read += len(data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def readline(self, size=-1):
|
||||||
|
"""Read line from stream with size limit enforcement"""
|
||||||
|
if size == -1:
|
||||||
|
size = self.max_size - self.bytes_read
|
||||||
|
|
||||||
|
if self.bytes_read + size > self.max_size:
|
||||||
|
raise ValueError(f"Stream size exceeds limit of {self.max_size} bytes")
|
||||||
|
|
||||||
|
line = self.stream.readline(size)
|
||||||
|
self.bytes_read += len(line)
|
||||||
|
|
||||||
|
return line
|
||||||
|
|
||||||
|
# Utility functions
|
||||||
|
def get_request_size():
|
||||||
|
"""Get the size of the current request"""
|
||||||
|
if request.content_length:
|
||||||
|
return request.content_length
|
||||||
|
|
||||||
|
# For chunked requests, read and measure
|
||||||
|
try:
|
||||||
|
data = request.get_data()
|
||||||
|
return len(data)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def format_size(size_bytes):
|
||||||
|
"""Format size in human-readable format"""
|
||||||
|
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||||
|
if size_bytes < 1024.0:
|
||||||
|
return f"{size_bytes:.1f} {unit}"
|
||||||
|
size_bytes /= 1024.0
|
||||||
|
return f"{size_bytes:.1f} TB"
|
||||||
|
|
||||||
|
# Configuration helper
|
||||||
|
def configure_size_limits(app, **kwargs):
|
||||||
|
"""
|
||||||
|
Configure size limits for the application
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Flask application
|
||||||
|
max_content_length: Global maximum request size
|
||||||
|
max_audio_size: Maximum audio file size
|
||||||
|
max_json_size: Maximum JSON payload size
|
||||||
|
max_image_size: Maximum image file size
|
||||||
|
"""
|
||||||
|
config = {
|
||||||
|
'max_content_length': kwargs.get('max_content_length', DEFAULT_LIMITS['max_content_length']),
|
||||||
|
'max_audio_size': kwargs.get('max_audio_size', DEFAULT_LIMITS['max_audio_size']),
|
||||||
|
'max_json_size': kwargs.get('max_json_size', DEFAULT_LIMITS['max_json_size']),
|
||||||
|
'max_image_size': kwargs.get('max_image_size', DEFAULT_LIMITS['max_image_size']),
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter = RequestSizeLimiter(app, config)
|
||||||
|
return limiter
|
27
requirements-prod.txt
Normal file
27
requirements-prod.txt
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Production requirements for Talk2Me
|
||||||
|
# Includes base requirements plus production WSGI server
|
||||||
|
|
||||||
|
# Include base requirements
|
||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
# Production WSGI server
|
||||||
|
gunicorn==21.2.0
|
||||||
|
|
||||||
|
# Async workers (optional, for better concurrency)
|
||||||
|
gevent==23.9.1
|
||||||
|
greenlet==3.0.1
|
||||||
|
|
||||||
|
# Production monitoring
|
||||||
|
prometheus-client==0.19.0
|
||||||
|
|
||||||
|
# Production caching (optional)
|
||||||
|
redis==5.0.1
|
||||||
|
hiredis==2.3.2
|
||||||
|
|
||||||
|
# Database for production (optional, for session storage)
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
SQLAlchemy==2.0.23
|
||||||
|
|
||||||
|
# Additional production utilities
|
||||||
|
python-json-logger==2.0.7 # JSON logging
|
||||||
|
sentry-sdk[flask]==1.39.1 # Error tracking (optional)
|
@ -1,5 +1,12 @@
|
|||||||
flask
|
flask
|
||||||
|
flask-cors
|
||||||
requests
|
requests
|
||||||
openai-whisper
|
openai-whisper
|
||||||
torch
|
torch
|
||||||
ollama
|
ollama
|
||||||
|
pywebpush
|
||||||
|
cryptography
|
||||||
|
python-dotenv
|
||||||
|
click
|
||||||
|
colorlog
|
||||||
|
psutil
|
||||||
|
411
secrets_manager.py
Normal file
411
secrets_manager.py
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
# Secrets management system for secure configuration
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional, List
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from functools import lru_cache
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SecretsManager:
|
||||||
|
"""
|
||||||
|
Secure secrets management with encryption, rotation, and audit logging
|
||||||
|
"""
|
||||||
|
def __init__(self, config_file: str = None):
|
||||||
|
self.config_file = config_file or os.environ.get('SECRETS_CONFIG', '.secrets.json')
|
||||||
|
self.lock = Lock()
|
||||||
|
self._secrets_cache = {}
|
||||||
|
self._encryption_key = None
|
||||||
|
self._master_key = None
|
||||||
|
self._audit_log = []
|
||||||
|
self._rotation_schedule = {}
|
||||||
|
self._validators = {}
|
||||||
|
|
||||||
|
# Initialize encryption
|
||||||
|
self._init_encryption()
|
||||||
|
|
||||||
|
# Load secrets
|
||||||
|
self._load_secrets()
|
||||||
|
|
||||||
|
def _init_encryption(self):
|
||||||
|
"""Initialize encryption key from environment or generate new one"""
|
||||||
|
# Try to get master key from environment
|
||||||
|
master_key = os.environ.get('MASTER_KEY')
|
||||||
|
|
||||||
|
if not master_key:
|
||||||
|
# Try to load from secure file
|
||||||
|
key_file = os.environ.get('MASTER_KEY_FILE', '.master_key')
|
||||||
|
if os.path.exists(key_file):
|
||||||
|
try:
|
||||||
|
with open(key_file, 'rb') as f:
|
||||||
|
master_key = f.read().decode('utf-8').strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load master key from file: {e}")
|
||||||
|
|
||||||
|
if not master_key:
|
||||||
|
# Generate new master key
|
||||||
|
logger.warning("No master key found. Generating new one.")
|
||||||
|
master_key = Fernet.generate_key().decode('utf-8')
|
||||||
|
|
||||||
|
# Save to secure file (should be protected by OS permissions)
|
||||||
|
key_file = os.environ.get('MASTER_KEY_FILE', '.master_key')
|
||||||
|
try:
|
||||||
|
with open(key_file, 'wb') as f:
|
||||||
|
f.write(master_key.encode('utf-8'))
|
||||||
|
os.chmod(key_file, 0o600) # Owner read/write only
|
||||||
|
logger.info(f"Master key saved to {key_file}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save master key: {e}")
|
||||||
|
|
||||||
|
self._master_key = master_key.encode('utf-8')
|
||||||
|
|
||||||
|
# Derive encryption key from master key
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=b'talk2me-secrets-salt', # In production, use random salt
|
||||||
|
iterations=100000,
|
||||||
|
)
|
||||||
|
key = base64.urlsafe_b64encode(kdf.derive(self._master_key))
|
||||||
|
self._encryption_key = Fernet(key)
|
||||||
|
|
||||||
|
def _load_secrets(self):
|
||||||
|
"""Load encrypted secrets from file"""
|
||||||
|
if not os.path.exists(self.config_file):
|
||||||
|
logger.info(f"No secrets file found at {self.config_file}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Decrypt secrets
|
||||||
|
for key, value in data.get('secrets', {}).items():
|
||||||
|
if isinstance(value, dict) and 'encrypted' in value:
|
||||||
|
try:
|
||||||
|
decrypted = self._decrypt(value['encrypted'])
|
||||||
|
self._secrets_cache[key] = {
|
||||||
|
'value': decrypted,
|
||||||
|
'created': value.get('created'),
|
||||||
|
'rotated': value.get('rotated'),
|
||||||
|
'metadata': value.get('metadata', {})
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to decrypt secret {key}: {e}")
|
||||||
|
else:
|
||||||
|
# Plain text (for migration)
|
||||||
|
self._secrets_cache[key] = {
|
||||||
|
'value': value,
|
||||||
|
'created': datetime.now().isoformat(),
|
||||||
|
'rotated': None,
|
||||||
|
'metadata': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load rotation schedule
|
||||||
|
self._rotation_schedule = data.get('rotation_schedule', {})
|
||||||
|
|
||||||
|
# Load audit log
|
||||||
|
self._audit_log = data.get('audit_log', [])
|
||||||
|
|
||||||
|
logger.info(f"Loaded {len(self._secrets_cache)} secrets")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load secrets: {e}")
|
||||||
|
|
||||||
|
def _save_secrets(self):
|
||||||
|
"""Save encrypted secrets to file"""
|
||||||
|
with self.lock:
|
||||||
|
data = {
|
||||||
|
'secrets': {},
|
||||||
|
'rotation_schedule': self._rotation_schedule,
|
||||||
|
'audit_log': self._audit_log[-1000:] # Keep last 1000 entries
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt secrets
|
||||||
|
for key, secret_data in self._secrets_cache.items():
|
||||||
|
data['secrets'][key] = {
|
||||||
|
'encrypted': self._encrypt(secret_data['value']),
|
||||||
|
'created': secret_data.get('created'),
|
||||||
|
'rotated': secret_data.get('rotated'),
|
||||||
|
'metadata': secret_data.get('metadata', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
try:
|
||||||
|
# Write to temporary file first
|
||||||
|
temp_file = f"{self.config_file}.tmp"
|
||||||
|
with open(temp_file, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
# Set secure permissions
|
||||||
|
os.chmod(temp_file, 0o600) # Owner read/write only
|
||||||
|
|
||||||
|
# Atomic rename
|
||||||
|
os.rename(temp_file, self.config_file)
|
||||||
|
|
||||||
|
logger.info(f"Saved {len(self._secrets_cache)} secrets")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save secrets: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _encrypt(self, value: str) -> str:
|
||||||
|
"""Encrypt a value"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = str(value)
|
||||||
|
return self._encryption_key.encrypt(value.encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
def _decrypt(self, encrypted_value: str) -> str:
|
||||||
|
"""Decrypt a value"""
|
||||||
|
return self._encryption_key.decrypt(encrypted_value.encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
def _audit(self, action: str, key: str, user: str = None, details: dict = None):
|
||||||
|
"""Add entry to audit log"""
|
||||||
|
entry = {
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'action': action,
|
||||||
|
'key': key,
|
||||||
|
'user': user or 'system',
|
||||||
|
'details': details or {}
|
||||||
|
}
|
||||||
|
self._audit_log.append(entry)
|
||||||
|
logger.info(f"Audit: {action} on {key} by {user or 'system'}")
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a secret value"""
|
||||||
|
# Try cache first
|
||||||
|
if key in self._secrets_cache:
|
||||||
|
self._audit('access', key)
|
||||||
|
return self._secrets_cache[key]['value']
|
||||||
|
|
||||||
|
# Try environment variable
|
||||||
|
env_key = f"SECRET_{key.upper()}"
|
||||||
|
env_value = os.environ.get(env_key)
|
||||||
|
if env_value:
|
||||||
|
self._audit('access', key, details={'source': 'environment'})
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
# Try regular environment variable
|
||||||
|
env_value = os.environ.get(key)
|
||||||
|
if env_value:
|
||||||
|
self._audit('access', key, details={'source': 'environment'})
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
self._audit('access_failed', key)
|
||||||
|
return default
|
||||||
|
|
||||||
|
def set(self, key: str, value: str, metadata: dict = None, user: str = None):
|
||||||
|
"""Set a secret value"""
|
||||||
|
with self.lock:
|
||||||
|
old_value = self._secrets_cache.get(key, {}).get('value')
|
||||||
|
|
||||||
|
self._secrets_cache[key] = {
|
||||||
|
'value': value,
|
||||||
|
'created': self._secrets_cache.get(key, {}).get('created', datetime.now().isoformat()),
|
||||||
|
'rotated': datetime.now().isoformat() if old_value else None,
|
||||||
|
'metadata': metadata or {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self._audit('set' if not old_value else 'update', key, user)
|
||||||
|
self._save_secrets()
|
||||||
|
|
||||||
|
def delete(self, key: str, user: str = None):
|
||||||
|
"""Delete a secret"""
|
||||||
|
with self.lock:
|
||||||
|
if key in self._secrets_cache:
|
||||||
|
del self._secrets_cache[key]
|
||||||
|
self._audit('delete', key, user)
|
||||||
|
self._save_secrets()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def rotate(self, key: str, new_value: str = None, user: str = None):
|
||||||
|
"""Rotate a secret"""
|
||||||
|
with self.lock:
|
||||||
|
if key not in self._secrets_cache:
|
||||||
|
raise KeyError(f"Secret {key} not found")
|
||||||
|
|
||||||
|
old_value = self._secrets_cache[key]['value']
|
||||||
|
|
||||||
|
# Generate new value if not provided
|
||||||
|
if not new_value:
|
||||||
|
if key.endswith('_KEY') or key.endswith('_TOKEN'):
|
||||||
|
new_value = secrets.token_urlsafe(32)
|
||||||
|
elif key.endswith('_PASSWORD'):
|
||||||
|
new_value = secrets.token_urlsafe(24)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Cannot auto-generate value for {key}")
|
||||||
|
|
||||||
|
# Update secret
|
||||||
|
self._secrets_cache[key]['value'] = new_value
|
||||||
|
self._secrets_cache[key]['rotated'] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
self._audit('rotate', key, user, {'generated': new_value is None})
|
||||||
|
self._save_secrets()
|
||||||
|
|
||||||
|
return old_value, new_value
|
||||||
|
|
||||||
|
def list_secrets(self) -> List[Dict[str, Any]]:
|
||||||
|
"""List all secrets (without values)"""
|
||||||
|
secrets_list = []
|
||||||
|
for key, data in self._secrets_cache.items():
|
||||||
|
secrets_list.append({
|
||||||
|
'key': key,
|
||||||
|
'created': data.get('created'),
|
||||||
|
'rotated': data.get('rotated'),
|
||||||
|
'metadata': data.get('metadata', {}),
|
||||||
|
'has_value': bool(data.get('value'))
|
||||||
|
})
|
||||||
|
return secrets_list
|
||||||
|
|
||||||
|
def add_validator(self, key: str, validator):
|
||||||
|
"""Add a validator function for a secret"""
|
||||||
|
self._validators[key] = validator
|
||||||
|
|
||||||
|
def validate(self, key: str, value: str) -> bool:
|
||||||
|
"""Validate a secret value"""
|
||||||
|
if key in self._validators:
|
||||||
|
try:
|
||||||
|
return self._validators[key](value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Validation failed for {key}: {e}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def schedule_rotation(self, key: str, days: int):
|
||||||
|
"""Schedule automatic rotation for a secret"""
|
||||||
|
self._rotation_schedule[key] = {
|
||||||
|
'days': days,
|
||||||
|
'last_rotated': self._secrets_cache.get(key, {}).get('rotated', datetime.now().isoformat())
|
||||||
|
}
|
||||||
|
self._save_secrets()
|
||||||
|
|
||||||
|
def check_rotation_needed(self) -> List[str]:
|
||||||
|
"""Check which secrets need rotation"""
|
||||||
|
needs_rotation = []
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
for key, schedule in self._rotation_schedule.items():
|
||||||
|
last_rotated = datetime.fromisoformat(schedule['last_rotated'])
|
||||||
|
if now - last_rotated > timedelta(days=schedule['days']):
|
||||||
|
needs_rotation.append(key)
|
||||||
|
|
||||||
|
return needs_rotation
|
||||||
|
|
||||||
|
def get_audit_log(self, key: str = None, limit: int = 100) -> List[Dict]:
|
||||||
|
"""Get audit log entries"""
|
||||||
|
logs = self._audit_log
|
||||||
|
|
||||||
|
if key:
|
||||||
|
logs = [log for log in logs if log['key'] == key]
|
||||||
|
|
||||||
|
return logs[-limit:]
|
||||||
|
|
||||||
|
def export_for_environment(self) -> Dict[str, str]:
|
||||||
|
"""Export secrets as environment variables"""
|
||||||
|
env_vars = {}
|
||||||
|
for key, data in self._secrets_cache.items():
|
||||||
|
env_key = f"SECRET_{key.upper()}"
|
||||||
|
env_vars[env_key] = data['value']
|
||||||
|
return env_vars
|
||||||
|
|
||||||
|
def verify_integrity(self) -> bool:
|
||||||
|
"""Verify integrity of secrets"""
|
||||||
|
try:
|
||||||
|
# Try to decrypt all secrets
|
||||||
|
for key, secret_data in self._secrets_cache.items():
|
||||||
|
if 'value' in secret_data:
|
||||||
|
# Re-encrypt and compare
|
||||||
|
encrypted = self._encrypt(secret_data['value'])
|
||||||
|
decrypted = self._decrypt(encrypted)
|
||||||
|
if decrypted != secret_data['value']:
|
||||||
|
logger.error(f"Integrity check failed for {key}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info("Integrity check passed")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Integrity check failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
_secrets_manager = None
|
||||||
|
_secrets_lock = Lock()
|
||||||
|
|
||||||
|
def get_secrets_manager(config_file: str = None) -> SecretsManager:
|
||||||
|
"""Get or create global secrets manager instance"""
|
||||||
|
global _secrets_manager
|
||||||
|
|
||||||
|
with _secrets_lock:
|
||||||
|
if _secrets_manager is None:
|
||||||
|
_secrets_manager = SecretsManager(config_file)
|
||||||
|
return _secrets_manager
|
||||||
|
|
||||||
|
def get_secret(key: str, default: Any = None) -> Any:
|
||||||
|
"""Convenience function to get a secret"""
|
||||||
|
manager = get_secrets_manager()
|
||||||
|
return manager.get(key, default)
|
||||||
|
|
||||||
|
def set_secret(key: str, value: str, metadata: dict = None):
|
||||||
|
"""Convenience function to set a secret"""
|
||||||
|
manager = get_secrets_manager()
|
||||||
|
manager.set(key, value, metadata)
|
||||||
|
|
||||||
|
# Flask integration
|
||||||
|
def init_app(app):
|
||||||
|
"""Initialize secrets management for Flask app"""
|
||||||
|
manager = get_secrets_manager()
|
||||||
|
|
||||||
|
# Load secrets into app config
|
||||||
|
app.config['SECRET_KEY'] = manager.get('FLASK_SECRET_KEY') or app.config.get('SECRET_KEY')
|
||||||
|
app.config['TTS_API_KEY'] = manager.get('TTS_API_KEY') or app.config.get('TTS_API_KEY')
|
||||||
|
|
||||||
|
# Add secret manager to app
|
||||||
|
app.secrets_manager = manager
|
||||||
|
|
||||||
|
# Add CLI commands
|
||||||
|
@app.cli.command('secrets-list')
|
||||||
|
def list_secrets_cmd():
|
||||||
|
"""List all secrets"""
|
||||||
|
secrets = manager.list_secrets()
|
||||||
|
for secret in secrets:
|
||||||
|
print(f"{secret['key']}: created={secret['created']}, rotated={secret['rotated']}")
|
||||||
|
|
||||||
|
@app.cli.command('secrets-set')
|
||||||
|
def set_secret_cmd():
|
||||||
|
"""Set a secret"""
|
||||||
|
import click
|
||||||
|
key = click.prompt('Secret key')
|
||||||
|
value = click.prompt('Secret value', hide_input=True)
|
||||||
|
manager.set(key, value, user='cli')
|
||||||
|
print(f"Secret {key} set successfully")
|
||||||
|
|
||||||
|
@app.cli.command('secrets-rotate')
|
||||||
|
def rotate_secret_cmd():
|
||||||
|
"""Rotate a secret"""
|
||||||
|
import click
|
||||||
|
key = click.prompt('Secret key to rotate')
|
||||||
|
old_value, new_value = manager.rotate(key, user='cli')
|
||||||
|
print(f"Secret {key} rotated successfully")
|
||||||
|
print(f"New value: {new_value}")
|
||||||
|
|
||||||
|
@app.cli.command('secrets-check-rotation')
|
||||||
|
def check_rotation_cmd():
|
||||||
|
"""Check which secrets need rotation"""
|
||||||
|
needs_rotation = manager.check_rotation_needed()
|
||||||
|
if needs_rotation:
|
||||||
|
print("Secrets needing rotation:")
|
||||||
|
for key in needs_rotation:
|
||||||
|
print(f" - {key}")
|
||||||
|
else:
|
||||||
|
print("No secrets need rotation")
|
||||||
|
|
||||||
|
logger.info("Secrets management initialized")
|
607
session_manager.py
Normal file
607
session_manager.py
Normal file
@ -0,0 +1,607 @@
|
|||||||
|
# Session management system for preventing resource leaks
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, Optional, List, Tuple
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from threading import Lock, Thread
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from collections import defaultdict
|
||||||
|
from functools import wraps
|
||||||
|
from flask import session, request, g, current_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionResource:
|
||||||
|
"""Represents a resource associated with a session"""
|
||||||
|
resource_id: str
|
||||||
|
resource_type: str # 'audio_file', 'temp_file', 'websocket', 'stream'
|
||||||
|
path: Optional[str] = None
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
last_accessed: float = field(default_factory=time.time)
|
||||||
|
size_bytes: int = 0
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserSession:
|
||||||
|
"""Represents a user session with associated resources"""
|
||||||
|
session_id: str
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
user_agent: Optional[str] = None
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
last_activity: float = field(default_factory=time.time)
|
||||||
|
resources: Dict[str, SessionResource] = field(default_factory=dict)
|
||||||
|
request_count: int = 0
|
||||||
|
total_bytes_used: int = 0
|
||||||
|
active_streams: int = 0
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""
|
||||||
|
Manages user sessions and associated resources to prevent leaks
|
||||||
|
"""
|
||||||
|
def __init__(self, config: Dict[str, Any] = None):
|
||||||
|
self.config = config or {}
|
||||||
|
self.sessions: Dict[str, UserSession] = {}
|
||||||
|
self.lock = Lock()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
self.cleanup_interval = self.config.get('cleanup_interval', 60) # 1 minute
|
||||||
|
self.session_storage_path = self.config.get('session_storage_path',
|
||||||
|
os.path.join(tempfile.gettempdir(), 'talk2me_sessions'))
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
self.stats = {
|
||||||
|
'total_sessions_created': 0,
|
||||||
|
'total_sessions_cleaned': 0,
|
||||||
|
'total_resources_cleaned': 0,
|
||||||
|
'total_bytes_cleaned': 0,
|
||||||
|
'active_sessions': 0,
|
||||||
|
'active_resources': 0,
|
||||||
|
'active_bytes': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resource cleanup handlers
|
||||||
|
self.cleanup_handlers = {
|
||||||
|
'audio_file': self._cleanup_audio_file,
|
||||||
|
'temp_file': self._cleanup_temp_file,
|
||||||
|
'websocket': self._cleanup_websocket,
|
||||||
|
'stream': self._cleanup_stream
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize storage
|
||||||
|
self._init_storage()
|
||||||
|
|
||||||
|
# Start cleanup thread
|
||||||
|
self.cleanup_thread = Thread(target=self._cleanup_loop, daemon=True)
|
||||||
|
self.cleanup_thread.start()
|
||||||
|
|
||||||
|
logger.info("Session manager initialized")
|
||||||
|
|
||||||
|
def _init_storage(self):
|
||||||
|
"""Initialize session storage directory"""
|
||||||
|
try:
|
||||||
|
os.makedirs(self.session_storage_path, mode=0o755, exist_ok=True)
|
||||||
|
logger.info(f"Session storage initialized at {self.session_storage_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create session storage: {e}")
|
||||||
|
|
||||||
|
def create_session(self, session_id: str = None, user_id: str = None,
|
||||||
|
ip_address: str = None, user_agent: str = None) -> UserSession:
|
||||||
|
"""Create a new session"""
|
||||||
|
with self.lock:
|
||||||
|
if not session_id:
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
if session_id in self.sessions:
|
||||||
|
logger.warning(f"Session {session_id} already exists")
|
||||||
|
return self.sessions[session_id]
|
||||||
|
|
||||||
|
session = UserSession(
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sessions[session_id] = session
|
||||||
|
self.stats['total_sessions_created'] += 1
|
||||||
|
self.stats['active_sessions'] = len(self.sessions)
|
||||||
|
|
||||||
|
# Create session directory
|
||||||
|
session_dir = os.path.join(self.session_storage_path, session_id)
|
||||||
|
try:
|
||||||
|
os.makedirs(session_dir, mode=0o755, exist_ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create session directory: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Created session {session_id}")
|
||||||
|
return session
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> Optional[UserSession]:
|
||||||
|
"""Get a session by ID"""
|
||||||
|
with self.lock:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if session:
|
||||||
|
session.last_activity = time.time()
|
||||||
|
return session
|
||||||
|
|
||||||
|
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) -> Optional[SessionResource]:
|
||||||
|
"""Add a resource to a session"""
|
||||||
|
with self.lock:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
logger.warning(f"Session {session_id} not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check limits
|
||||||
|
if len(session.resources) >= self.max_resources_per_session:
|
||||||
|
logger.warning(f"Session {session_id} reached resource limit")
|
||||||
|
self._cleanup_oldest_resources(session, 1)
|
||||||
|
|
||||||
|
if session.total_bytes_used + size_bytes > self.max_bytes_per_session:
|
||||||
|
logger.warning(f"Session {session_id} reached size limit")
|
||||||
|
bytes_to_free = (session.total_bytes_used + size_bytes) - self.max_bytes_per_session
|
||||||
|
self._cleanup_resources_by_size(session, bytes_to_free)
|
||||||
|
|
||||||
|
# Create resource
|
||||||
|
if not resource_id:
|
||||||
|
resource_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
resource = SessionResource(
|
||||||
|
resource_id=resource_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
path=path,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
metadata=metadata or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
session.resources[resource_id] = resource
|
||||||
|
session.total_bytes_used += size_bytes
|
||||||
|
session.last_activity = time.time()
|
||||||
|
|
||||||
|
# Update stats
|
||||||
|
self.stats['active_resources'] += 1
|
||||||
|
self.stats['active_bytes'] += size_bytes
|
||||||
|
|
||||||
|
logger.debug(f"Added {resource_type} resource {resource_id} to session {session_id}")
|
||||||
|
return resource
|
||||||
|
|
||||||
|
def remove_resource(self, session_id: str, resource_id: str) -> bool:
|
||||||
|
"""Remove a resource from a session"""
|
||||||
|
with self.lock:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return False
|
||||||
|
|
||||||
|
resource = session.resources.get(resource_id)
|
||||||
|
if not resource:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Cleanup resource
|
||||||
|
self._cleanup_resource(resource)
|
||||||
|
|
||||||
|
# Remove from session
|
||||||
|
del session.resources[resource_id]
|
||||||
|
session.total_bytes_used -= resource.size_bytes
|
||||||
|
|
||||||
|
# Update stats
|
||||||
|
self.stats['active_resources'] -= 1
|
||||||
|
self.stats['active_bytes'] -= resource.size_bytes
|
||||||
|
self.stats['total_resources_cleaned'] += 1
|
||||||
|
self.stats['total_bytes_cleaned'] += resource.size_bytes
|
||||||
|
|
||||||
|
logger.debug(f"Removed resource {resource_id} from session {session_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_session_activity(self, session_id: str):
|
||||||
|
"""Update session last activity time"""
|
||||||
|
with self.lock:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if session:
|
||||||
|
session.last_activity = time.time()
|
||||||
|
session.request_count += 1
|
||||||
|
|
||||||
|
def cleanup_session(self, session_id: str) -> bool:
|
||||||
|
"""Clean up a session and all its resources"""
|
||||||
|
with self.lock:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Cleanup all resources
|
||||||
|
for resource_id in list(session.resources.keys()):
|
||||||
|
self.remove_resource(session_id, resource_id)
|
||||||
|
|
||||||
|
# Remove session directory
|
||||||
|
session_dir = os.path.join(self.session_storage_path, session_id)
|
||||||
|
try:
|
||||||
|
if os.path.exists(session_dir):
|
||||||
|
shutil.rmtree(session_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to remove session directory: {e}")
|
||||||
|
|
||||||
|
# Remove session
|
||||||
|
del self.sessions[session_id]
|
||||||
|
|
||||||
|
# Update stats
|
||||||
|
self.stats['active_sessions'] = len(self.sessions)
|
||||||
|
self.stats['total_sessions_cleaned'] += 1
|
||||||
|
|
||||||
|
logger.info(f"Cleaned up session {session_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cleanup_resource(self, resource: SessionResource):
|
||||||
|
"""Clean up a single resource"""
|
||||||
|
handler = self.cleanup_handlers.get(resource.resource_type)
|
||||||
|
if handler:
|
||||||
|
try:
|
||||||
|
handler(resource)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to cleanup {resource.resource_type} {resource.resource_id}: {e}")
|
||||||
|
|
||||||
|
def _cleanup_audio_file(self, resource: SessionResource):
|
||||||
|
"""Clean up audio file resource"""
|
||||||
|
if resource.path and os.path.exists(resource.path):
|
||||||
|
try:
|
||||||
|
os.remove(resource.path)
|
||||||
|
logger.debug(f"Removed audio file {resource.path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to remove audio file {resource.path}: {e}")
|
||||||
|
|
||||||
|
def _cleanup_temp_file(self, resource: SessionResource):
|
||||||
|
"""Clean up temporary file resource"""
|
||||||
|
if resource.path and os.path.exists(resource.path):
|
||||||
|
try:
|
||||||
|
os.remove(resource.path)
|
||||||
|
logger.debug(f"Removed temp file {resource.path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to remove temp file {resource.path}: {e}")
|
||||||
|
|
||||||
|
def _cleanup_websocket(self, resource: SessionResource):
|
||||||
|
"""Clean up websocket resource"""
|
||||||
|
# Implement websocket cleanup if needed
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _cleanup_stream(self, resource: SessionResource):
|
||||||
|
"""Clean up stream resource"""
|
||||||
|
# Implement stream cleanup if needed
|
||||||
|
if resource.metadata.get('stream_id'):
|
||||||
|
# Close any open streams
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _cleanup_oldest_resources(self, session: UserSession, count: int):
|
||||||
|
"""Clean up oldest resources from a session"""
|
||||||
|
# Sort resources by creation time
|
||||||
|
sorted_resources = sorted(
|
||||||
|
session.resources.items(),
|
||||||
|
key=lambda x: x[1].created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove oldest resources
|
||||||
|
for resource_id, _ in sorted_resources[:count]:
|
||||||
|
self.remove_resource(session.session_id, resource_id)
|
||||||
|
|
||||||
|
def _cleanup_resources_by_size(self, session: UserSession, bytes_to_free: int):
|
||||||
|
"""Clean up resources to free up space"""
|
||||||
|
freed_bytes = 0
|
||||||
|
|
||||||
|
# Sort resources by size (largest first)
|
||||||
|
sorted_resources = sorted(
|
||||||
|
session.resources.items(),
|
||||||
|
key=lambda x: x[1].size_bytes,
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove resources until we've freed enough space
|
||||||
|
for resource_id, resource in sorted_resources:
|
||||||
|
if freed_bytes >= bytes_to_free:
|
||||||
|
break
|
||||||
|
|
||||||
|
freed_bytes += resource.size_bytes
|
||||||
|
self.remove_resource(session.session_id, resource_id)
|
||||||
|
|
||||||
|
def _cleanup_loop(self):
|
||||||
|
"""Background cleanup thread"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
time.sleep(self.cleanup_interval)
|
||||||
|
self.cleanup_expired_sessions()
|
||||||
|
self.cleanup_idle_sessions()
|
||||||
|
self.cleanup_orphaned_files()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in cleanup loop: {e}")
|
||||||
|
|
||||||
|
def cleanup_expired_sessions(self):
|
||||||
|
"""Clean up sessions that have exceeded max duration"""
|
||||||
|
with self.lock:
|
||||||
|
now = time.time()
|
||||||
|
expired_sessions = []
|
||||||
|
|
||||||
|
for session_id, session in self.sessions.items():
|
||||||
|
if now - session.created_at > self.max_session_duration:
|
||||||
|
expired_sessions.append(session_id)
|
||||||
|
|
||||||
|
for session_id in expired_sessions:
|
||||||
|
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"""
|
||||||
|
with self.lock:
|
||||||
|
now = time.time()
|
||||||
|
idle_sessions = []
|
||||||
|
|
||||||
|
for session_id, session in self.sessions.items():
|
||||||
|
if now - session.last_activity > self.max_idle_time:
|
||||||
|
idle_sessions.append(session_id)
|
||||||
|
|
||||||
|
for session_id in idle_sessions:
|
||||||
|
logger.info(f"Cleaning up idle session {session_id}")
|
||||||
|
self.cleanup_session(session_id)
|
||||||
|
|
||||||
|
def cleanup_orphaned_files(self):
|
||||||
|
"""Clean up orphaned files in session storage"""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(self.session_storage_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get all session directories
|
||||||
|
session_dirs = set(os.listdir(self.session_storage_path))
|
||||||
|
|
||||||
|
# Get active session IDs
|
||||||
|
with self.lock:
|
||||||
|
active_sessions = set(self.sessions.keys())
|
||||||
|
|
||||||
|
# Find orphaned directories
|
||||||
|
orphaned_dirs = session_dirs - active_sessions
|
||||||
|
|
||||||
|
# Clean up orphaned directories
|
||||||
|
for dir_name in orphaned_dirs:
|
||||||
|
dir_path = os.path.join(self.session_storage_path, dir_name)
|
||||||
|
if os.path.isdir(dir_path):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(dir_path)
|
||||||
|
logger.info(f"Cleaned up orphaned session directory {dir_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to remove orphaned directory {dir_path}: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning orphaned files: {e}")
|
||||||
|
|
||||||
|
def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get detailed information about a session"""
|
||||||
|
with self.lock:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'session_id': session.session_id,
|
||||||
|
'user_id': session.user_id,
|
||||||
|
'ip_address': session.ip_address,
|
||||||
|
'created_at': datetime.fromtimestamp(session.created_at).isoformat(),
|
||||||
|
'last_activity': datetime.fromtimestamp(session.last_activity).isoformat(),
|
||||||
|
'duration_seconds': int(time.time() - session.created_at),
|
||||||
|
'idle_seconds': int(time.time() - session.last_activity),
|
||||||
|
'request_count': session.request_count,
|
||||||
|
'resource_count': len(session.resources),
|
||||||
|
'total_bytes_used': session.total_bytes_used,
|
||||||
|
'active_streams': session.active_streams,
|
||||||
|
'resources': [
|
||||||
|
{
|
||||||
|
'resource_id': r.resource_id,
|
||||||
|
'resource_type': r.resource_type,
|
||||||
|
'size_bytes': r.size_bytes,
|
||||||
|
'created_at': datetime.fromtimestamp(r.created_at).isoformat(),
|
||||||
|
'last_accessed': datetime.fromtimestamp(r.last_accessed).isoformat()
|
||||||
|
}
|
||||||
|
for r in session.resources.values()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_all_sessions_info(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get information about all active sessions"""
|
||||||
|
with self.lock:
|
||||||
|
return [
|
||||||
|
self.get_session_info(session_id)
|
||||||
|
for session_id in self.sessions.keys()
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get session manager statistics"""
|
||||||
|
with self.lock:
|
||||||
|
return {
|
||||||
|
**self.stats,
|
||||||
|
'uptime_seconds': int(time.time() - self.stats.get('start_time', time.time())),
|
||||||
|
'avg_session_duration': self._calculate_avg_session_duration(),
|
||||||
|
'avg_resources_per_session': self._calculate_avg_resources_per_session(),
|
||||||
|
'total_storage_used': self._calculate_total_storage_used()
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_avg_session_duration(self) -> float:
|
||||||
|
"""Calculate average session duration"""
|
||||||
|
if not self.sessions:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_duration = sum(
|
||||||
|
time.time() - session.created_at
|
||||||
|
for session in self.sessions.values()
|
||||||
|
)
|
||||||
|
return total_duration / len(self.sessions)
|
||||||
|
|
||||||
|
def _calculate_avg_resources_per_session(self) -> float:
|
||||||
|
"""Calculate average resources per session"""
|
||||||
|
if not self.sessions:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_resources = sum(
|
||||||
|
len(session.resources)
|
||||||
|
for session in self.sessions.values()
|
||||||
|
)
|
||||||
|
return total_resources / len(self.sessions)
|
||||||
|
|
||||||
|
def _calculate_total_storage_used(self) -> int:
|
||||||
|
"""Calculate total storage used"""
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(self.session_storage_path):
|
||||||
|
for file in files:
|
||||||
|
filepath = os.path.join(root, file)
|
||||||
|
total += os.path.getsize(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating storage used: {e}")
|
||||||
|
return total
|
||||||
|
|
||||||
|
def export_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Export metrics for monitoring"""
|
||||||
|
with self.lock:
|
||||||
|
return {
|
||||||
|
'sessions': {
|
||||||
|
'active': self.stats['active_sessions'],
|
||||||
|
'total_created': self.stats['total_sessions_created'],
|
||||||
|
'total_cleaned': self.stats['total_sessions_cleaned']
|
||||||
|
},
|
||||||
|
'resources': {
|
||||||
|
'active': self.stats['active_resources'],
|
||||||
|
'total_cleaned': self.stats['total_resources_cleaned'],
|
||||||
|
'active_bytes': self.stats['active_bytes'],
|
||||||
|
'total_bytes_cleaned': self.stats['total_bytes_cleaned']
|
||||||
|
},
|
||||||
|
'limits': {
|
||||||
|
'max_session_duration': self.max_session_duration,
|
||||||
|
'max_idle_time': self.max_idle_time,
|
||||||
|
'max_resources_per_session': self.max_resources_per_session,
|
||||||
|
'max_bytes_per_session': self.max_bytes_per_session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Global session manager instance
|
||||||
|
_session_manager = None
|
||||||
|
_session_lock = Lock()
|
||||||
|
|
||||||
|
def get_session_manager(config: Dict[str, Any] = None) -> SessionManager:
|
||||||
|
"""Get or create global session manager instance"""
|
||||||
|
global _session_manager
|
||||||
|
|
||||||
|
with _session_lock:
|
||||||
|
if _session_manager is None:
|
||||||
|
_session_manager = SessionManager(config)
|
||||||
|
return _session_manager
|
||||||
|
|
||||||
|
# Flask integration
|
||||||
|
def init_app(app):
|
||||||
|
"""Initialize session management for Flask app"""
|
||||||
|
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),
|
||||||
|
'cleanup_interval': app.config.get('SESSION_CLEANUP_INTERVAL', 60),
|
||||||
|
'session_storage_path': app.config.get('SESSION_STORAGE_PATH',
|
||||||
|
os.path.join(app.config.get('UPLOAD_FOLDER', tempfile.gettempdir()), 'sessions'))
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = get_session_manager(config)
|
||||||
|
app.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
|
||||||
|
|
||||||
|
# Add CLI commands
|
||||||
|
@app.cli.command('sessions-list')
|
||||||
|
def list_sessions_cmd():
|
||||||
|
"""List all active sessions"""
|
||||||
|
sessions = manager.get_all_sessions_info()
|
||||||
|
for session_info in sessions:
|
||||||
|
print(f"\nSession: {session_info['session_id']}")
|
||||||
|
print(f" Created: {session_info['created_at']}")
|
||||||
|
print(f" Last activity: {session_info['last_activity']}")
|
||||||
|
print(f" Resources: {session_info['resource_count']}")
|
||||||
|
print(f" Bytes used: {session_info['total_bytes_used']}")
|
||||||
|
|
||||||
|
@app.cli.command('sessions-cleanup')
|
||||||
|
def cleanup_sessions_cmd():
|
||||||
|
"""Manual session cleanup"""
|
||||||
|
manager.cleanup_expired_sessions()
|
||||||
|
manager.cleanup_idle_sessions()
|
||||||
|
manager.cleanup_orphaned_files()
|
||||||
|
print("Session cleanup completed")
|
||||||
|
|
||||||
|
@app.cli.command('sessions-stats')
|
||||||
|
def session_stats_cmd():
|
||||||
|
"""Show session statistics"""
|
||||||
|
stats = manager.get_stats()
|
||||||
|
print(json.dumps(stats, indent=2))
|
||||||
|
|
||||||
|
logger.info("Session management initialized")
|
||||||
|
|
||||||
|
# Decorator for session resource tracking
|
||||||
|
def track_resource(resource_type: str):
|
||||||
|
"""Decorator to track resources for a session"""
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Track resource if in request context
|
||||||
|
if hasattr(g, 'user_session') and hasattr(g, 'session_manager'):
|
||||||
|
if isinstance(result, (str, bytes)) or hasattr(result, 'filename'):
|
||||||
|
# Determine path and size
|
||||||
|
path = None
|
||||||
|
size = 0
|
||||||
|
|
||||||
|
if isinstance(result, str) and os.path.exists(result):
|
||||||
|
path = result
|
||||||
|
size = os.path.getsize(result)
|
||||||
|
elif hasattr(result, 'filename'):
|
||||||
|
path = result.filename
|
||||||
|
if os.path.exists(path):
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
|
||||||
|
# Add resource to session
|
||||||
|
g.session_manager.add_resource(
|
||||||
|
session_id=g.user_session.session_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
path=path,
|
||||||
|
size_bytes=size
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
@ -0,0 +1,559 @@
|
|||||||
|
/* Main styles for Talk2Me application */
|
||||||
|
|
||||||
|
/* Loading animations */
|
||||||
|
.loading-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #007bff;
|
||||||
|
animation: dotPulse 1.4s infinite ease-in-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span:nth-child(1) {
|
||||||
|
animation-delay: -0.32s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span:nth-child(2) {
|
||||||
|
animation-delay: -0.16s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wave animation for recording */
|
||||||
|
.recording-wave {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-wave span {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 2px;
|
||||||
|
animation: wave 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-wave span:nth-child(1) {
|
||||||
|
left: 0;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-wave span:nth-child(2) {
|
||||||
|
left: 8px;
|
||||||
|
animation-delay: -1.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-wave span:nth-child(3) {
|
||||||
|
left: 16px;
|
||||||
|
animation-delay: -1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-wave span:nth-child(4) {
|
||||||
|
left: 24px;
|
||||||
|
animation-delay: -0.9s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-wave span:nth-child(5) {
|
||||||
|
left: 32px;
|
||||||
|
animation-delay: -0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave {
|
||||||
|
0%, 40%, 100% {
|
||||||
|
transform: scaleY(0.4);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner animation */
|
||||||
|
.spinner-custom {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-custom::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(0, 123, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-custom::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-top-color: #007bff;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Translation animation */
|
||||||
|
.translation-animation {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-animation .arrow {
|
||||||
|
width: 30px;
|
||||||
|
height: 2px;
|
||||||
|
background: #28a745;
|
||||||
|
position: relative;
|
||||||
|
animation: moveArrow 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-animation .arrow::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -8px;
|
||||||
|
top: -4px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 8px solid #28a745;
|
||||||
|
border-top: 5px solid transparent;
|
||||||
|
border-bottom: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes moveArrow {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Processing text animation */
|
||||||
|
.processing-text {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
font-style: italic;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-text::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
transparent 0%,
|
||||||
|
#007bff 50%,
|
||||||
|
transparent 100%);
|
||||||
|
animation: processLine 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes processLine {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animation for results */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for buttons */
|
||||||
|
.btn-pulse {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(0, 123, 255, 0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(0, 123, 255, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(0, 123, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content .spinner-custom {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator animations */
|
||||||
|
.status-indicator {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.processing {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.success {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.error {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card loading state */
|
||||||
|
.card-loading {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-loading::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.4),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text skeleton loader */
|
||||||
|
.skeleton-loader {
|
||||||
|
background: #eee;
|
||||||
|
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio playing animation */
|
||||||
|
.audio-playing {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-playing span {
|
||||||
|
width: 3px;
|
||||||
|
background: #28a745;
|
||||||
|
animation: audioBar 0.5s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-playing span:nth-child(1) {
|
||||||
|
height: 40%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-playing span:nth-child(2) {
|
||||||
|
height: 60%;
|
||||||
|
animation-delay: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-playing span:nth-child(3) {
|
||||||
|
height: 80%;
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-playing span:nth-child(4) {
|
||||||
|
height: 60%;
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-playing span:nth-child(5) {
|
||||||
|
height: 40%;
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes audioBar {
|
||||||
|
to {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions */
|
||||||
|
.btn {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success notification */
|
||||||
|
.success-notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-notification.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-notification i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile optimizations */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.loading-overlay {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-custom,
|
||||||
|
.recording-wave {
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-notification {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 300px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Streaming translation styles */
|
||||||
|
.streaming-text {
|
||||||
|
position: relative;
|
||||||
|
min-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-active::after {
|
||||||
|
content: '▊';
|
||||||
|
display: inline-block;
|
||||||
|
animation: cursor-blink 1s infinite;
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
0%, 49% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%, 100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth text appearance for streaming */
|
||||||
|
.streaming-text {
|
||||||
|
transition: all 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-speaker styles */
|
||||||
|
.speaker-button {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 2px solid;
|
||||||
|
background-color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-button.active {
|
||||||
|
color: white !important;
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(255,255,255,0.3);
|
||||||
|
color: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-button.active .speaker-avatar {
|
||||||
|
background-color: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-entry {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
position: relative;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-speaker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-speaker-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-text {
|
||||||
|
margin-left: 33px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-translation {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Speaker list responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.speaker-button {
|
||||||
|
min-width: 100px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-avatar {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
1103
static/js/app.js
1103
static/js/app.js
File diff suppressed because it is too large
Load Diff
155
static/js/src/apiClient.ts
Normal file
155
static/js/src/apiClient.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
// API Client with CORS support
|
||||||
|
export interface ApiClientConfig {
|
||||||
|
baseUrl?: string;
|
||||||
|
credentials?: RequestCredentials;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
private static instance: ApiClient;
|
||||||
|
private config: ApiClientConfig;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// Default configuration
|
||||||
|
this.config = {
|
||||||
|
baseUrl: '', // Use same origin by default
|
||||||
|
credentials: 'same-origin', // Change to 'include' for cross-origin requests
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest' // Identify as AJAX request
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if we're in a cross-origin context
|
||||||
|
this.detectCrossOrigin();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): ApiClient {
|
||||||
|
if (!ApiClient.instance) {
|
||||||
|
ApiClient.instance = new ApiClient();
|
||||||
|
}
|
||||||
|
return ApiClient.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if we're making cross-origin requests
|
||||||
|
private detectCrossOrigin(): void {
|
||||||
|
// Check if the app is loaded from a different origin
|
||||||
|
const currentScript = document.currentScript as HTMLScriptElement | null;
|
||||||
|
const scriptSrc = currentScript?.src || '';
|
||||||
|
if (scriptSrc && !scriptSrc.startsWith(window.location.origin)) {
|
||||||
|
// We're likely in a cross-origin context
|
||||||
|
this.config.credentials = 'include';
|
||||||
|
console.log('Cross-origin context detected, enabling credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for explicit configuration in meta tags
|
||||||
|
const corsOrigin = document.querySelector('meta[name="cors-origin"]');
|
||||||
|
if (corsOrigin) {
|
||||||
|
const origin = corsOrigin.getAttribute('content');
|
||||||
|
if (origin && origin !== window.location.origin) {
|
||||||
|
this.config.baseUrl = origin;
|
||||||
|
this.config.credentials = 'include';
|
||||||
|
console.log(`Using CORS origin: ${origin}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure the API client
|
||||||
|
configure(config: Partial<ApiClientConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a fetch request with CORS support
|
||||||
|
async fetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||||
|
// Construct full URL
|
||||||
|
const fullUrl = this.config.baseUrl ? `${this.config.baseUrl}${url}` : url;
|
||||||
|
|
||||||
|
// Merge headers
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
if (this.config.headers) {
|
||||||
|
const configHeaders = new Headers(this.config.headers);
|
||||||
|
configHeaders.forEach((value, key) => {
|
||||||
|
if (!headers.has(key)) {
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge options with defaults
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: options.credentials || this.config.credentials
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add CORS mode if cross-origin
|
||||||
|
if (this.config.baseUrl && this.config.baseUrl !== window.location.origin) {
|
||||||
|
fetchOptions.mode = 'cors';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(fullUrl, fetchOptions);
|
||||||
|
|
||||||
|
// Check for CORS errors
|
||||||
|
if (!response.ok && response.type === 'opaque') {
|
||||||
|
throw new Error('CORS request failed - check server CORS configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
// Enhanced error handling for CORS issues
|
||||||
|
if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
|
||||||
|
console.error('CORS Error: Failed to fetch. Check that:', {
|
||||||
|
requestedUrl: fullUrl,
|
||||||
|
origin: window.location.origin,
|
||||||
|
credentials: fetchOptions.credentials,
|
||||||
|
mode: fetchOptions.mode
|
||||||
|
});
|
||||||
|
throw new Error('CORS request failed. The server may not allow requests from this origin.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods
|
||||||
|
async get(url: string, options?: RequestInit): Promise<Response> {
|
||||||
|
return this.fetch(url, { ...options, method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(url: string, body?: any, options?: RequestInit): Promise<Response> {
|
||||||
|
const init: RequestInit = { ...options, method: 'POST' };
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
if (body instanceof FormData) {
|
||||||
|
init.body = body;
|
||||||
|
} else {
|
||||||
|
init.headers = {
|
||||||
|
...init.headers,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
init.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON convenience methods
|
||||||
|
async getJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
const response = await this.get(url, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async postJSON<T>(url: string, body?: any, options?: RequestInit): Promise<T> {
|
||||||
|
const response = await this.post(url, body, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton instance
|
||||||
|
export const apiClient = ApiClient.getInstance();
|
1651
static/js/src/app.ts
Normal file
1651
static/js/src/app.ts
Normal file
File diff suppressed because it is too large
Load Diff
321
static/js/src/connectionManager.ts
Normal file
321
static/js/src/connectionManager.ts
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
// Connection management with retry logic
|
||||||
|
export interface ConnectionConfig {
|
||||||
|
maxRetries: number;
|
||||||
|
initialDelay: number;
|
||||||
|
maxDelay: number;
|
||||||
|
backoffMultiplier: number;
|
||||||
|
timeout: number;
|
||||||
|
onlineCheckInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetryOptions {
|
||||||
|
retries?: number;
|
||||||
|
delay?: number;
|
||||||
|
onRetry?: (attempt: number, error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionStatus = 'online' | 'offline' | 'connecting' | 'error';
|
||||||
|
|
||||||
|
export interface ConnectionState {
|
||||||
|
status: ConnectionStatus;
|
||||||
|
lastError?: Error;
|
||||||
|
retryCount: number;
|
||||||
|
lastOnlineTime?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConnectionManager {
|
||||||
|
private static instance: ConnectionManager;
|
||||||
|
private config: ConnectionConfig;
|
||||||
|
private state: ConnectionState;
|
||||||
|
private listeners: Map<string, (state: ConnectionState) => void> = new Map();
|
||||||
|
private onlineCheckTimer?: number;
|
||||||
|
private reconnectTimer?: number;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.config = {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 1000, // 1 second
|
||||||
|
maxDelay: 30000, // 30 seconds
|
||||||
|
backoffMultiplier: 2,
|
||||||
|
timeout: 10000, // 10 seconds
|
||||||
|
onlineCheckInterval: 5000 // 5 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
status: navigator.onLine ? 'online' : 'offline',
|
||||||
|
retryCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.startOnlineCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): ConnectionManager {
|
||||||
|
if (!ConnectionManager.instance) {
|
||||||
|
ConnectionManager.instance = new ConnectionManager();
|
||||||
|
}
|
||||||
|
return ConnectionManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure connection settings
|
||||||
|
configure(config: Partial<ConnectionConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup browser online/offline event listeners
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
console.log('Browser online event detected');
|
||||||
|
this.updateState({ status: 'online', retryCount: 0 });
|
||||||
|
this.checkServerConnection();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
console.log('Browser offline event detected');
|
||||||
|
this.updateState({ status: 'offline' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for visibility changes to check connection when tab becomes active
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden && this.state.status === 'offline') {
|
||||||
|
this.checkServerConnection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start periodic online checking
|
||||||
|
private startOnlineCheck(): void {
|
||||||
|
this.onlineCheckTimer = window.setInterval(() => {
|
||||||
|
if (this.state.status === 'offline' || this.state.status === 'error') {
|
||||||
|
this.checkServerConnection();
|
||||||
|
}
|
||||||
|
}, this.config.onlineCheckInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check actual server connection
|
||||||
|
async checkServerConnection(): Promise<boolean> {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
this.updateState({ status: 'offline' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateState({ status: 'connecting' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
const response = await fetch('/health', {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-cache'
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.updateState({
|
||||||
|
status: 'online',
|
||||||
|
retryCount: 0,
|
||||||
|
lastOnlineTime: new Date()
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Server returned status ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.updateState({
|
||||||
|
status: 'error',
|
||||||
|
lastError: error as Error
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry a failed request with exponential backoff
|
||||||
|
async retryRequest<T>(
|
||||||
|
request: () => Promise<T>,
|
||||||
|
options: RetryOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
retries = this.config.maxRetries,
|
||||||
|
delay = this.config.initialDelay,
|
||||||
|
onRetry
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
// Check if we're online before attempting
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
throw new Error('No internet connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timeout to request
|
||||||
|
const result = await this.withTimeout(request(), this.config.timeout);
|
||||||
|
|
||||||
|
// Success - reset retry count
|
||||||
|
if (this.state.retryCount > 0) {
|
||||||
|
this.updateState({ retryCount: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
// Don't retry if offline
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
this.updateState({ status: 'offline' });
|
||||||
|
throw new Error('Request failed: No internet connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't retry on client errors (4xx)
|
||||||
|
if (this.isClientError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call retry callback if provided
|
||||||
|
if (onRetry && attempt < retries) {
|
||||||
|
onRetry(attempt + 1, lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have retries left, wait and try again
|
||||||
|
if (attempt < retries) {
|
||||||
|
const backoffDelay = Math.min(
|
||||||
|
delay * Math.pow(this.config.backoffMultiplier, attempt),
|
||||||
|
this.config.maxDelay
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Retry attempt ${attempt + 1}/${retries} after ${backoffDelay}ms`);
|
||||||
|
|
||||||
|
// Update retry count in state
|
||||||
|
this.updateState({ retryCount: attempt + 1 });
|
||||||
|
|
||||||
|
await this.delay(backoffDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries exhausted
|
||||||
|
this.updateState({
|
||||||
|
status: 'error',
|
||||||
|
lastError: lastError!
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(`Request failed after ${retries} retries: ${lastError!.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timeout to a promise
|
||||||
|
private withTimeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Request timeout')), timeout);
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if error is a client error (4xx)
|
||||||
|
private isClientError(error: any): boolean {
|
||||||
|
if (error.response && error.response.status >= 400 && error.response.status < 500) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for specific error messages that shouldn't be retried
|
||||||
|
const message = error.message?.toLowerCase() || '';
|
||||||
|
const noRetryErrors = ['unauthorized', 'forbidden', 'bad request', 'not found'];
|
||||||
|
|
||||||
|
return noRetryErrors.some(e => message.includes(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay helper
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update connection state
|
||||||
|
private updateState(updates: Partial<ConnectionState>): void {
|
||||||
|
this.state = { ...this.state, ...updates };
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to connection state changes
|
||||||
|
subscribe(id: string, callback: (state: ConnectionState) => void): void {
|
||||||
|
this.listeners.set(id, callback);
|
||||||
|
// Immediately call with current state
|
||||||
|
callback(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe from connection state changes
|
||||||
|
unsubscribe(id: string): void {
|
||||||
|
this.listeners.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify all listeners of state change
|
||||||
|
private notifyListeners(): void {
|
||||||
|
this.listeners.forEach(callback => callback(this.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current connection state
|
||||||
|
getState(): ConnectionState {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if currently online
|
||||||
|
isOnline(): boolean {
|
||||||
|
return this.state.status === 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual reconnect attempt
|
||||||
|
async reconnect(): Promise<boolean> {
|
||||||
|
console.log('Manual reconnect requested');
|
||||||
|
return this.checkServerConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
destroy(): void {
|
||||||
|
if (this.onlineCheckTimer) {
|
||||||
|
clearInterval(this.onlineCheckTimer);
|
||||||
|
}
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
}
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for retrying fetch requests
|
||||||
|
export async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
retryOptions: RetryOptions = {}
|
||||||
|
): Promise<Response> {
|
||||||
|
const connectionManager = ConnectionManager.getInstance();
|
||||||
|
|
||||||
|
return connectionManager.retryRequest(async () => {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (!response.ok && response.status >= 500) {
|
||||||
|
// Server error - throw to trigger retry
|
||||||
|
throw new Error(`Server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}, retryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for retrying JSON requests
|
||||||
|
export async function fetchJSONWithRetry<T>(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
retryOptions: RetryOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetchWithRetry(url, options, retryOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
325
static/js/src/connectionUI.ts
Normal file
325
static/js/src/connectionUI.ts
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
// Connection status UI component
|
||||||
|
import { ConnectionManager, ConnectionState } from './connectionManager';
|
||||||
|
import { RequestQueueManager } from './requestQueue';
|
||||||
|
|
||||||
|
export class ConnectionUI {
|
||||||
|
private static instance: ConnectionUI;
|
||||||
|
private connectionManager: ConnectionManager;
|
||||||
|
private queueManager: RequestQueueManager;
|
||||||
|
private statusElement: HTMLElement | null = null;
|
||||||
|
private retryButton: HTMLButtonElement | null = null;
|
||||||
|
private offlineMessage: HTMLElement | null = null;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.connectionManager = ConnectionManager.getInstance();
|
||||||
|
this.queueManager = RequestQueueManager.getInstance();
|
||||||
|
this.createUI();
|
||||||
|
this.subscribeToConnectionChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): ConnectionUI {
|
||||||
|
if (!ConnectionUI.instance) {
|
||||||
|
ConnectionUI.instance = new ConnectionUI();
|
||||||
|
}
|
||||||
|
return ConnectionUI.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createUI(): void {
|
||||||
|
// Create connection status indicator
|
||||||
|
this.statusElement = document.createElement('div');
|
||||||
|
this.statusElement.id = 'connectionStatus';
|
||||||
|
this.statusElement.className = 'connection-status';
|
||||||
|
this.statusElement.innerHTML = `
|
||||||
|
<span class="connection-icon"></span>
|
||||||
|
<span class="connection-text">Checking connection...</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create offline message banner
|
||||||
|
this.offlineMessage = document.createElement('div');
|
||||||
|
this.offlineMessage.id = 'offlineMessage';
|
||||||
|
this.offlineMessage.className = 'offline-message';
|
||||||
|
this.offlineMessage.innerHTML = `
|
||||||
|
<div class="offline-content">
|
||||||
|
<i class="fas fa-wifi-slash"></i>
|
||||||
|
<span class="offline-text">You're offline. Some features may be limited.</span>
|
||||||
|
<button class="btn btn-sm btn-outline-light retry-connection">
|
||||||
|
<i class="fas fa-sync"></i> Retry
|
||||||
|
</button>
|
||||||
|
<div class="queued-info" style="display: none;">
|
||||||
|
<small class="queued-count"></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.offlineMessage.style.display = 'none';
|
||||||
|
|
||||||
|
// Add to page
|
||||||
|
document.body.appendChild(this.statusElement);
|
||||||
|
document.body.appendChild(this.offlineMessage);
|
||||||
|
|
||||||
|
// Get retry button reference
|
||||||
|
this.retryButton = this.offlineMessage.querySelector('.retry-connection') as HTMLButtonElement;
|
||||||
|
this.retryButton?.addEventListener('click', () => this.handleRetry());
|
||||||
|
|
||||||
|
// Add CSS if not already present
|
||||||
|
if (!document.getElementById('connection-ui-styles')) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'connection-ui-styles';
|
||||||
|
style.textContent = `
|
||||||
|
.connection-status {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.online {
|
||||||
|
background: rgba(40, 167, 69, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.offline {
|
||||||
|
background: rgba(220, 53, 69, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.connecting {
|
||||||
|
background: rgba(255, 193, 7, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-icon::before {
|
||||||
|
content: '●';
|
||||||
|
display: inline-block;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.connecting .connection-icon::before {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
content: '↻';
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-message {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 1001;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-message.show {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-content i {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-connection {
|
||||||
|
border-color: white;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-connection:hover {
|
||||||
|
background: white;
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queued-info {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queued-count {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.connection-status {
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-content {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private subscribeToConnectionChanges(): void {
|
||||||
|
this.connectionManager.subscribe('connection-ui', (state: ConnectionState) => {
|
||||||
|
this.updateUI(state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateUI(state: ConnectionState): void {
|
||||||
|
if (!this.statusElement || !this.offlineMessage) return;
|
||||||
|
|
||||||
|
const statusText = this.statusElement.querySelector('.connection-text') as HTMLElement;
|
||||||
|
|
||||||
|
// Update status element
|
||||||
|
this.statusElement.className = `connection-status visible ${state.status}`;
|
||||||
|
|
||||||
|
switch (state.status) {
|
||||||
|
case 'online':
|
||||||
|
statusText.textContent = 'Connected';
|
||||||
|
this.hideOfflineMessage();
|
||||||
|
// Hide status after 3 seconds when online
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.connectionManager.getState().status === 'online') {
|
||||||
|
this.statusElement?.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'offline':
|
||||||
|
statusText.textContent = 'Offline';
|
||||||
|
this.showOfflineMessage();
|
||||||
|
this.updateQueuedInfo();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'connecting':
|
||||||
|
statusText.textContent = 'Reconnecting...';
|
||||||
|
if (this.retryButton) {
|
||||||
|
this.retryButton.disabled = true;
|
||||||
|
this.retryButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Connecting...';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
statusText.textContent = `Connection error${state.retryCount > 0 ? ` (Retry ${state.retryCount})` : ''}`;
|
||||||
|
this.showOfflineMessage();
|
||||||
|
this.updateQueuedInfo();
|
||||||
|
if (this.retryButton) {
|
||||||
|
this.retryButton.disabled = false;
|
||||||
|
this.retryButton.innerHTML = '<i class="fas fa-sync"></i> Retry';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showOfflineMessage(): void {
|
||||||
|
if (this.offlineMessage) {
|
||||||
|
this.offlineMessage.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.offlineMessage?.classList.add('show');
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideOfflineMessage(): void {
|
||||||
|
if (this.offlineMessage) {
|
||||||
|
this.offlineMessage.classList.remove('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.offlineMessage) {
|
||||||
|
this.offlineMessage.style.display = 'none';
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateQueuedInfo(): void {
|
||||||
|
const queueStatus = this.queueManager.getStatus();
|
||||||
|
const queuedByType = this.queueManager.getQueuedByType();
|
||||||
|
const queuedInfo = this.offlineMessage?.querySelector('.queued-info') as HTMLElement;
|
||||||
|
const queuedCount = this.offlineMessage?.querySelector('.queued-count') as HTMLElement;
|
||||||
|
|
||||||
|
if (queuedInfo && queuedCount) {
|
||||||
|
const totalQueued = queueStatus.queueLength + queueStatus.activeRequests;
|
||||||
|
|
||||||
|
if (totalQueued > 0) {
|
||||||
|
queuedInfo.style.display = 'block';
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (queuedByType.transcribe > 0) {
|
||||||
|
parts.push(`${queuedByType.transcribe} transcription${queuedByType.transcribe > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
if (queuedByType.translate > 0) {
|
||||||
|
parts.push(`${queuedByType.translate} translation${queuedByType.translate > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
if (queuedByType.tts > 0) {
|
||||||
|
parts.push(`${queuedByType.tts} audio generation${queuedByType.tts > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedCount.textContent = `${totalQueued} request${totalQueued > 1 ? 's' : ''} queued${parts.length > 0 ? ': ' + parts.join(', ') : ''}`;
|
||||||
|
} else {
|
||||||
|
queuedInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRetry(): Promise<void> {
|
||||||
|
if (this.retryButton) {
|
||||||
|
this.retryButton.disabled = true;
|
||||||
|
this.retryButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Connecting...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await this.connectionManager.reconnect();
|
||||||
|
|
||||||
|
if (!success && this.retryButton) {
|
||||||
|
this.retryButton.disabled = false;
|
||||||
|
this.retryButton.innerHTML = '<i class="fas fa-sync"></i> Retry';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method to show temporary connection message
|
||||||
|
showTemporaryMessage(message: string, type: 'success' | 'error' | 'warning' = 'success'): void {
|
||||||
|
if (!this.statusElement) return;
|
||||||
|
|
||||||
|
const statusText = this.statusElement.querySelector('.connection-text') as HTMLElement;
|
||||||
|
const originalClass = this.statusElement.className;
|
||||||
|
const originalText = statusText.textContent;
|
||||||
|
|
||||||
|
// Update appearance based on type
|
||||||
|
this.statusElement.className = `connection-status visible ${type === 'success' ? 'online' : type === 'error' ? 'offline' : 'connecting'}`;
|
||||||
|
statusText.textContent = message;
|
||||||
|
|
||||||
|
// Reset after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.statusElement && statusText) {
|
||||||
|
this.statusElement.className = originalClass;
|
||||||
|
statusText.textContent = originalText || '';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
286
static/js/src/errorBoundary.ts
Normal file
286
static/js/src/errorBoundary.ts
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
// Error boundary implementation for better error handling
|
||||||
|
export interface ErrorInfo {
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
component?: string;
|
||||||
|
timestamp: number;
|
||||||
|
userAgent: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary {
|
||||||
|
private static instance: ErrorBoundary;
|
||||||
|
private errorLog: ErrorInfo[] = [];
|
||||||
|
private maxErrorLog = 50;
|
||||||
|
private errorHandlers: Map<string, (error: Error, errorInfo: ErrorInfo) => void> = new Map();
|
||||||
|
private globalErrorHandler: ((error: Error, errorInfo: ErrorInfo) => void) | null = null;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.setupGlobalErrorHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): ErrorBoundary {
|
||||||
|
if (!ErrorBoundary.instance) {
|
||||||
|
ErrorBoundary.instance = new ErrorBoundary();
|
||||||
|
}
|
||||||
|
return ErrorBoundary.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGlobalErrorHandlers(): void {
|
||||||
|
// Handle unhandled errors
|
||||||
|
window.addEventListener('error', (event: ErrorEvent) => {
|
||||||
|
const errorInfo: ErrorInfo = {
|
||||||
|
message: event.message,
|
||||||
|
stack: event.error?.stack,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
url: window.location.href,
|
||||||
|
component: 'global'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logError(event.error || new Error(event.message), errorInfo);
|
||||||
|
this.handleError(event.error || new Error(event.message), errorInfo);
|
||||||
|
|
||||||
|
// Prevent default error handling
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle unhandled promise rejections
|
||||||
|
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
||||||
|
const error = new Error(event.reason?.message || 'Unhandled Promise Rejection');
|
||||||
|
const errorInfo: ErrorInfo = {
|
||||||
|
message: error.message,
|
||||||
|
stack: event.reason?.stack,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
url: window.location.href,
|
||||||
|
component: 'promise'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logError(error, errorInfo);
|
||||||
|
this.handleError(error, errorInfo);
|
||||||
|
|
||||||
|
// Prevent default error handling
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap a function with error boundary
|
||||||
|
wrap<T extends (...args: any[]) => any>(
|
||||||
|
fn: T,
|
||||||
|
component: string,
|
||||||
|
fallback?: (...args: Parameters<T>) => ReturnType<T>
|
||||||
|
): T {
|
||||||
|
return ((...args: Parameters<T>): ReturnType<T> => {
|
||||||
|
try {
|
||||||
|
const result = fn(...args);
|
||||||
|
|
||||||
|
// Handle async functions
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
return result.catch((error: Error) => {
|
||||||
|
const errorInfo: ErrorInfo = {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
component,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
url: window.location.href
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logError(error, errorInfo);
|
||||||
|
this.handleError(error, errorInfo);
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
return fallback(...args) as ReturnType<T>;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}) as ReturnType<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorInfo: ErrorInfo = {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
component,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
url: window.location.href
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logError(error, errorInfo);
|
||||||
|
this.handleError(error, errorInfo);
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
return fallback(...args);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap async functions specifically
|
||||||
|
wrapAsync<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
fn: T,
|
||||||
|
component: string,
|
||||||
|
fallback?: (...args: Parameters<T>) => ReturnType<T>
|
||||||
|
): T {
|
||||||
|
return (async (...args: Parameters<T>) => {
|
||||||
|
try {
|
||||||
|
return await fn(...args);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorInfo: ErrorInfo = {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
component,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
url: window.location.href
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logError(error, errorInfo);
|
||||||
|
this.handleError(error, errorInfo);
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
return fallback(...args);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register component-specific error handler
|
||||||
|
registerErrorHandler(component: string, handler: (error: Error, errorInfo: ErrorInfo) => void): void {
|
||||||
|
this.errorHandlers.set(component, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set global error handler
|
||||||
|
setGlobalErrorHandler(handler: (error: Error, errorInfo: ErrorInfo) => void): void {
|
||||||
|
this.globalErrorHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private logError(error: Error, errorInfo: ErrorInfo): void {
|
||||||
|
// Add to error log
|
||||||
|
this.errorLog.push(errorInfo);
|
||||||
|
|
||||||
|
// Keep only recent errors
|
||||||
|
if (this.errorLog.length > this.maxErrorLog) {
|
||||||
|
this.errorLog.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to console in development
|
||||||
|
console.error(`[${errorInfo.component}] Error:`, error);
|
||||||
|
console.error('Error Info:', errorInfo);
|
||||||
|
|
||||||
|
// Send to monitoring service if available
|
||||||
|
this.sendToMonitoring(error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(error: Error, errorInfo: ErrorInfo): void {
|
||||||
|
// Check for component-specific handler
|
||||||
|
const componentHandler = this.errorHandlers.get(errorInfo.component || '');
|
||||||
|
if (componentHandler) {
|
||||||
|
componentHandler(error, errorInfo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global handler if set
|
||||||
|
if (this.globalErrorHandler) {
|
||||||
|
this.globalErrorHandler(error, errorInfo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error handling
|
||||||
|
this.showErrorNotification(error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showErrorNotification(error: Error, errorInfo: ErrorInfo): void {
|
||||||
|
// Create error notification
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = 'alert alert-danger alert-dismissible fade show position-fixed bottom-0 end-0 m-3';
|
||||||
|
notification.style.zIndex = '9999';
|
||||||
|
notification.style.maxWidth = '400px';
|
||||||
|
|
||||||
|
const isUserFacing = this.isUserFacingError(error);
|
||||||
|
const message = isUserFacing ? error.message : 'An unexpected error occurred. Please try again.';
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
<strong><i class="fas fa-exclamation-circle"></i> Error${errorInfo.component ? ` in ${errorInfo.component}` : ''}</strong>
|
||||||
|
<p class="mb-0">${message}</p>
|
||||||
|
${!isUserFacing ? '<small class="text-muted">The error has been logged for investigation.</small>' : ''}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
// Auto-dismiss after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.remove();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUserFacingError(error: Error): boolean {
|
||||||
|
// Determine if error should be shown to user as-is
|
||||||
|
const userFacingMessages = [
|
||||||
|
'rate limit',
|
||||||
|
'network',
|
||||||
|
'offline',
|
||||||
|
'not found',
|
||||||
|
'unauthorized',
|
||||||
|
'forbidden',
|
||||||
|
'timeout',
|
||||||
|
'invalid'
|
||||||
|
];
|
||||||
|
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
return userFacingMessages.some(msg => message.includes(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendToMonitoring(error: Error, errorInfo: ErrorInfo): Promise<void> {
|
||||||
|
// Only send errors in production
|
||||||
|
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send error to backend monitoring endpoint
|
||||||
|
await fetch('/api/log-error', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
name: error.name
|
||||||
|
},
|
||||||
|
errorInfo
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (monitoringError) {
|
||||||
|
// Fail silently - don't create error loop
|
||||||
|
console.error('Failed to send error to monitoring:', monitoringError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get error log for debugging
|
||||||
|
getErrorLog(): ErrorInfo[] {
|
||||||
|
return [...this.errorLog];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear error log
|
||||||
|
clearErrorLog(): void {
|
||||||
|
this.errorLog = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if component has recent errors
|
||||||
|
hasRecentErrors(component: string, timeWindow: number = 60000): boolean {
|
||||||
|
const cutoff = Date.now() - timeWindow;
|
||||||
|
return this.errorLog.some(
|
||||||
|
error => error.component === component && error.timestamp > cutoff
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
309
static/js/src/memoryManager.ts
Normal file
309
static/js/src/memoryManager.ts
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* Memory management utilities for preventing leaks in audio handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class MemoryManager {
|
||||||
|
private static instance: MemoryManager;
|
||||||
|
private audioContexts: Set<AudioContext> = new Set();
|
||||||
|
private objectURLs: Set<string> = new Set();
|
||||||
|
private mediaStreams: Set<MediaStream> = new Set();
|
||||||
|
private intervals: Set<number> = new Set();
|
||||||
|
private timeouts: Set<number> = new Set();
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// Set up periodic cleanup
|
||||||
|
this.startPeriodicCleanup();
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
window.addEventListener('beforeunload', () => this.cleanup());
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): MemoryManager {
|
||||||
|
if (!MemoryManager.instance) {
|
||||||
|
MemoryManager.instance = new MemoryManager();
|
||||||
|
}
|
||||||
|
return MemoryManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an AudioContext for cleanup
|
||||||
|
*/
|
||||||
|
registerAudioContext(context: AudioContext): void {
|
||||||
|
this.audioContexts.add(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an object URL for cleanup
|
||||||
|
*/
|
||||||
|
registerObjectURL(url: string): void {
|
||||||
|
this.objectURLs.add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a MediaStream for cleanup
|
||||||
|
*/
|
||||||
|
registerMediaStream(stream: MediaStream): void {
|
||||||
|
this.mediaStreams.add(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an interval for cleanup
|
||||||
|
*/
|
||||||
|
registerInterval(id: number): void {
|
||||||
|
this.intervals.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a timeout for cleanup
|
||||||
|
*/
|
||||||
|
registerTimeout(id: number): void {
|
||||||
|
this.timeouts.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up a specific AudioContext
|
||||||
|
*/
|
||||||
|
cleanupAudioContext(context: AudioContext): void {
|
||||||
|
if (context.state !== 'closed') {
|
||||||
|
context.close().catch(console.error);
|
||||||
|
}
|
||||||
|
this.audioContexts.delete(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up a specific object URL
|
||||||
|
*/
|
||||||
|
cleanupObjectURL(url: string): void {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
this.objectURLs.delete(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up a specific MediaStream
|
||||||
|
*/
|
||||||
|
cleanupMediaStream(stream: MediaStream): void {
|
||||||
|
stream.getTracks().forEach(track => {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
this.mediaStreams.delete(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all resources
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
// Clean up audio contexts
|
||||||
|
this.audioContexts.forEach(context => {
|
||||||
|
if (context.state !== 'closed') {
|
||||||
|
context.close().catch(console.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.audioContexts.clear();
|
||||||
|
|
||||||
|
// Clean up object URLs
|
||||||
|
this.objectURLs.forEach(url => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
this.objectURLs.clear();
|
||||||
|
|
||||||
|
// Clean up media streams
|
||||||
|
this.mediaStreams.forEach(stream => {
|
||||||
|
stream.getTracks().forEach(track => {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.mediaStreams.clear();
|
||||||
|
|
||||||
|
// Clear intervals and timeouts
|
||||||
|
this.intervals.forEach(id => clearInterval(id));
|
||||||
|
this.intervals.clear();
|
||||||
|
|
||||||
|
this.timeouts.forEach(id => clearTimeout(id));
|
||||||
|
this.timeouts.clear();
|
||||||
|
|
||||||
|
console.log('Memory cleanup completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get memory usage statistics
|
||||||
|
*/
|
||||||
|
getStats(): MemoryStats {
|
||||||
|
return {
|
||||||
|
audioContexts: this.audioContexts.size,
|
||||||
|
objectURLs: this.objectURLs.size,
|
||||||
|
mediaStreams: this.mediaStreams.size,
|
||||||
|
intervals: this.intervals.size,
|
||||||
|
timeouts: this.timeouts.size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic cleanup of orphaned resources
|
||||||
|
*/
|
||||||
|
private startPeriodicCleanup(): void {
|
||||||
|
setInterval(() => {
|
||||||
|
// Clean up closed audio contexts
|
||||||
|
this.audioContexts.forEach(context => {
|
||||||
|
if (context.state === 'closed') {
|
||||||
|
this.audioContexts.delete(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up stopped media streams
|
||||||
|
this.mediaStreams.forEach(stream => {
|
||||||
|
const activeTracks = stream.getTracks().filter(track => track.readyState === 'live');
|
||||||
|
if (activeTracks.length === 0) {
|
||||||
|
this.mediaStreams.delete(stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log stats in development
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const stats = this.getStats();
|
||||||
|
if (Object.values(stats).some(v => v > 0)) {
|
||||||
|
console.log('Memory manager stats:', stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000); // Every 30 seconds
|
||||||
|
|
||||||
|
// Don't track this interval to avoid self-reference
|
||||||
|
// It will be cleared on page unload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoryStats {
|
||||||
|
audioContexts: number;
|
||||||
|
objectURLs: number;
|
||||||
|
mediaStreams: number;
|
||||||
|
intervals: number;
|
||||||
|
timeouts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for safe audio blob handling
|
||||||
|
*/
|
||||||
|
export class AudioBlobHandler {
|
||||||
|
private blob: Blob;
|
||||||
|
private objectURL?: string;
|
||||||
|
private memoryManager: MemoryManager;
|
||||||
|
|
||||||
|
constructor(blob: Blob) {
|
||||||
|
this.blob = blob;
|
||||||
|
this.memoryManager = MemoryManager.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get object URL (creates one if needed)
|
||||||
|
*/
|
||||||
|
getObjectURL(): string {
|
||||||
|
if (!this.objectURL) {
|
||||||
|
this.objectURL = URL.createObjectURL(this.blob);
|
||||||
|
this.memoryManager.registerObjectURL(this.objectURL);
|
||||||
|
}
|
||||||
|
return this.objectURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the blob
|
||||||
|
*/
|
||||||
|
getBlob(): Blob {
|
||||||
|
return this.blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
if (this.objectURL) {
|
||||||
|
this.memoryManager.cleanupObjectURL(this.objectURL);
|
||||||
|
this.objectURL = undefined;
|
||||||
|
}
|
||||||
|
// Help garbage collection
|
||||||
|
(this.blob as any) = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe MediaRecorder wrapper
|
||||||
|
*/
|
||||||
|
export class SafeMediaRecorder {
|
||||||
|
private mediaRecorder?: MediaRecorder;
|
||||||
|
private stream?: MediaStream;
|
||||||
|
private chunks: Blob[] = [];
|
||||||
|
private memoryManager: MemoryManager;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.memoryManager = MemoryManager.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(constraints: MediaStreamConstraints = { audio: true }): Promise<void> {
|
||||||
|
// Clean up any existing recorder
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
this.memoryManager.registerMediaStream(this.stream);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||||
|
? 'audio/webm;codecs=opus'
|
||||||
|
: 'audio/webm'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mediaRecorder = new MediaRecorder(this.stream, options);
|
||||||
|
this.chunks = [];
|
||||||
|
|
||||||
|
this.mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
this.chunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mediaRecorder.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.mediaRecorder) {
|
||||||
|
reject(new Error('MediaRecorder not initialized'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mediaRecorder.onstop = () => {
|
||||||
|
const blob = new Blob(this.chunks, {
|
||||||
|
type: this.mediaRecorder?.mimeType || 'audio/webm'
|
||||||
|
});
|
||||||
|
resolve(blob);
|
||||||
|
|
||||||
|
// Clean up after delivering the blob
|
||||||
|
setTimeout(() => this.cleanup(), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mediaRecorder.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
if (this.stream) {
|
||||||
|
this.memoryManager.cleanupMediaStream(this.stream);
|
||||||
|
this.stream = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mediaRecorder) {
|
||||||
|
if (this.mediaRecorder.state !== 'inactive') {
|
||||||
|
try {
|
||||||
|
this.mediaRecorder.stop();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.mediaRecorder = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear chunks
|
||||||
|
this.chunks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecording(): boolean {
|
||||||
|
return this.mediaRecorder?.state === 'recording';
|
||||||
|
}
|
||||||
|
}
|
147
static/js/src/performanceMonitor.ts
Normal file
147
static/js/src/performanceMonitor.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
// Performance monitoring for translation latency
|
||||||
|
export class PerformanceMonitor {
|
||||||
|
private static instance: PerformanceMonitor;
|
||||||
|
private metrics: Map<string, number[]> = new Map();
|
||||||
|
private timers: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): PerformanceMonitor {
|
||||||
|
if (!PerformanceMonitor.instance) {
|
||||||
|
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||||
|
}
|
||||||
|
return PerformanceMonitor.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start timing an operation
|
||||||
|
startTimer(operation: string): void {
|
||||||
|
this.timers.set(operation, performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// End timing and record the duration
|
||||||
|
endTimer(operation: string): number {
|
||||||
|
const startTime = this.timers.get(operation);
|
||||||
|
if (!startTime) {
|
||||||
|
console.warn(`No start time found for operation: ${operation}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
this.recordMetric(operation, duration);
|
||||||
|
this.timers.delete(operation);
|
||||||
|
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a metric value
|
||||||
|
recordMetric(name: string, value: number): void {
|
||||||
|
if (!this.metrics.has(name)) {
|
||||||
|
this.metrics.set(name, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = this.metrics.get(name)!;
|
||||||
|
values.push(value);
|
||||||
|
|
||||||
|
// Keep only last 100 values
|
||||||
|
if (values.length > 100) {
|
||||||
|
values.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get average metric value
|
||||||
|
getAverageMetric(name: string): number {
|
||||||
|
const values = this.metrics.get(name);
|
||||||
|
if (!values || values.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = values.reduce((a, b) => a + b, 0);
|
||||||
|
return sum / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get time to first byte (TTFB) for streaming
|
||||||
|
measureTTFB(operation: string, firstByteTime: number): number {
|
||||||
|
const startTime = this.timers.get(operation);
|
||||||
|
if (!startTime) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttfb = firstByteTime - startTime;
|
||||||
|
this.recordMetric(`${operation}_ttfb`, ttfb);
|
||||||
|
return ttfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get performance summary
|
||||||
|
getPerformanceSummary(): {
|
||||||
|
streaming: {
|
||||||
|
avgTotalTime: number;
|
||||||
|
avgTTFB: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
regular: {
|
||||||
|
avgTotalTime: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
improvement: {
|
||||||
|
ttfbReduction: number;
|
||||||
|
perceivedLatencyReduction: number;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const streamingTotal = this.getAverageMetric('streaming_translation');
|
||||||
|
const streamingTTFB = this.getAverageMetric('streaming_translation_ttfb');
|
||||||
|
const streamingCount = this.metrics.get('streaming_translation')?.length || 0;
|
||||||
|
|
||||||
|
const regularTotal = this.getAverageMetric('regular_translation');
|
||||||
|
const regularCount = this.metrics.get('regular_translation')?.length || 0;
|
||||||
|
|
||||||
|
// Calculate improvements
|
||||||
|
const ttfbReduction = regularTotal > 0 && streamingTTFB > 0
|
||||||
|
? ((regularTotal - streamingTTFB) / regularTotal) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Perceived latency is based on TTFB for streaming vs total time for regular
|
||||||
|
const perceivedLatencyReduction = ttfbReduction;
|
||||||
|
|
||||||
|
return {
|
||||||
|
streaming: {
|
||||||
|
avgTotalTime: streamingTotal,
|
||||||
|
avgTTFB: streamingTTFB,
|
||||||
|
count: streamingCount
|
||||||
|
},
|
||||||
|
regular: {
|
||||||
|
avgTotalTime: regularTotal,
|
||||||
|
count: regularCount
|
||||||
|
},
|
||||||
|
improvement: {
|
||||||
|
ttfbReduction: Math.round(ttfbReduction),
|
||||||
|
perceivedLatencyReduction: Math.round(perceivedLatencyReduction)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log performance stats to console
|
||||||
|
logPerformanceStats(): void {
|
||||||
|
const summary = this.getPerformanceSummary();
|
||||||
|
|
||||||
|
console.group('Translation Performance Stats');
|
||||||
|
console.log('Streaming Translation:');
|
||||||
|
console.log(` Average Total Time: ${summary.streaming.avgTotalTime.toFixed(2)}ms`);
|
||||||
|
console.log(` Average TTFB: ${summary.streaming.avgTTFB.toFixed(2)}ms`);
|
||||||
|
console.log(` Sample Count: ${summary.streaming.count}`);
|
||||||
|
|
||||||
|
console.log('Regular Translation:');
|
||||||
|
console.log(` Average Total Time: ${summary.regular.avgTotalTime.toFixed(2)}ms`);
|
||||||
|
console.log(` Sample Count: ${summary.regular.count}`);
|
||||||
|
|
||||||
|
console.log('Improvements:');
|
||||||
|
console.log(` TTFB Reduction: ${summary.improvement.ttfbReduction}%`);
|
||||||
|
console.log(` Perceived Latency Reduction: ${summary.improvement.perceivedLatencyReduction}%`);
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all metrics
|
||||||
|
clearMetrics(): void {
|
||||||
|
this.metrics.clear();
|
||||||
|
this.timers.clear();
|
||||||
|
}
|
||||||
|
}
|
333
static/js/src/requestQueue.ts
Normal file
333
static/js/src/requestQueue.ts
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
// Request queue and throttling manager
|
||||||
|
import { ConnectionManager, ConnectionState } from './connectionManager';
|
||||||
|
|
||||||
|
export interface QueuedRequest {
|
||||||
|
id: string;
|
||||||
|
type: 'transcribe' | 'translate' | 'tts';
|
||||||
|
request: () => Promise<any>;
|
||||||
|
resolve: (value: any) => void;
|
||||||
|
reject: (reason?: any) => void;
|
||||||
|
retryCount: number;
|
||||||
|
priority: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RequestQueueManager {
|
||||||
|
private static instance: RequestQueueManager;
|
||||||
|
private queue: QueuedRequest[] = [];
|
||||||
|
private activeRequests: Map<string, QueuedRequest> = new Map();
|
||||||
|
private maxConcurrent = 2; // Maximum concurrent requests
|
||||||
|
private maxRetries = 3;
|
||||||
|
private retryDelay = 1000; // Base retry delay in ms
|
||||||
|
private isProcessing = false;
|
||||||
|
private connectionManager: ConnectionManager;
|
||||||
|
private isPaused = false;
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
private requestHistory: number[] = [];
|
||||||
|
private maxRequestsPerMinute = 30;
|
||||||
|
private maxRequestsPerSecond = 2;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.connectionManager = ConnectionManager.getInstance();
|
||||||
|
|
||||||
|
// Subscribe to connection state changes
|
||||||
|
this.connectionManager.subscribe('request-queue', (state: ConnectionState) => {
|
||||||
|
this.handleConnectionStateChange(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start processing queue
|
||||||
|
this.startProcessing();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): RequestQueueManager {
|
||||||
|
if (!RequestQueueManager.instance) {
|
||||||
|
RequestQueueManager.instance = new RequestQueueManager();
|
||||||
|
}
|
||||||
|
return RequestQueueManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add request to queue
|
||||||
|
async enqueue<T>(
|
||||||
|
type: 'transcribe' | 'translate' | 'tts',
|
||||||
|
request: () => Promise<T>,
|
||||||
|
priority: number = 5
|
||||||
|
): Promise<T> {
|
||||||
|
// Check rate limits
|
||||||
|
if (!this.checkRateLimits()) {
|
||||||
|
throw new Error('Rate limit exceeded. Please slow down.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = this.generateId();
|
||||||
|
const queuedRequest: QueuedRequest = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
request,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
retryCount: 0,
|
||||||
|
priority,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to queue based on priority
|
||||||
|
this.addToQueue(queuedRequest);
|
||||||
|
|
||||||
|
// Log queue status
|
||||||
|
console.log(`Request queued: ${type}, Queue size: ${this.queue.length}, Active: ${this.activeRequests.size}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addToQueue(request: QueuedRequest): void {
|
||||||
|
// Insert based on priority (higher priority first)
|
||||||
|
const insertIndex = this.queue.findIndex(item => item.priority < request.priority);
|
||||||
|
if (insertIndex === -1) {
|
||||||
|
this.queue.push(request);
|
||||||
|
} else {
|
||||||
|
this.queue.splice(insertIndex, 0, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkRateLimits(): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Clean old entries
|
||||||
|
this.requestHistory = this.requestHistory.filter(
|
||||||
|
time => now - time < 60000 // Keep last minute
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check per-second limit
|
||||||
|
const lastSecond = this.requestHistory.filter(
|
||||||
|
time => now - time < 1000
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (lastSecond >= this.maxRequestsPerSecond) {
|
||||||
|
console.warn('Per-second rate limit reached');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-minute limit
|
||||||
|
if (this.requestHistory.length >= this.maxRequestsPerMinute) {
|
||||||
|
console.warn('Per-minute rate limit reached');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this request
|
||||||
|
this.requestHistory.push(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startProcessing(): Promise<void> {
|
||||||
|
if (this.isProcessing) return;
|
||||||
|
this.isProcessing = true;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
await this.processQueue();
|
||||||
|
await this.delay(100); // Check queue every 100ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueue(): Promise<void> {
|
||||||
|
// Check if we're paused or can't process more requests
|
||||||
|
if (this.isPaused || this.activeRequests.size >= this.maxConcurrent || this.queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're online
|
||||||
|
if (!this.connectionManager.isOnline()) {
|
||||||
|
console.log('Queue processing paused - offline');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next request
|
||||||
|
const request = this.queue.shift();
|
||||||
|
if (!request) return;
|
||||||
|
|
||||||
|
// Mark as active
|
||||||
|
this.activeRequests.set(request.id, request);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute request with connection manager retry logic
|
||||||
|
const result = await this.connectionManager.retryRequest(
|
||||||
|
request.request,
|
||||||
|
{
|
||||||
|
retries: this.maxRetries - request.retryCount,
|
||||||
|
delay: this.calculateRetryDelay(request.retryCount + 1),
|
||||||
|
onRetry: (attempt, error) => {
|
||||||
|
console.log(`Retry ${attempt} for ${request.type}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
request.resolve(result);
|
||||||
|
console.log(`Request completed: ${request.type}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Request failed after retries: ${request.type}`, error);
|
||||||
|
|
||||||
|
// Check if it's a connection error and we should queue for later
|
||||||
|
if (this.isConnectionError(error) && request.retryCount < this.maxRetries) {
|
||||||
|
request.retryCount++;
|
||||||
|
console.log(`Re-queuing ${request.type} due to connection error`);
|
||||||
|
|
||||||
|
// Re-queue with higher priority
|
||||||
|
request.priority = Math.max(request.priority + 1, 10);
|
||||||
|
this.addToQueue(request);
|
||||||
|
} else {
|
||||||
|
// Non-recoverable error or max retries reached
|
||||||
|
request.reject(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Remove from active
|
||||||
|
this.activeRequests.delete(request.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: shouldRetry logic is now handled by ConnectionManager
|
||||||
|
// Keeping for reference but not used directly
|
||||||
|
|
||||||
|
private calculateRetryDelay(retryCount: number): number {
|
||||||
|
// Exponential backoff with jitter
|
||||||
|
const baseDelay = this.retryDelay * Math.pow(2, retryCount - 1);
|
||||||
|
const jitter = Math.random() * 0.3 * baseDelay; // 30% jitter
|
||||||
|
return Math.min(baseDelay + jitter, 30000); // Max 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get queue status
|
||||||
|
getStatus(): {
|
||||||
|
queueLength: number;
|
||||||
|
activeRequests: number;
|
||||||
|
requestsPerMinute: number;
|
||||||
|
} {
|
||||||
|
const now = Date.now();
|
||||||
|
const recentRequests = this.requestHistory.filter(
|
||||||
|
time => now - time < 60000
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
queueLength: this.queue.length,
|
||||||
|
activeRequests: this.activeRequests.size,
|
||||||
|
requestsPerMinute: recentRequests
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear queue (for emergency use)
|
||||||
|
clearQueue(): void {
|
||||||
|
this.queue.forEach(request => {
|
||||||
|
request.reject(new Error('Queue cleared'));
|
||||||
|
});
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stuck requests (requests older than 60 seconds)
|
||||||
|
clearStuckRequests(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const stuckThreshold = 60000; // 60 seconds
|
||||||
|
|
||||||
|
// Clear stuck active requests
|
||||||
|
this.activeRequests.forEach((request, id) => {
|
||||||
|
if (now - request.timestamp > stuckThreshold) {
|
||||||
|
console.warn(`Clearing stuck active request: ${request.type}`);
|
||||||
|
request.reject(new Error('Request timeout - cleared by recovery'));
|
||||||
|
this.activeRequests.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear old queued requests
|
||||||
|
this.queue = this.queue.filter(request => {
|
||||||
|
if (now - request.timestamp > stuckThreshold) {
|
||||||
|
console.warn(`Clearing stuck queued request: ${request.type}`);
|
||||||
|
request.reject(new Error('Request timeout - cleared by recovery'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
updateSettings(settings: {
|
||||||
|
maxConcurrent?: number;
|
||||||
|
maxRequestsPerMinute?: number;
|
||||||
|
maxRequestsPerSecond?: number;
|
||||||
|
}): void {
|
||||||
|
if (settings.maxConcurrent !== undefined) {
|
||||||
|
this.maxConcurrent = settings.maxConcurrent;
|
||||||
|
}
|
||||||
|
if (settings.maxRequestsPerMinute !== undefined) {
|
||||||
|
this.maxRequestsPerMinute = settings.maxRequestsPerMinute;
|
||||||
|
}
|
||||||
|
if (settings.maxRequestsPerSecond !== undefined) {
|
||||||
|
this.maxRequestsPerSecond = settings.maxRequestsPerSecond;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle connection state changes
|
||||||
|
private handleConnectionStateChange(state: ConnectionState): void {
|
||||||
|
console.log(`Connection state changed: ${state.status}`);
|
||||||
|
|
||||||
|
if (state.status === 'offline' || state.status === 'error') {
|
||||||
|
// Pause processing when offline
|
||||||
|
this.isPaused = true;
|
||||||
|
|
||||||
|
// Notify queued requests about offline status
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
console.log(`${this.queue.length} requests queued while offline`);
|
||||||
|
}
|
||||||
|
} else if (state.status === 'online') {
|
||||||
|
// Resume processing when back online
|
||||||
|
this.isPaused = false;
|
||||||
|
console.log('Connection restored, resuming queue processing');
|
||||||
|
|
||||||
|
// Process any queued requests
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
console.log(`Processing ${this.queue.length} queued requests`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if error is connection-related
|
||||||
|
private isConnectionError(error: any): boolean {
|
||||||
|
const errorMessage = error.message?.toLowerCase() || '';
|
||||||
|
const connectionErrors = [
|
||||||
|
'network',
|
||||||
|
'fetch',
|
||||||
|
'connection',
|
||||||
|
'timeout',
|
||||||
|
'offline',
|
||||||
|
'cors'
|
||||||
|
];
|
||||||
|
|
||||||
|
return connectionErrors.some(e => errorMessage.includes(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause queue processing
|
||||||
|
pause(): void {
|
||||||
|
this.isPaused = true;
|
||||||
|
console.log('Request queue paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume queue processing
|
||||||
|
resume(): void {
|
||||||
|
this.isPaused = false;
|
||||||
|
console.log('Request queue resumed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get number of queued requests by type
|
||||||
|
getQueuedByType(): { transcribe: number; translate: number; tts: number } {
|
||||||
|
const counts = { transcribe: 0, translate: 0, tts: 0 };
|
||||||
|
|
||||||
|
this.queue.forEach(request => {
|
||||||
|
counts[request.type]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
}
|
270
static/js/src/speakerManager.ts
Normal file
270
static/js/src/speakerManager.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
// Speaker management for multi-speaker support
|
||||||
|
export interface Speaker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
language: string;
|
||||||
|
color: string;
|
||||||
|
avatar?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
lastActiveTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakerTranscription {
|
||||||
|
speakerId: string;
|
||||||
|
text: string;
|
||||||
|
language: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationEntry {
|
||||||
|
id: string;
|
||||||
|
speakerId: string;
|
||||||
|
originalText: string;
|
||||||
|
originalLanguage: string;
|
||||||
|
translations: Map<string, string>; // languageCode -> translatedText
|
||||||
|
timestamp: number;
|
||||||
|
audioUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpeakerManager {
|
||||||
|
private static instance: SpeakerManager;
|
||||||
|
private speakers: Map<string, Speaker> = new Map();
|
||||||
|
private conversation: ConversationEntry[] = [];
|
||||||
|
private activeSpeakerId: string | null = null;
|
||||||
|
private maxConversationLength = 100;
|
||||||
|
|
||||||
|
// Predefined colors for speakers
|
||||||
|
private speakerColors = [
|
||||||
|
'#007bff', '#28a745', '#dc3545', '#ffc107',
|
||||||
|
'#17a2b8', '#6f42c1', '#e83e8c', '#fd7e14'
|
||||||
|
];
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): SpeakerManager {
|
||||||
|
if (!SpeakerManager.instance) {
|
||||||
|
SpeakerManager.instance = new SpeakerManager();
|
||||||
|
}
|
||||||
|
return SpeakerManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new speaker
|
||||||
|
addSpeaker(name: string, language: string): Speaker {
|
||||||
|
const id = this.generateSpeakerId();
|
||||||
|
const colorIndex = this.speakers.size % this.speakerColors.length;
|
||||||
|
|
||||||
|
const speaker: Speaker = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
language,
|
||||||
|
color: this.speakerColors[colorIndex],
|
||||||
|
isActive: false,
|
||||||
|
avatar: this.generateAvatar(name)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.speakers.set(id, speaker);
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
|
||||||
|
return speaker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update speaker
|
||||||
|
updateSpeaker(id: string, updates: Partial<Speaker>): void {
|
||||||
|
const speaker = this.speakers.get(id);
|
||||||
|
if (speaker) {
|
||||||
|
Object.assign(speaker, updates);
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove speaker
|
||||||
|
removeSpeaker(id: string): void {
|
||||||
|
this.speakers.delete(id);
|
||||||
|
if (this.activeSpeakerId === id) {
|
||||||
|
this.activeSpeakerId = null;
|
||||||
|
}
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all speakers
|
||||||
|
getAllSpeakers(): Speaker[] {
|
||||||
|
return Array.from(this.speakers.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get speaker by ID
|
||||||
|
getSpeaker(id: string): Speaker | undefined {
|
||||||
|
return this.speakers.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set active speaker
|
||||||
|
setActiveSpeaker(id: string | null): void {
|
||||||
|
// Deactivate all speakers
|
||||||
|
this.speakers.forEach(speaker => {
|
||||||
|
speaker.isActive = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate selected speaker
|
||||||
|
if (id && this.speakers.has(id)) {
|
||||||
|
const speaker = this.speakers.get(id)!;
|
||||||
|
speaker.isActive = true;
|
||||||
|
speaker.lastActiveTime = Date.now();
|
||||||
|
this.activeSpeakerId = id;
|
||||||
|
} else {
|
||||||
|
this.activeSpeakerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active speaker
|
||||||
|
getActiveSpeaker(): Speaker | null {
|
||||||
|
return this.activeSpeakerId ? this.speakers.get(this.activeSpeakerId) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add conversation entry
|
||||||
|
addConversationEntry(
|
||||||
|
speakerId: string,
|
||||||
|
originalText: string,
|
||||||
|
originalLanguage: string
|
||||||
|
): ConversationEntry {
|
||||||
|
const entry: ConversationEntry = {
|
||||||
|
id: this.generateEntryId(),
|
||||||
|
speakerId,
|
||||||
|
originalText,
|
||||||
|
originalLanguage,
|
||||||
|
translations: new Map(),
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.conversation.push(entry);
|
||||||
|
|
||||||
|
// Limit conversation length
|
||||||
|
if (this.conversation.length > this.maxConversationLength) {
|
||||||
|
this.conversation.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add translation to conversation entry
|
||||||
|
addTranslation(entryId: string, language: string, translatedText: string): void {
|
||||||
|
const entry = this.conversation.find(e => e.id === entryId);
|
||||||
|
if (entry) {
|
||||||
|
entry.translations.set(language, translatedText);
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get conversation for a specific language
|
||||||
|
getConversationInLanguage(language: string): Array<{
|
||||||
|
speakerId: string;
|
||||||
|
speakerName: string;
|
||||||
|
speakerColor: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: number;
|
||||||
|
isOriginal: boolean;
|
||||||
|
}> {
|
||||||
|
return this.conversation.map(entry => {
|
||||||
|
const speaker = this.speakers.get(entry.speakerId);
|
||||||
|
const isOriginal = entry.originalLanguage === language;
|
||||||
|
const text = isOriginal ?
|
||||||
|
entry.originalText :
|
||||||
|
entry.translations.get(language) || `[Translating from ${entry.originalLanguage}...]`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
speakerId: entry.speakerId,
|
||||||
|
speakerName: speaker?.name || 'Unknown',
|
||||||
|
speakerColor: speaker?.color || '#666',
|
||||||
|
text,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
isOriginal
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full conversation history
|
||||||
|
getFullConversation(): ConversationEntry[] {
|
||||||
|
return [...this.conversation];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear conversation
|
||||||
|
clearConversation(): void {
|
||||||
|
this.conversation = [];
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique speaker ID
|
||||||
|
private generateSpeakerId(): string {
|
||||||
|
return `speaker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique entry ID
|
||||||
|
private generateEntryId(): string {
|
||||||
|
return `entry_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate avatar initials
|
||||||
|
private generateAvatar(name: string): string {
|
||||||
|
const parts = name.trim().split(' ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return parts[0][0].toUpperCase() + parts[1][0].toUpperCase();
|
||||||
|
}
|
||||||
|
return name.substr(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
private saveToLocalStorage(): void {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
speakers: Array.from(this.speakers.entries()),
|
||||||
|
conversation: this.conversation.map(entry => ({
|
||||||
|
...entry,
|
||||||
|
translations: Array.from(entry.translations.entries())
|
||||||
|
})),
|
||||||
|
activeSpeakerId: this.activeSpeakerId
|
||||||
|
};
|
||||||
|
localStorage.setItem('speakerData', JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save speaker data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from localStorage
|
||||||
|
private loadFromLocalStorage(): void {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('speakerData');
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
|
||||||
|
// Restore speakers
|
||||||
|
if (data.speakers) {
|
||||||
|
this.speakers = new Map(data.speakers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore conversation with Map translations
|
||||||
|
if (data.conversation) {
|
||||||
|
this.conversation = data.conversation.map((entry: any) => ({
|
||||||
|
...entry,
|
||||||
|
translations: new Map(entry.translations || [])
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore active speaker
|
||||||
|
this.activeSpeakerId = data.activeSpeakerId || null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load speaker data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export conversation as text
|
||||||
|
exportConversation(language: string): string {
|
||||||
|
const entries = this.getConversationInLanguage(language);
|
||||||
|
return entries.map(entry =>
|
||||||
|
`[${new Date(entry.timestamp).toLocaleTimeString()}] ${entry.speakerName}: ${entry.text}`
|
||||||
|
).join('\n');
|
||||||
|
}
|
||||||
|
}
|
250
static/js/src/streamingTranslation.ts
Normal file
250
static/js/src/streamingTranslation.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
// Streaming translation implementation for reduced latency
|
||||||
|
import { Validator } from './validator';
|
||||||
|
import { PerformanceMonitor } from './performanceMonitor';
|
||||||
|
|
||||||
|
export interface StreamChunk {
|
||||||
|
type: 'start' | 'chunk' | 'complete' | 'error';
|
||||||
|
text?: string;
|
||||||
|
full_text?: string;
|
||||||
|
error?: string;
|
||||||
|
source_lang?: string;
|
||||||
|
target_lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StreamingTranslation {
|
||||||
|
private eventSource: EventSource | null = null;
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
private performanceMonitor = PerformanceMonitor.getInstance();
|
||||||
|
private firstChunkReceived = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private onChunk: (text: string) => void,
|
||||||
|
private onComplete: (fullText: string) => void,
|
||||||
|
private onError: (error: string) => void,
|
||||||
|
private onStart?: () => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async startStreaming(
|
||||||
|
text: string,
|
||||||
|
sourceLang: string,
|
||||||
|
targetLang: string,
|
||||||
|
useStreaming: boolean = true
|
||||||
|
): Promise<void> {
|
||||||
|
// Cancel any existing stream
|
||||||
|
this.cancel();
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const sanitizedText = Validator.sanitizeText(text);
|
||||||
|
if (!sanitizedText) {
|
||||||
|
this.onError('No text to translate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!useStreaming) {
|
||||||
|
// Fall back to regular translation
|
||||||
|
await this.fallbackToRegularTranslation(sanitizedText, sourceLang, targetLang);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if browser supports EventSource
|
||||||
|
if (!window.EventSource) {
|
||||||
|
console.warn('EventSource not supported, falling back to regular translation');
|
||||||
|
await this.fallbackToRegularTranslation(sanitizedText, sourceLang, targetLang);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify start
|
||||||
|
if (this.onStart) {
|
||||||
|
this.onStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start performance timing
|
||||||
|
this.performanceMonitor.startTimer('streaming_translation');
|
||||||
|
this.firstChunkReceived = false;
|
||||||
|
|
||||||
|
// Create abort controller for cleanup
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
|
// Start streaming request
|
||||||
|
const response = await fetch('/translate/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: sanitizedText,
|
||||||
|
source_lang: sourceLang,
|
||||||
|
target_lang: targetLang
|
||||||
|
}),
|
||||||
|
signal: this.abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if response is event-stream
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('text/event-stream')) {
|
||||||
|
throw new Error('Server does not support streaming');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the stream
|
||||||
|
await this.processStream(response);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
console.log('Stream cancelled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Streaming error:', error);
|
||||||
|
|
||||||
|
// Fall back to regular translation on error
|
||||||
|
await this.fallbackToRegularTranslation(sanitizedText, sourceLang, targetLang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processStream(response: Response): Promise<void> {
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Process complete SSE messages
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6)) as StreamChunk;
|
||||||
|
this.handleStreamChunk(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse SSE data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleStreamChunk(chunk: StreamChunk): void {
|
||||||
|
switch (chunk.type) {
|
||||||
|
case 'start':
|
||||||
|
console.log('Translation started:', chunk.source_lang, '->', chunk.target_lang);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'chunk':
|
||||||
|
if (chunk.text) {
|
||||||
|
// Record time to first byte
|
||||||
|
if (!this.firstChunkReceived) {
|
||||||
|
this.firstChunkReceived = true;
|
||||||
|
this.performanceMonitor.measureTTFB('streaming_translation', performance.now());
|
||||||
|
}
|
||||||
|
this.onChunk(chunk.text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
if (chunk.full_text) {
|
||||||
|
// End performance timing
|
||||||
|
this.performanceMonitor.endTimer('streaming_translation');
|
||||||
|
this.onComplete(chunk.full_text);
|
||||||
|
|
||||||
|
// Log performance stats periodically
|
||||||
|
if (Math.random() < 0.1) { // 10% of the time
|
||||||
|
this.performanceMonitor.logPerformanceStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
this.onError(chunk.error || 'Unknown streaming error');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fallbackToRegularTranslation(
|
||||||
|
text: string,
|
||||||
|
sourceLang: string,
|
||||||
|
targetLang: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: text,
|
||||||
|
source_lang: sourceLang,
|
||||||
|
target_lang: targetLang
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.translation) {
|
||||||
|
// Simulate streaming by showing text progressively
|
||||||
|
this.simulateStreaming(data.translation);
|
||||||
|
} else {
|
||||||
|
this.onError(data.error || 'Translation failed');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.onError(error.message || 'Translation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private simulateStreaming(text: string): void {
|
||||||
|
// Simulate streaming for better UX even with non-streaming response
|
||||||
|
const words = text.split(' ');
|
||||||
|
let index = 0;
|
||||||
|
let accumulated = '';
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (index >= words.length) {
|
||||||
|
clearInterval(interval);
|
||||||
|
this.onComplete(accumulated.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = words[index] + (index < words.length - 1 ? ' ' : '');
|
||||||
|
accumulated += chunk;
|
||||||
|
this.onChunk(chunk);
|
||||||
|
index++;
|
||||||
|
}, 50); // 50ms between words for smooth appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController.abort();
|
||||||
|
this.abortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.eventSource) {
|
||||||
|
this.eventSource.close();
|
||||||
|
this.eventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
243
static/js/src/translationCache.ts
Normal file
243
static/js/src/translationCache.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
// Translation cache management for offline support
|
||||||
|
import { TranslationCacheEntry, CacheStats } from './types';
|
||||||
|
import { Validator } from './validator';
|
||||||
|
|
||||||
|
export class TranslationCache {
|
||||||
|
private static DB_NAME = 'VoiceTranslatorDB';
|
||||||
|
private static DB_VERSION = 2; // Increment version for cache store
|
||||||
|
private static CACHE_STORE = 'translationCache';
|
||||||
|
// private static MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50MB limit - Reserved for future use
|
||||||
|
private static MAX_ENTRIES = 1000; // Maximum number of cached translations
|
||||||
|
private static CACHE_EXPIRY_DAYS = 30; // Expire entries after 30 days
|
||||||
|
|
||||||
|
// Generate cache key from input parameters
|
||||||
|
static generateCacheKey(text: string, sourceLang: string, targetLang: string): string {
|
||||||
|
// Normalize and sanitize text to create a consistent key
|
||||||
|
const normalizedText = text.trim().toLowerCase();
|
||||||
|
const sanitized = Validator.sanitizeCacheKey(normalizedText);
|
||||||
|
return `${sourceLang}:${targetLang}:${sanitized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open or create the cache database
|
||||||
|
static async openDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
// Create cache store if it doesn't exist
|
||||||
|
if (!db.objectStoreNames.contains(this.CACHE_STORE)) {
|
||||||
|
const store = db.createObjectStore(this.CACHE_STORE, { keyPath: 'key' });
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||||
|
store.createIndex('lastAccessed', 'lastAccessed', { unique: false });
|
||||||
|
store.createIndex('sourceLanguage', 'sourceLanguage', { unique: false });
|
||||||
|
store.createIndex('targetLanguage', 'targetLanguage', { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = (event: Event) => {
|
||||||
|
resolve((event.target as IDBOpenDBRequest).result);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject('Failed to open translation cache database');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cached translation
|
||||||
|
static async getCachedTranslation(
|
||||||
|
text: string,
|
||||||
|
sourceLang: string,
|
||||||
|
targetLang: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const db = await this.openDB();
|
||||||
|
const transaction = db.transaction([this.CACHE_STORE], 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.CACHE_STORE);
|
||||||
|
|
||||||
|
const key = this.generateCacheKey(text, sourceLang, targetLang);
|
||||||
|
const request = store.get(key);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
request.onsuccess = (event: Event) => {
|
||||||
|
const entry = (event.target as IDBRequest).result as TranslationCacheEntry;
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
// Check if entry is not expired
|
||||||
|
const expiryTime = entry.timestamp + (this.CACHE_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
if (Date.now() < expiryTime) {
|
||||||
|
// Update access count and last accessed time
|
||||||
|
entry.accessCount++;
|
||||||
|
entry.lastAccessed = Date.now();
|
||||||
|
store.put(entry);
|
||||||
|
|
||||||
|
console.log(`Cache hit for translation: ${sourceLang} -> ${targetLang}`);
|
||||||
|
resolve(entry.targetText);
|
||||||
|
} else {
|
||||||
|
// Entry expired, delete it
|
||||||
|
store.delete(key);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('Failed to get cached translation');
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cache lookup error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save translation to cache
|
||||||
|
static async cacheTranslation(
|
||||||
|
sourceText: string,
|
||||||
|
sourceLang: string,
|
||||||
|
targetText: string,
|
||||||
|
targetLang: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.openDB();
|
||||||
|
const transaction = db.transaction([this.CACHE_STORE], 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.CACHE_STORE);
|
||||||
|
|
||||||
|
const key = this.generateCacheKey(sourceText, sourceLang, targetLang);
|
||||||
|
const entry: TranslationCacheEntry = {
|
||||||
|
key,
|
||||||
|
sourceText,
|
||||||
|
sourceLanguage: sourceLang,
|
||||||
|
targetText,
|
||||||
|
targetLanguage: targetLang,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
accessCount: 1,
|
||||||
|
lastAccessed: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check cache size before adding
|
||||||
|
await this.ensureCacheSize(db);
|
||||||
|
|
||||||
|
store.put(entry);
|
||||||
|
console.log(`Cached translation: ${sourceLang} -> ${targetLang}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to cache translation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure cache doesn't exceed size limits
|
||||||
|
static async ensureCacheSize(db: IDBDatabase): Promise<void> {
|
||||||
|
const transaction = db.transaction([this.CACHE_STORE], 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.CACHE_STORE);
|
||||||
|
|
||||||
|
// Count entries
|
||||||
|
const countRequest = store.count();
|
||||||
|
|
||||||
|
countRequest.onsuccess = async () => {
|
||||||
|
const count = countRequest.result;
|
||||||
|
|
||||||
|
if (count >= this.MAX_ENTRIES) {
|
||||||
|
// Delete least recently accessed entries
|
||||||
|
const index = store.index('lastAccessed');
|
||||||
|
const cursor = index.openCursor();
|
||||||
|
let deleted = 0;
|
||||||
|
const toDelete = Math.floor(count * 0.2); // Delete 20% of entries
|
||||||
|
|
||||||
|
cursor.onsuccess = (event: Event) => {
|
||||||
|
const cursor = (event.target as IDBRequest).result;
|
||||||
|
if (cursor && deleted < toDelete) {
|
||||||
|
cursor.delete();
|
||||||
|
deleted++;
|
||||||
|
cursor.continue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cache statistics
|
||||||
|
static async getCacheStats(): Promise<CacheStats> {
|
||||||
|
try {
|
||||||
|
const db = await this.openDB();
|
||||||
|
const transaction = db.transaction([this.CACHE_STORE], 'readonly');
|
||||||
|
const store = transaction.objectStore(this.CACHE_STORE);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const stats: CacheStats = {
|
||||||
|
totalEntries: 0,
|
||||||
|
totalSize: 0,
|
||||||
|
oldestEntry: Date.now(),
|
||||||
|
newestEntry: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const countRequest = store.count();
|
||||||
|
countRequest.onsuccess = () => {
|
||||||
|
stats.totalEntries = countRequest.result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cursor = store.openCursor();
|
||||||
|
cursor.onsuccess = (event: Event) => {
|
||||||
|
const cursor = (event.target as IDBRequest).result;
|
||||||
|
if (cursor) {
|
||||||
|
const entry = cursor.value as TranslationCacheEntry;
|
||||||
|
// Estimate size (rough calculation)
|
||||||
|
stats.totalSize += (entry.sourceText.length + entry.targetText.length) * 2;
|
||||||
|
stats.oldestEntry = Math.min(stats.oldestEntry, entry.timestamp);
|
||||||
|
stats.newestEntry = Math.max(stats.newestEntry, entry.timestamp);
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
resolve(stats);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get cache stats:', error);
|
||||||
|
return {
|
||||||
|
totalEntries: 0,
|
||||||
|
totalSize: 0,
|
||||||
|
oldestEntry: 0,
|
||||||
|
newestEntry: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all cache
|
||||||
|
static async clearCache(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.openDB();
|
||||||
|
const transaction = db.transaction([this.CACHE_STORE], 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.CACHE_STORE);
|
||||||
|
store.clear();
|
||||||
|
console.log('Translation cache cleared');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear cache:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export cache for backup
|
||||||
|
static async exportCache(): Promise<TranslationCacheEntry[]> {
|
||||||
|
try {
|
||||||
|
const db = await this.openDB();
|
||||||
|
const transaction = db.transaction([this.CACHE_STORE], 'readonly');
|
||||||
|
const store = transaction.objectStore(this.CACHE_STORE);
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
request.onsuccess = () => {
|
||||||
|
resolve(request.result);
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
resolve([]);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export cache:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
109
static/js/src/types.ts
Normal file
109
static/js/src/types.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// Type definitions for Talk2Me application
|
||||||
|
|
||||||
|
export interface TranscriptionResponse {
|
||||||
|
success: boolean;
|
||||||
|
text?: string;
|
||||||
|
error?: string;
|
||||||
|
detected_language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationResponse {
|
||||||
|
success: boolean;
|
||||||
|
translation?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSResponse {
|
||||||
|
success: boolean;
|
||||||
|
audio_url?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSServerStatus {
|
||||||
|
status: 'online' | 'error' | 'auth_error';
|
||||||
|
message: string;
|
||||||
|
url: string;
|
||||||
|
code?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSConfigUpdate {
|
||||||
|
server_url?: string;
|
||||||
|
api_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSConfigResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
url?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationRequest {
|
||||||
|
text: string;
|
||||||
|
source_lang: string;
|
||||||
|
target_lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSRequest {
|
||||||
|
text: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushPublicKeyResponse {
|
||||||
|
publicKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexedDBRecord {
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionRecord extends IndexedDBRecord {
|
||||||
|
text: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationRecord extends IndexedDBRecord {
|
||||||
|
sourceText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationCacheEntry {
|
||||||
|
key: string;
|
||||||
|
sourceText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
timestamp: number;
|
||||||
|
accessCount: number;
|
||||||
|
lastAccessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheStats {
|
||||||
|
totalEntries: number;
|
||||||
|
totalSize: number;
|
||||||
|
oldestEntry: number;
|
||||||
|
newestEntry: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service Worker types
|
||||||
|
export interface PeriodicSyncManager {
|
||||||
|
register(tag: string, options?: { minInterval: number }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceWorkerRegistrationExtended extends ServiceWorkerRegistration {
|
||||||
|
periodicSync?: PeriodicSyncManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend window interface for PWA features
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
deferredPrompt?: BeforeInstallPromptEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
259
static/js/src/validator.ts
Normal file
259
static/js/src/validator.ts
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
// Input validation and sanitization utilities
|
||||||
|
export class Validator {
|
||||||
|
// Sanitize HTML to prevent XSS attacks
|
||||||
|
static sanitizeHTML(input: string): string {
|
||||||
|
// Create a temporary div element
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.textContent = input;
|
||||||
|
return temp.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and sanitize text input
|
||||||
|
static sanitizeText(input: string, maxLength: number = 10000): string {
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim and limit length
|
||||||
|
let sanitized = input.trim().substring(0, maxLength);
|
||||||
|
|
||||||
|
// Remove null bytes
|
||||||
|
sanitized = sanitized.replace(/\0/g, '');
|
||||||
|
|
||||||
|
// Remove control characters except newlines and tabs
|
||||||
|
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate language code
|
||||||
|
static validateLanguageCode(code: string, allowedLanguages: string[]): string | null {
|
||||||
|
if (!code || typeof code !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = code.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Check if it's in the allowed list
|
||||||
|
if (allowedLanguages.includes(sanitized) || sanitized === 'auto') {
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file upload
|
||||||
|
static validateAudioFile(file: File): { valid: boolean; error?: string } {
|
||||||
|
// Check if file exists
|
||||||
|
if (!file) {
|
||||||
|
return { valid: false, error: 'No file provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size (max 25MB)
|
||||||
|
const maxSize = 25 * 1024 * 1024;
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return { valid: false, error: 'File size exceeds 25MB limit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type
|
||||||
|
const allowedTypes = [
|
||||||
|
'audio/webm',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/wav',
|
||||||
|
'audio/mp3',
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/x-m4a',
|
||||||
|
'audio/x-wav'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
// Check by extension as fallback
|
||||||
|
const ext = file.name.toLowerCase().split('.').pop();
|
||||||
|
const allowedExtensions = ['webm', 'ogg', 'wav', 'mp3', 'mp4', 'm4a'];
|
||||||
|
|
||||||
|
if (!ext || !allowedExtensions.includes(ext)) {
|
||||||
|
return { valid: false, error: 'Invalid audio file type' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
static validateURL(url: string): string | null {
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
// Only allow http and https
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent localhost in production
|
||||||
|
if (window.location.hostname !== 'localhost' &&
|
||||||
|
(parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate API key (basic format check)
|
||||||
|
static validateAPIKey(key: string): string | null {
|
||||||
|
if (!key || typeof key !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
const trimmed = key.trim();
|
||||||
|
|
||||||
|
// Check length (most API keys are 20-128 characters)
|
||||||
|
if (trimmed.length < 20 || trimmed.length > 128) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow alphanumeric, dash, and underscore
|
||||||
|
if (!/^[a-zA-Z0-9\-_]+$/.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body size
|
||||||
|
static validateRequestSize(data: any, maxSizeKB: number = 1024): boolean {
|
||||||
|
try {
|
||||||
|
const jsonString = JSON.stringify(data);
|
||||||
|
const sizeInBytes = new Blob([jsonString]).size;
|
||||||
|
return sizeInBytes <= maxSizeKB * 1024;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize filename
|
||||||
|
static sanitizeFilename(filename: string): string {
|
||||||
|
if (!filename || typeof filename !== 'string') {
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove path components
|
||||||
|
let name = filename.split(/[/\\]/).pop() || 'file';
|
||||||
|
|
||||||
|
// Remove dangerous characters
|
||||||
|
name = name.replace(/[^a-zA-Z0-9.\-_]/g, '_');
|
||||||
|
|
||||||
|
// Limit length
|
||||||
|
if (name.length > 255) {
|
||||||
|
const ext = name.split('.').pop();
|
||||||
|
const base = name.substring(0, 250 - (ext ? ext.length + 1 : 0));
|
||||||
|
name = ext ? `${base}.${ext}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate settings object
|
||||||
|
static validateSettings(settings: any): { valid: boolean; sanitized?: any; errors?: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const sanitized: any = {};
|
||||||
|
|
||||||
|
// Validate notification settings
|
||||||
|
if (settings.notificationsEnabled !== undefined) {
|
||||||
|
sanitized.notificationsEnabled = Boolean(settings.notificationsEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.notifyTranscription !== undefined) {
|
||||||
|
sanitized.notifyTranscription = Boolean(settings.notifyTranscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.notifyTranslation !== undefined) {
|
||||||
|
sanitized.notifyTranslation = Boolean(settings.notifyTranslation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.notifyErrors !== undefined) {
|
||||||
|
sanitized.notifyErrors = Boolean(settings.notifyErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate offline mode
|
||||||
|
if (settings.offlineMode !== undefined) {
|
||||||
|
sanitized.offlineMode = Boolean(settings.offlineMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate TTS settings
|
||||||
|
if (settings.ttsServerUrl !== undefined) {
|
||||||
|
const url = this.validateURL(settings.ttsServerUrl);
|
||||||
|
if (settings.ttsServerUrl && !url) {
|
||||||
|
errors.push('Invalid TTS server URL');
|
||||||
|
} else {
|
||||||
|
sanitized.ttsServerUrl = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.ttsApiKey !== undefined) {
|
||||||
|
const key = this.validateAPIKey(settings.ttsApiKey);
|
||||||
|
if (settings.ttsApiKey && !key) {
|
||||||
|
errors.push('Invalid API key format');
|
||||||
|
} else {
|
||||||
|
sanitized.ttsApiKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
sanitized: errors.length === 0 ? sanitized : undefined,
|
||||||
|
errors: errors.length > 0 ? errors : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting check
|
||||||
|
private static requestCounts: Map<string, number[]> = new Map();
|
||||||
|
|
||||||
|
static checkRateLimit(
|
||||||
|
action: string,
|
||||||
|
maxRequests: number = 10,
|
||||||
|
windowMs: number = 60000
|
||||||
|
): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = action;
|
||||||
|
|
||||||
|
if (!this.requestCounts.has(key)) {
|
||||||
|
this.requestCounts.set(key, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = this.requestCounts.get(key)!;
|
||||||
|
|
||||||
|
// Remove old timestamps
|
||||||
|
const cutoff = now - windowMs;
|
||||||
|
const recent = timestamps.filter(t => t > cutoff);
|
||||||
|
|
||||||
|
// Check if limit exceeded
|
||||||
|
if (recent.length >= maxRequests) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current timestamp
|
||||||
|
recent.push(now);
|
||||||
|
this.requestCounts.set(key, recent);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate translation cache key
|
||||||
|
static sanitizeCacheKey(key: string): string {
|
||||||
|
if (!key || typeof key !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove special characters that might cause issues
|
||||||
|
return key.replace(/[^\w\s-]/gi, '').substring(0, 500);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Voice Language Translator",
|
"name": "Talk2Me",
|
||||||
"short_name": "Translator",
|
"short_name": "Translator",
|
||||||
"description": "Translate spoken language between multiple languages with speech input and output",
|
"description": "Translate spoken language between multiple languages with speech input and output",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
// Service Worker for Voice Language Translator PWA
|
// Service Worker for Talk2Me PWA
|
||||||
|
|
||||||
const CACHE_NAME = 'voice-translator-v1';
|
const CACHE_NAME = 'voice-translator-v1';
|
||||||
const ASSETS_TO_CACHE = [
|
const ASSETS_TO_CACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/static/css/styles.css',
|
'/static/css/styles.css',
|
||||||
'/static/js/app.js',
|
'/static/js/dist/app.js',
|
||||||
'/static/icons/icon-192x192.png',
|
'/static/icons/icon-192x192.png',
|
||||||
'/static/icons/icon-512x512.png',
|
'/static/icons/icon-512x512.png',
|
||||||
'/static/icons/favicon.ico'
|
'/static/icons/favicon.ico',
|
||||||
|
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css',
|
||||||
|
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Install event - cache essential assets
|
// Install event - cache essential assets
|
||||||
@ -90,15 +92,34 @@ self.addEventListener('fetch', (event) => {
|
|||||||
|
|
||||||
// Handle push notifications
|
// Handle push notifications
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = event.data.json();
|
const data = event.data.json();
|
||||||
const options = {
|
const options = {
|
||||||
body: data.body || 'New translation available',
|
body: data.body || 'New translation available',
|
||||||
icon: '/static/icons/icon-192x192.png',
|
icon: data.icon || '/static/icons/icon-192x192.png',
|
||||||
badge: '/static/icons/badge-72x72.png',
|
badge: data.badge || '/static/icons/icon-192x192.png',
|
||||||
vibrate: [100, 50, 100],
|
vibrate: [100, 50, 100],
|
||||||
|
tag: data.tag || 'talk2me-notification',
|
||||||
|
requireInteraction: false,
|
||||||
|
silent: false,
|
||||||
data: {
|
data: {
|
||||||
url: data.url || '/'
|
url: data.url || '/',
|
||||||
|
...data.data
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
title: 'View',
|
||||||
|
icon: '/static/icons/icon-192x192.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'close',
|
||||||
|
title: 'Close'
|
||||||
}
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
@ -109,7 +130,55 @@ self.addEventListener('push', (event) => {
|
|||||||
// Handle notification click
|
// Handle notification click
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === 'close') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlToOpen = event.notification.data.url || '/';
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.openWindow(event.notification.data.url)
|
clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
includeUncontrolled: true
|
||||||
|
}).then((windowClients) => {
|
||||||
|
// Check if there's already a window/tab with the app open
|
||||||
|
for (let client of windowClients) {
|
||||||
|
if (client.url === urlToOpen && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not, open a new window/tab
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(urlToOpen);
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle periodic background sync
|
||||||
|
self.addEventListener('periodicsync', (event) => {
|
||||||
|
if (event.tag === 'translation-updates') {
|
||||||
|
event.waitUntil(checkForUpdates());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkForUpdates() {
|
||||||
|
// Check for app updates or send usage statistics
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/check-updates');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.hasUpdate) {
|
||||||
|
self.registration.showNotification('Update Available', {
|
||||||
|
body: 'A new version of Voice Translator is available!',
|
||||||
|
icon: '/static/icons/icon-192x192.png',
|
||||||
|
badge: '/static/icons/icon-192x192.png',
|
||||||
|
tag: 'update-notification'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check for updates:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
66
talk2me.service
Normal file
66
talk2me.service
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Talk2Me Real-time Translation Service
|
||||||
|
Documentation=https://github.com/your-repo/talk2me
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
User=talk2me
|
||||||
|
Group=talk2me
|
||||||
|
WorkingDirectory=/opt/talk2me
|
||||||
|
Environment="PATH=/opt/talk2me/venv/bin"
|
||||||
|
Environment="FLASK_ENV=production"
|
||||||
|
Environment="PYTHONUNBUFFERED=1"
|
||||||
|
|
||||||
|
# Production environment variables
|
||||||
|
EnvironmentFile=-/opt/talk2me/.env
|
||||||
|
|
||||||
|
# Gunicorn command with production settings
|
||||||
|
ExecStart=/opt/talk2me/venv/bin/gunicorn \
|
||||||
|
--config /opt/talk2me/gunicorn_config.py \
|
||||||
|
--error-logfile /var/log/talk2me/gunicorn-error.log \
|
||||||
|
--access-logfile /var/log/talk2me/gunicorn-access.log \
|
||||||
|
--log-level info \
|
||||||
|
wsgi:application
|
||||||
|
|
||||||
|
# Reload via SIGHUP
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
|
||||||
|
# Graceful stop
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StartLimitBurst=3
|
||||||
|
StartLimitInterval=60
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
|
||||||
|
# Allow writing to specific directories
|
||||||
|
ReadWritePaths=/var/log/talk2me /tmp/talk2me_uploads
|
||||||
|
|
||||||
|
# Resource limits
|
||||||
|
LimitNOFILE=65536
|
||||||
|
LimitNPROC=4096
|
||||||
|
|
||||||
|
# Memory limits (adjust based on your system)
|
||||||
|
MemoryLimit=4G
|
||||||
|
MemoryHigh=3G
|
||||||
|
|
||||||
|
# CPU limits (optional)
|
||||||
|
# CPUQuota=200%
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
<title>Voice Language Translator</title>
|
<title>Talk2Me</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
@ -74,6 +74,7 @@
|
|||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.btn-action {
|
.btn-action {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -121,9 +122,28 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="text-center mb-4">Voice Language Translator</h1>
|
<h1 class="text-center mb-4">Talk2Me</h1>
|
||||||
<!--<p class="text-center text-muted">Powered by Gemma 3, Whisper & Edge TTS</p>-->
|
<!--<p class="text-center text-muted">Powered by Gemma 3, Whisper & Edge TTS</p>-->
|
||||||
|
|
||||||
|
<!-- Multi-speaker toolbar -->
|
||||||
|
<div id="speakerToolbar" class="card mb-3" style="display: none;">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2 mb-md-0">
|
||||||
|
<button id="addSpeakerBtn" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-user-plus"></i> Add Speaker
|
||||||
|
</button>
|
||||||
|
<button id="toggleMultiSpeaker" class="btn btn-sm btn-secondary">
|
||||||
|
<i class="fas fa-users"></i> Multi-Speaker: <span id="multiSpeakerStatus">OFF</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="speakerList" class="d-flex gap-2 flex-wrap">
|
||||||
|
<!-- Speaker buttons will be added here dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@ -132,6 +152,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<select id="sourceLanguage" class="form-select language-select mb-3">
|
<select id="sourceLanguage" class="form-select language-select mb-3">
|
||||||
|
<option value="auto">Auto-detect</option>
|
||||||
{% for language in languages %}
|
{% for language in languages %}
|
||||||
<option value="{{ language }}">{{ language }}</option>
|
<option value="{{ language }}">{{ language }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -183,6 +204,13 @@
|
|||||||
<i class="fas fa-microphone"></i>
|
<i class="fas fa-microphone"></i>
|
||||||
</button>
|
</button>
|
||||||
<p class="status-indicator" id="statusIndicator">Click to start recording</p>
|
<p class="status-indicator" id="statusIndicator">Click to start recording</p>
|
||||||
|
<!-- Queue Status Indicator -->
|
||||||
|
<div id="queueStatus" class="text-center mt-2" style="display: none;">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-list"></i> Queue: <span id="queueLength">0</span> |
|
||||||
|
<i class="fas fa-sync"></i> Active: <span id="activeRequests">0</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center mt-3">
|
<div class="text-center mt-3">
|
||||||
@ -197,284 +225,180 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Multi-speaker conversation view -->
|
||||||
|
<div id="conversationView" class="card mt-4" style="display: none;">
|
||||||
|
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Conversation</h5>
|
||||||
|
<div>
|
||||||
|
<button id="exportConversation" class="btn btn-sm btn-light">
|
||||||
|
<i class="fas fa-download"></i> Export
|
||||||
|
</button>
|
||||||
|
<button id="clearConversation" class="btn btn-sm btn-light">
|
||||||
|
<i class="fas fa-trash"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<div id="conversationContent">
|
||||||
|
<!-- Conversation entries will be added here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<audio id="audioPlayer" style="display: none;"></audio>
|
<audio id="audioPlayer" style="display: none;"></audio>
|
||||||
|
|
||||||
|
<!-- TTS Server Configuration Alert -->
|
||||||
|
<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">
|
||||||
|
<input type="text" id="ttsServerUrl" class="form-control mb-2" placeholder="TTS Server URL">
|
||||||
|
<input type="password" id="ttsApiKey" class="form-control mb-2" placeholder="API Key">
|
||||||
|
<button id="updateTtsServer" class="btn btn-sm btn-primary">Update Configuration</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div id="loadingOverlay" class="loading-overlay">
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="spinner-custom"></div>
|
||||||
|
<p id="loadingText" class="mt-3">Processing...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Settings -->
|
||||||
|
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 5">
|
||||||
|
<div id="notificationPrompt" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="toast-header">
|
||||||
|
<i class="fas fa-bell text-primary me-2"></i>
|
||||||
|
<strong class="me-auto">Enable Notifications</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
Get notified when translations are complete!
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="enableNotifications">Enable</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="toast">Not now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Toast -->
|
||||||
|
<div id="successToast" class="toast align-items-center text-white bg-success border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
<i class="fas fa-check-circle me-2"></i>
|
||||||
|
<span id="successMessage">Settings saved successfully!</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="settingsModalLabel">Settings</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<h6>Notifications</h6>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="notificationToggle">
|
||||||
|
<label class="form-check-label" for="notificationToggle">
|
||||||
|
Enable push notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mt-2">Get notified when transcriptions and translations complete</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h6>Notification Types</h6>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="notifyTranscription" checked>
|
||||||
|
<label class="form-check-label" for="notifyTranscription">
|
||||||
|
Transcription complete
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="notifyTranslation" checked>
|
||||||
|
<label class="form-check-label" for="notifyTranslation">
|
||||||
|
Translation complete
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="notifyErrors">
|
||||||
|
<label class="form-check-label" for="notifyErrors">
|
||||||
|
Error notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h6 class="mb-3">Translation Settings</h6>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="streamingTranslation" checked>
|
||||||
|
<label class="form-check-label" for="streamingTranslation">
|
||||||
|
Enable streaming translation
|
||||||
|
<small class="text-muted d-block">Shows translation as it's generated for faster feedback</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="multiSpeakerMode">
|
||||||
|
<label class="form-check-label" for="multiSpeakerMode">
|
||||||
|
Enable multi-speaker mode
|
||||||
|
<small class="text-muted d-block">Track multiple speakers in conversations</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h6>Offline Cache</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span>Cached translations:</span>
|
||||||
|
<span id="cacheCount" class="badge bg-primary">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span>Cache size:</span>
|
||||||
|
<span id="cacheSize" class="badge bg-secondary">0 KB</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="offlineMode" checked>
|
||||||
|
<label class="form-check-label" for="offlineMode">
|
||||||
|
Enable offline caching
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" id="clearCache">
|
||||||
|
<i class="fas fa-trash"></i> Clear Cache
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div id="settingsSaveStatus" class="text-success me-auto" style="display: none;">
|
||||||
|
<i class="fas fa-check-circle"></i> Saved!
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveSettings">Save settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Button -->
|
||||||
|
<button type="button" class="btn btn-outline-secondary position-fixed top-0 end-0 m-3" data-bs-toggle="modal" data-bs-target="#settingsModal">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Simple Success Notification -->
|
||||||
|
<div id="successNotification" class="success-notification">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span id="successText">Settings saved successfully!</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script src="/static/js/dist/app.js"></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>
|
|
||||||
<script src="/static/js/app.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
228
test-cors.html
Normal file
228
test-cors.html
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
<!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>
|
168
test_error_logging.py
Executable file
168
test_error_logging.py
Executable file
@ -0,0 +1,168 @@
|
|||||||
|
#!/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()
|
264
test_session_manager.py
Normal file
264
test_session_manager.py
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
#!/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()
|
146
test_size_limits.py
Executable file
146
test_size_limits.py
Executable file
@ -0,0 +1,146 @@
|
|||||||
|
#!/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!")
|
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"outDir": "./static/js/dist",
|
||||||
|
"rootDir": "./static/js/src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"noEmitOnError": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"strictPropertyInitialization": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"static/js/src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"static/js/dist"
|
||||||
|
]
|
||||||
|
}
|
243
validators.py
Normal file
243
validators.py
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
Input validation and sanitization for the Talk2Me application
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import html
|
||||||
|
from typing import Optional, Dict, Any, Tuple
|
||||||
|
import os
|
||||||
|
|
||||||
|
class Validators:
|
||||||
|
# Maximum sizes
|
||||||
|
MAX_TEXT_LENGTH = 10000
|
||||||
|
MAX_AUDIO_SIZE = 25 * 1024 * 1024 # 25MB
|
||||||
|
MAX_URL_LENGTH = 2048
|
||||||
|
MAX_API_KEY_LENGTH = 128
|
||||||
|
|
||||||
|
# Allowed audio formats
|
||||||
|
ALLOWED_AUDIO_EXTENSIONS = {'.webm', '.ogg', '.wav', '.mp3', '.mp4', '.m4a'}
|
||||||
|
ALLOWED_AUDIO_MIMETYPES = {
|
||||||
|
'audio/webm', 'audio/ogg', 'audio/wav', 'audio/mp3',
|
||||||
|
'audio/mpeg', 'audio/mp4', 'audio/x-m4a', 'audio/x-wav'
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sanitize_text(text: str, max_length: int = None) -> str:
|
||||||
|
"""Sanitize text input by removing dangerous characters"""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if max_length is None:
|
||||||
|
max_length = Validators.MAX_TEXT_LENGTH
|
||||||
|
|
||||||
|
# Trim and limit length
|
||||||
|
text = text.strip()[:max_length]
|
||||||
|
|
||||||
|
# Remove null bytes
|
||||||
|
text = text.replace('\x00', '')
|
||||||
|
|
||||||
|
# Remove control characters except newlines and tabs
|
||||||
|
text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sanitize_html(text: str) -> str:
|
||||||
|
"""Escape HTML to prevent XSS"""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return ""
|
||||||
|
return html.escape(text)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_language_code(code: str, allowed_languages: set) -> Optional[str]:
|
||||||
|
"""Validate language code against allowed list"""
|
||||||
|
if not code or not isinstance(code, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
code = code.strip().lower()
|
||||||
|
|
||||||
|
# Check if it's in the allowed list or is 'auto'
|
||||||
|
if code in allowed_languages or code == 'auto':
|
||||||
|
return code
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_audio_file(file_storage) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""Validate uploaded audio file"""
|
||||||
|
if not file_storage:
|
||||||
|
return False, "No file provided"
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
file_storage.seek(0, os.SEEK_END)
|
||||||
|
size = file_storage.tell()
|
||||||
|
file_storage.seek(0)
|
||||||
|
|
||||||
|
if size > Validators.MAX_AUDIO_SIZE:
|
||||||
|
return False, f"File size exceeds {Validators.MAX_AUDIO_SIZE // (1024*1024)}MB limit"
|
||||||
|
|
||||||
|
# Check file extension
|
||||||
|
if file_storage.filename:
|
||||||
|
ext = os.path.splitext(file_storage.filename.lower())[1]
|
||||||
|
if ext not in Validators.ALLOWED_AUDIO_EXTENSIONS:
|
||||||
|
return False, "Invalid audio file type"
|
||||||
|
|
||||||
|
# Check MIME type if available
|
||||||
|
if hasattr(file_storage, 'content_type') and file_storage.content_type:
|
||||||
|
if file_storage.content_type not in Validators.ALLOWED_AUDIO_MIMETYPES:
|
||||||
|
# Allow generic application/octet-stream as browsers sometimes use this
|
||||||
|
if file_storage.content_type != 'application/octet-stream':
|
||||||
|
return False, "Invalid audio MIME type"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_url(url: str) -> Optional[str]:
|
||||||
|
"""Validate and sanitize URL"""
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = url.strip()
|
||||||
|
|
||||||
|
# Check length
|
||||||
|
if len(url) > Validators.MAX_URL_LENGTH:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Basic URL pattern check
|
||||||
|
url_pattern = re.compile(
|
||||||
|
r'^https?://' # http:// or https://
|
||||||
|
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
|
||||||
|
r'localhost|' # localhost...
|
||||||
|
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|' # ...or ipv4
|
||||||
|
r'\[?[A-F0-9]*:[A-F0-9:]+\]?)' # ...or ipv6
|
||||||
|
r'(?::\d+)?' # optional port
|
||||||
|
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
||||||
|
|
||||||
|
if not url_pattern.match(url):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prevent some common injection attempts
|
||||||
|
dangerous_patterns = [
|
||||||
|
'javascript:', 'data:', 'vbscript:', 'file:', 'about:', 'chrome:'
|
||||||
|
]
|
||||||
|
if any(pattern in url.lower() for pattern in dangerous_patterns):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_api_key(key: str) -> Optional[str]:
|
||||||
|
"""Validate API key format"""
|
||||||
|
if not key or not isinstance(key, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = key.strip()
|
||||||
|
|
||||||
|
# Check length
|
||||||
|
if len(key) < 20 or len(key) > Validators.MAX_API_KEY_LENGTH:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Only allow alphanumeric, dash, and underscore
|
||||||
|
if not re.match(r'^[a-zA-Z0-9\-_]+$', key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return key
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
"""Sanitize filename to prevent directory traversal"""
|
||||||
|
if not filename or not isinstance(filename, str):
|
||||||
|
return "file"
|
||||||
|
|
||||||
|
# Remove any path components
|
||||||
|
filename = os.path.basename(filename)
|
||||||
|
|
||||||
|
# Remove dangerous characters
|
||||||
|
filename = re.sub(r'[^a-zA-Z0-9.\-_]', '_', filename)
|
||||||
|
|
||||||
|
# Limit length
|
||||||
|
if len(filename) > 255:
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
max_name_length = 255 - len(ext)
|
||||||
|
filename = name[:max_name_length] + ext
|
||||||
|
|
||||||
|
# Don't allow hidden files
|
||||||
|
if filename.startswith('.'):
|
||||||
|
filename = '_' + filename[1:]
|
||||||
|
|
||||||
|
return filename or "file"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_json_size(data: Dict[str, Any], max_size_kb: int = 1024) -> bool:
|
||||||
|
"""Check if JSON data size is within limits"""
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
json_str = json.dumps(data)
|
||||||
|
size_kb = len(json_str.encode('utf-8')) / 1024
|
||||||
|
return size_kb <= max_size_kb
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_settings(settings: Dict[str, Any]) -> Tuple[bool, Dict[str, Any], list]:
|
||||||
|
"""Validate settings object"""
|
||||||
|
errors = []
|
||||||
|
sanitized = {}
|
||||||
|
|
||||||
|
# Boolean settings
|
||||||
|
bool_settings = [
|
||||||
|
'notificationsEnabled', 'notifyTranscription',
|
||||||
|
'notifyTranslation', 'notifyErrors', 'offlineMode'
|
||||||
|
]
|
||||||
|
|
||||||
|
for setting in bool_settings:
|
||||||
|
if setting in settings:
|
||||||
|
sanitized[setting] = bool(settings[setting])
|
||||||
|
|
||||||
|
# URL validation
|
||||||
|
if 'ttsServerUrl' in settings and settings['ttsServerUrl']:
|
||||||
|
url = Validators.validate_url(settings['ttsServerUrl'])
|
||||||
|
if not url:
|
||||||
|
errors.append('Invalid TTS server URL')
|
||||||
|
else:
|
||||||
|
sanitized['ttsServerUrl'] = url
|
||||||
|
|
||||||
|
# API key validation
|
||||||
|
if 'ttsApiKey' in settings and settings['ttsApiKey']:
|
||||||
|
key = Validators.validate_api_key(settings['ttsApiKey'])
|
||||||
|
if not key:
|
||||||
|
errors.append('Invalid API key format')
|
||||||
|
else:
|
||||||
|
sanitized['ttsApiKey'] = key
|
||||||
|
|
||||||
|
return len(errors) == 0, sanitized, errors
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rate_limit_check(identifier: str, action: str, max_requests: int = 10,
|
||||||
|
window_seconds: int = 60, storage: Dict = None) -> bool:
|
||||||
|
"""
|
||||||
|
Simple rate limiting check
|
||||||
|
Returns True if request is allowed, False if rate limited
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
if storage is None:
|
||||||
|
return True # Can't track without storage
|
||||||
|
|
||||||
|
key = f"{identifier}:{action}"
|
||||||
|
current_time = time.time()
|
||||||
|
window_start = current_time - window_seconds
|
||||||
|
|
||||||
|
# Get or create request list
|
||||||
|
if key not in storage:
|
||||||
|
storage[key] = []
|
||||||
|
|
||||||
|
# Remove old requests outside the window
|
||||||
|
storage[key] = [t for t in storage[key] if t > window_start]
|
||||||
|
|
||||||
|
# Check if limit exceeded
|
||||||
|
if len(storage[key]) >= max_requests:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Add current request
|
||||||
|
storage[key].append(current_time)
|
||||||
|
return True
|
39
whisper_config.py
Normal file
39
whisper_config.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Whisper Model Configuration and Optimization Settings
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Model selection based on available resources
|
||||||
|
# Available models: tiny, base, small, medium, large
|
||||||
|
MODEL_SIZE = "base" # ~140MB, good balance of speed and accuracy
|
||||||
|
|
||||||
|
# GPU Optimization Settings
|
||||||
|
GPU_OPTIMIZATIONS = {
|
||||||
|
"enable_tf32": True, # TensorFloat-32 for Ampere GPUs
|
||||||
|
"enable_cudnn_benchmark": True, # Auto-tune convolution algorithms
|
||||||
|
"use_fp16": True, # Half precision for faster inference
|
||||||
|
"pre_allocate_memory": True, # Reduce memory fragmentation
|
||||||
|
"warm_up_gpu": True # Cache CUDA kernels on startup
|
||||||
|
}
|
||||||
|
|
||||||
|
# Transcription Settings for Speed
|
||||||
|
TRANSCRIBE_OPTIONS = {
|
||||||
|
"task": "transcribe",
|
||||||
|
"temperature": 0, # Disable sampling
|
||||||
|
"best_of": 1, # No beam search
|
||||||
|
"beam_size": 1, # Single beam
|
||||||
|
"condition_on_previous_text": False, # Faster inference
|
||||||
|
"compression_ratio_threshold": 2.4,
|
||||||
|
"logprob_threshold": -1.0,
|
||||||
|
"no_speech_threshold": 0.6,
|
||||||
|
"word_timestamps": False # Disable if not needed
|
||||||
|
}
|
||||||
|
|
||||||
|
# Memory Management
|
||||||
|
MEMORY_SETTINGS = {
|
||||||
|
"clear_cache_after_transcribe": True,
|
||||||
|
"force_garbage_collection": True,
|
||||||
|
"max_concurrent_transcriptions": 1 # Prevent memory overflow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Performance Monitoring
|
||||||
|
ENABLE_PERFORMANCE_LOGGING = True
|
34
wsgi.py
Normal file
34
wsgi.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
WSGI entry point for production deployment
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add the project directory to the Python path
|
||||||
|
project_root = Path(__file__).parent.absolute()
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
# Set production environment
|
||||||
|
os.environ['FLASK_ENV'] = 'production'
|
||||||
|
|
||||||
|
# Import and configure the Flask app
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
# Production configuration overrides
|
||||||
|
app.config.update(
|
||||||
|
DEBUG=False,
|
||||||
|
TESTING=False,
|
||||||
|
# Ensure proper secret key is set in production
|
||||||
|
SECRET_KEY=os.environ.get('SECRET_KEY', app.config.get('SECRET_KEY'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the WSGI application
|
||||||
|
application = app
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# This is only for development/testing
|
||||||
|
# In production, use: gunicorn wsgi:application
|
||||||
|
print("Warning: Running WSGI directly. Use a proper WSGI server in production!")
|
||||||
|
application.run(host='0.0.0.0', port=5005)
|
Loading…
Reference in New Issue
Block a user