Bunch of improvements:
- Refactored the dashboard html into seperate pages and all the necessary components - Added login and secured api routes / debugged getting system working on a tailnet. - added some functionality to the debugging and health endpoints - added in a new phone contact import and debugged.
This commit is contained in:
parent
489d8bb1e7
commit
498e1ab6ca
90
.env.example
Normal file
90
.env.example
Normal file
@ -0,0 +1,90 @@
|
||||
# SMS Campaign Manager Configuration
|
||||
# Copy this file to .env and fill in your actual values
|
||||
# NEVER commit .env to version control!
|
||||
|
||||
# Android Device Configuration
|
||||
PHONE_IP=100.107.173.66
|
||||
ADB_PORT=5555
|
||||
TERMUX_API_PORT=5001
|
||||
|
||||
# Flask Application
|
||||
FLASK_ENV=production
|
||||
DEFAULT_DELAY_SECONDS=3
|
||||
|
||||
# SMS Automation (ADB tap coordinates for your device)
|
||||
# Adjust these based on your device's screen resolution
|
||||
SEND_BUTTON_X=1300
|
||||
SEND_BUTTON_Y=2900
|
||||
|
||||
# SMS Retry Configuration
|
||||
SMS_MAX_RETRIES=3
|
||||
SMS_RETRY_BASE_DELAY=2
|
||||
SMS_MAX_RETRY_DELAY=8
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY - API KEYS
|
||||
# =============================================================================
|
||||
# Generate these keys by running: python3 src/core/auth.py
|
||||
# NEVER share these keys or commit them to git!
|
||||
# =============================================================================
|
||||
|
||||
# Admin API Key - Full access including database reset
|
||||
# Use for: Personal admin access, critical operations
|
||||
ADMIN_API_KEY=generate_this_with_python3_src_core_auth_py
|
||||
|
||||
# User API Key - Regular application access
|
||||
# Use for: Web dashboard, normal API operations, automated campaigns
|
||||
USER_API_KEY=generate_this_with_python3_src_core_auth_py
|
||||
|
||||
# Termux API Key - Android device communication
|
||||
# Use for: Communication between Flask server and Android Termux
|
||||
TERMUX_API_KEY=generate_this_with_python3_src_core_auth_py
|
||||
|
||||
# Flask Secret Key - For session management and CSRF protection
|
||||
SECRET_KEY=generate_this_with_python3_src_core_auth_py
|
||||
|
||||
# Termux API Secret - Used by Android Termux API server
|
||||
# Should match TERMUX_API_KEY value
|
||||
TERMUX_API_SECRET=same_as_termux_api_key_above
|
||||
|
||||
# =============================================================================
|
||||
# USER MANAGEMENT (Optional - for web dashboard login)
|
||||
# =============================================================================
|
||||
# Create an initial admin user from environment variables
|
||||
# After first login, you can use the CLI tool: python3 manage_users.py
|
||||
# =============================================================================
|
||||
|
||||
# Default admin username and password (optional)
|
||||
# If set, will create this user on first run
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change_this_password_immediately
|
||||
|
||||
# =============================================================================
|
||||
# RATE LIMITING - Configurable Rate Limits
|
||||
# =============================================================================
|
||||
# Adjust these values to control API rate limits per IP address
|
||||
# Format: "X per minute/hour/day" (can specify multiple, comma-separated)
|
||||
# Lower values = more restrictive, Higher values = more permissive
|
||||
# =============================================================================
|
||||
|
||||
# Default rate limits (applied to all endpoints unless overridden)
|
||||
# Recommended: 200 per hour, 1000 per day
|
||||
RATE_LIMIT_DEFAULT=200 per hour, 1000 per day
|
||||
|
||||
# Login endpoint (prevent brute force password attacks)
|
||||
# Recommended: 5-10 per minute (very restrictive to prevent attacks)
|
||||
RATE_LIMIT_LOGIN=5 per minute
|
||||
|
||||
# SMS sending endpoints (prevent spam and abuse)
|
||||
# Recommended: 10 per minute, 100 per hour, 500 per day
|
||||
# Adjust based on your SMS sending volume needs
|
||||
RATE_LIMIT_SMS=10 per minute, 100 per hour, 500 per day
|
||||
|
||||
# File upload endpoints (prevent resource exhaustion)
|
||||
# Recommended: 10 per hour, 50 per day
|
||||
# Adjust based on how often you upload contact lists
|
||||
RATE_LIMIT_UPLOAD=10 per hour, 50 per day
|
||||
|
||||
# Database reset endpoint (prevent accidental/malicious data loss)
|
||||
# Recommended: 2 per hour (very restrictive - this is destructive)
|
||||
RATE_LIMIT_DATABASE_RESET=2 per hour
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@ -104,8 +104,12 @@ celerybeat.pid
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
# Environments - CRITICAL: NEVER COMMIT THESE FILES
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
.env.development
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
@ -113,6 +117,13 @@ ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Security - API keys and secrets
|
||||
*.key
|
||||
*.pem
|
||||
*.crt
|
||||
secrets/
|
||||
api_keys.txt
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
@ -174,3 +185,6 @@ src/routes/__pycache__/conversations_enhanced.cpython-311.pyc
|
||||
src/routes/__pycache__/conversations.cpython-311.pyc
|
||||
src/services/__pycache__/termux_sync_service.cpython-311.pyc
|
||||
src/services/__pycache__/websocket_service.cpython-311.pyc
|
||||
|
||||
# CSV
|
||||
*.csv
|
||||
@ -1,209 +0,0 @@
|
||||
# Database Reset Feature
|
||||
|
||||
## Overview
|
||||
|
||||
Added a "Reset Database" feature to the System Testing page that allows complete database reset with proper safety measures.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Frontend (UI)
|
||||
|
||||
**Location**: [src/templates/dashboard.html](src/templates/dashboard.html)
|
||||
|
||||
**Added Components**:
|
||||
|
||||
1. **Database Management Section** (lines 579-596)
|
||||
- Warning box with red styling
|
||||
- Reset button with loading state
|
||||
- Result display area for success/error messages
|
||||
|
||||
2. **Confirmation Modal** (lines 862-913)
|
||||
- Full-screen overlay with backdrop
|
||||
- Detailed warning about data loss
|
||||
- List of items that will be deleted
|
||||
- Type-to-confirm input (requires typing "RESET")
|
||||
- Cancel and confirm buttons
|
||||
- Enter key support for confirmation
|
||||
- Automatic modal close on success
|
||||
|
||||
**JavaScript State**: [src/static/js/dashboard.js](src/static/js/dashboard.js:87-91)
|
||||
|
||||
```javascript
|
||||
showResetConfirmation: false,
|
||||
resetConfirmText: '',
|
||||
resettingDatabase: false,
|
||||
resetResult: null
|
||||
```
|
||||
|
||||
**JavaScript Function**: [src/static/js/dashboard.js](src/static/js/dashboard.js:802-853)
|
||||
|
||||
```javascript
|
||||
async resetDatabase() {
|
||||
// Validates confirmation text
|
||||
// Calls API endpoint
|
||||
// Shows result
|
||||
// Reloads all data on success
|
||||
// Clears local state
|
||||
}
|
||||
```
|
||||
|
||||
### Backend (API)
|
||||
|
||||
**New Route File**: [src/routes/api/database_routes.py](src/routes/api/database_routes.py)
|
||||
|
||||
**Endpoints**:
|
||||
|
||||
1. **POST /api/database/reset**
|
||||
- Deletes the database file
|
||||
- Reinitializes schema with default templates
|
||||
- Returns success/error status
|
||||
- Includes comprehensive error handling
|
||||
|
||||
2. **GET /api/database/stats** (bonus endpoint)
|
||||
- Returns database statistics
|
||||
- Table counts, file size, existence check
|
||||
|
||||
**Route Registration**: [src/app.py](src/app.py:36)
|
||||
- Imported database_routes blueprint
|
||||
- Initialized with db_manager and config
|
||||
- Registered with Flask app
|
||||
|
||||
## Safety Features
|
||||
|
||||
### Multi-Layer Confirmation
|
||||
|
||||
1. **Initial Button Click**: User must click "Reset Database" button
|
||||
2. **Modal Warning**: Full-screen modal with detailed warnings
|
||||
3. **Type to Confirm**: User must type "RESET" exactly
|
||||
4. **Button Disabled**: Confirm button disabled until correct text entered
|
||||
5. **Enter Key Support**: Can press Enter after typing "RESET"
|
||||
|
||||
### Visual Warnings
|
||||
|
||||
- Red color scheme for danger zone
|
||||
- Warning emoji (⚠️)
|
||||
- Bold "This cannot be undone!" message
|
||||
- Bullet list of what will be deleted:
|
||||
- All campaigns and their messages
|
||||
- All contact lists
|
||||
- All conversation history
|
||||
- All message templates
|
||||
|
||||
### Backend Safety
|
||||
|
||||
- Permission error handling
|
||||
- Graceful error messages
|
||||
- Logging of all reset attempts
|
||||
- Wait period before deletion (0.5s)
|
||||
- Automatic schema reinitialization
|
||||
- Default templates restored
|
||||
|
||||
## User Experience
|
||||
|
||||
### Success Flow
|
||||
|
||||
1. Click "🗑️ Reset Database"
|
||||
2. Modal appears with warnings
|
||||
3. Type "RESET" in input field
|
||||
4. Click "Reset Database" or press Enter
|
||||
5. Button shows "Resetting..." state
|
||||
6. Success message appears
|
||||
7. Modal closes automatically
|
||||
8. UI refreshes with empty state
|
||||
9. Default templates are restored
|
||||
|
||||
### Error Flow
|
||||
|
||||
1. If error occurs during reset
|
||||
2. Error message displayed in red box
|
||||
3. Modal remains open
|
||||
4. User can try again or cancel
|
||||
5. Detailed error logged to server
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **src/templates/dashboard.html**
|
||||
- Added database management section
|
||||
- Added confirmation modal
|
||||
|
||||
2. **src/static/js/dashboard.js**
|
||||
- Added state variables
|
||||
- Added resetDatabase() function
|
||||
|
||||
3. **src/routes/api/database_routes.py** (NEW)
|
||||
- Created database management routes
|
||||
|
||||
4. **src/routes/api/__init__.py**
|
||||
- Exported database_routes and init function
|
||||
|
||||
5. **src/app.py**
|
||||
- Imported database_routes
|
||||
- Initialized and registered blueprint
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Test Steps
|
||||
|
||||
1. Navigate to http://localhost:5000
|
||||
2. Click on "🧪 System Testing" tab
|
||||
3. Scroll to "Database Management" section
|
||||
4. Click "🗑️ Reset Database"
|
||||
5. Verify modal appears
|
||||
6. Try clicking "Reset Database" without typing - should be disabled
|
||||
7. Type "RESET" in input field
|
||||
8. Confirm button becomes enabled
|
||||
9. Click "Reset Database" or press Enter
|
||||
10. Verify success message
|
||||
11. Check that campaigns, lists, templates are empty
|
||||
12. Verify default templates are restored
|
||||
|
||||
### API Test
|
||||
|
||||
```bash
|
||||
# Test database reset endpoint
|
||||
curl -X POST http://localhost:5000/api/database/reset
|
||||
|
||||
# Check database stats
|
||||
curl http://localhost:5000/api/database/stats
|
||||
```
|
||||
|
||||
## Screenshots Description
|
||||
|
||||
**Database Management Section**:
|
||||
- Red border and background
|
||||
- Warning text
|
||||
- Reset button
|
||||
- Result area
|
||||
|
||||
**Confirmation Modal**:
|
||||
- Warning icon in circle
|
||||
- Detailed warning message
|
||||
- Bullet list of data to delete
|
||||
- "Type RESET to confirm" input
|
||||
- Cancel and Reset buttons
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Add database backup before reset
|
||||
- Export data option before reset
|
||||
- Selective table reset (reset only campaigns, only templates, etc.)
|
||||
- Database migration/upgrade tools
|
||||
- Import/restore from backup
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Endpoint requires POST method
|
||||
- No authentication (add if needed for production)
|
||||
- Confirmation required on frontend
|
||||
- All actions logged
|
||||
- Error messages sanitized
|
||||
- File permissions respected
|
||||
|
||||
## Notes
|
||||
|
||||
- Default templates are automatically restored after reset
|
||||
- Database file located at `./data/campaign.db`
|
||||
- Journal mode: TRUNCATE (for Docker compatibility)
|
||||
- Reset takes ~0.5-1 second typically
|
||||
- All data is permanently deleted (no recovery)
|
||||
75
README.md
75
README.md
@ -1,5 +1,16 @@
|
||||
# SMS Campaign Manager 📱
|
||||
*Dockerized SMS automation system with Android integration*
|
||||
*Secure, Dockerized SMS automation system with Android integration*
|
||||
|
||||
## 🔐 Now with User Authentication!
|
||||
|
||||
**No more ModHeader!** Access the web dashboard with username and password.
|
||||
|
||||
- ✅ **User Login** - Simple username/password authentication
|
||||
- ✅ **API Keys** - Secure API access for scripts and automation
|
||||
- ✅ **24-Hour Sessions** - Stay logged in without re-entering credentials
|
||||
- ✅ **Role-Based Access** - Admin and User roles with different permissions
|
||||
|
||||
**Quick Start**: Open `http://localhost:5000/` → Login with `admin` / `@thebunker`
|
||||
|
||||
[](./docker/docker-compose.yml)
|
||||
[](./src/requirements.txt)
|
||||
@ -14,7 +25,7 @@
|
||||
nano .env
|
||||
|
||||
# 2. Deploy to Android using automated script
|
||||
./deploy-android.sh
|
||||
./scripts/deploy-android.sh
|
||||
|
||||
# 3. Start Ubuntu homelab
|
||||
./run.sh start
|
||||
@ -79,7 +90,7 @@ open http://localhost:5000
|
||||
### 3. Deploy to Android Device
|
||||
```bash
|
||||
# Use the automated deployment script
|
||||
./deploy-android.sh
|
||||
./scripts/deploy-android.sh
|
||||
|
||||
# The script will:
|
||||
# - Test connectivity to your Android device
|
||||
@ -242,20 +253,31 @@ Android Monitor Dashboard (Port 5000) ← Device monitoring
|
||||
|
||||
---
|
||||
|
||||
## <EFBFBD> Documentation
|
||||
## 📚 Documentation
|
||||
|
||||
### Setup & Configuration
|
||||
- [`android-dev-setup.md`](android-dev-setup.md) - Android device setup guide
|
||||
- [`termux-development-setup-success.md`](termux-development-setup-success.md) - Termux integration walkthrough
|
||||
- [`workplan.md`](workplan.md) - Development roadmap and feature planning
|
||||
For complete documentation, see the [docs/](docs/) directory or visit the documentation site.
|
||||
|
||||
### Technical Documentation
|
||||
- [`files.md`](files.md) - Complete project file documentation
|
||||
- [`termux-integration-summary.md`](termux-integration-summary.md) - Integration architecture details
|
||||
- [`instruct.md`](instruct.md) - Development guidelines and preferences
|
||||
### Quick Links
|
||||
- [Quick Start Guide](docs/setup/quick-start.md) - Get started in minutes
|
||||
- [Deployment Guide](docs/deployment/deployment-guide.md) - Production deployment
|
||||
- [User Management](docs/guides/user-management.md) - Managing users and permissions
|
||||
- [Security Setup](docs/security/security-setup.md) - Securing your installation
|
||||
- [API Security](docs/security/api-security.md) - API authentication guide
|
||||
- [Android Development](docs/development/android-dev-setup.md) - Android setup details
|
||||
- [File Structure](docs/reference/files.md) - Project file documentation
|
||||
- [Project Instructions](docs/reference/project-instructions.md) - Development guidelines
|
||||
|
||||
### Reference
|
||||
- [`text history.md`](text%20history.md) - Message templates and campaign history
|
||||
### Building Documentation
|
||||
```bash
|
||||
# Install mkdocs and material theme
|
||||
pip install mkdocs mkdocs-material
|
||||
|
||||
# Serve documentation locally
|
||||
mkdocs serve
|
||||
|
||||
# Build static documentation
|
||||
mkdocs build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -294,13 +316,10 @@ python app.py
|
||||
### Testing SMS Integration
|
||||
```bash
|
||||
# Test ADB connection
|
||||
./auto.sh
|
||||
|
||||
# Test Termux API (if configured)
|
||||
./test-termux-integration.sh
|
||||
./scripts/auto.sh
|
||||
|
||||
# Manual SMS test via UI script
|
||||
./ui.sh
|
||||
./scripts/ui.sh
|
||||
```
|
||||
|
||||
---
|
||||
@ -354,7 +373,7 @@ ABD Texting Testing/
|
||||
The automated deployment script simplifies Android service deployment:
|
||||
|
||||
```bash
|
||||
./deploy-android.sh
|
||||
./scripts/deploy-android.sh
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
@ -719,15 +738,8 @@ ssh android-dev "termux-sms-list -l 1" # Should list recent SMS
|
||||
|
||||
### Reinstall if services are corrupted
|
||||
```bash
|
||||
# Redeploy scripts to ~/bin/
|
||||
scp -P 8022 android/*.sh android-dev@10.0.0.193:~/bin/
|
||||
ssh android-dev "chmod +x ~/bin/*.sh"
|
||||
|
||||
# Redeploy Python apps to ~/projects/sms-campaign-manager/
|
||||
scp -P 8022 android/*.py android-dev@10.0.0.193:~/projects/sms-campaign-manager/
|
||||
|
||||
# Restart services
|
||||
ssh android-dev "~/bin/start-all-services.sh"
|
||||
# Use the deployment script to redeploy everything
|
||||
./scripts/deploy-android.sh
|
||||
```
|
||||
|
||||
### Service Port Conflicts
|
||||
@ -743,13 +755,10 @@ ssh android-dev "lsof -ti:5000 | xargs kill -9"
|
||||
### Phone Not Connecting
|
||||
```bash
|
||||
# Auto-reconnect your phone
|
||||
./auto.sh
|
||||
./scripts/auto.sh
|
||||
|
||||
# Check ADB devices
|
||||
adb devices
|
||||
|
||||
# Restart connection monitoring
|
||||
./phone-auto-connect.sh restart
|
||||
```
|
||||
|
||||
### Termux API Issues
|
||||
|
||||
@ -39,10 +39,11 @@ CONFIG = {
|
||||
'RATE_LIMIT_DELAY': 1.0, # Reduced from 2.0 to 1.0 seconds between messages for faster campaigns
|
||||
'ALLOWED_COMMANDS': [
|
||||
'termux-sms-send',
|
||||
'termux-sms-list',
|
||||
'termux-sms-list',
|
||||
'termux-battery-status',
|
||||
'termux-location',
|
||||
'termux-notification'
|
||||
'termux-notification',
|
||||
'termux-contact-list'
|
||||
]
|
||||
}
|
||||
|
||||
@ -212,6 +213,22 @@ class SMSApiServer:
|
||||
# Global server instance
|
||||
sms_server = SMSApiServer()
|
||||
|
||||
def verify_api_key():
|
||||
"""Verify API key from request headers"""
|
||||
api_key = request.headers.get('X-API-Key') or request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
expected_key = CONFIG['SECRET_KEY']
|
||||
|
||||
if not api_key:
|
||||
logger.warning(f"⚠️ No API key provided for {request.path} from {request.remote_addr}")
|
||||
return False
|
||||
|
||||
if not hmac.compare_digest(api_key, expected_key):
|
||||
logger.warning(f"⚠️ Invalid API key for {request.path} from {request.remote_addr}")
|
||||
return False
|
||||
|
||||
logger.info(f"✅ Valid API key for {request.path}")
|
||||
return True
|
||||
|
||||
# API Endpoints
|
||||
# Web interface route
|
||||
@app.route("/")
|
||||
@ -291,12 +308,25 @@ def index():
|
||||
<p><code>GET /api/device/info</code></p>
|
||||
<p>System information and device details</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>📇 Contact List</h3>
|
||||
<p><code>GET /api/contacts/list</code></p>
|
||||
<p>Fetch all contacts from phone (with optional search)</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>🧪 Test Contact Structure</h3>
|
||||
<p><code>GET /api/contacts/test</code></p>
|
||||
<p>Analyze contact list JSON structure and fields</p>
|
||||
</div>
|
||||
|
||||
<div class="test-links">
|
||||
<h3>🧪 Quick Tests</h3>
|
||||
<a href="/health">📊 Health Check</a>
|
||||
<a href="/api/device/battery">🔋 Battery</a>
|
||||
<a href="/api/device/info">ℹ️ Device Info</a>
|
||||
<a href="/api/contacts/test">📇 Test Contacts</a>
|
||||
</div>
|
||||
|
||||
<div class="footer" style="text-align: center; margin-top: 40px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.2); opacity: 0.7; font-size: 0.9em;">
|
||||
@ -330,6 +360,14 @@ def health_check():
|
||||
@app.route('/api/sms/send', methods=['POST'])
|
||||
def send_sms():
|
||||
"""Send SMS message via Termux API"""
|
||||
# Verify API key
|
||||
if not verify_api_key():
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Authentication required',
|
||||
'message': 'Please provide valid API key via X-API-Key header'
|
||||
}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
@ -549,6 +587,14 @@ def get_contact_info(phone):
|
||||
@app.route('/api/sms/send-reply', methods=['POST'])
|
||||
def send_reply():
|
||||
"""Send a reply message with enhanced tracking"""
|
||||
# Verify API key
|
||||
if not verify_api_key():
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Authentication required',
|
||||
'message': 'Please provide valid API key via X-API-Key header'
|
||||
}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
@ -601,22 +647,124 @@ def campaign_notification():
|
||||
data = request.get_json()
|
||||
title = data.get('title', 'SMS Campaign')
|
||||
message = data.get('message', 'Campaign update')
|
||||
|
||||
|
||||
result = sms_server.execute_termux_command([
|
||||
'termux-notification',
|
||||
'--title', title,
|
||||
'--content', message
|
||||
])
|
||||
|
||||
|
||||
return jsonify({
|
||||
'success': result['success'],
|
||||
'error': result.get('error')
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Notification error: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/contacts/test', methods=['GET'])
|
||||
def test_contacts():
|
||||
"""Test endpoint to fetch and examine raw contact list structure"""
|
||||
try:
|
||||
logger.info("Testing termux-contact-list command...")
|
||||
|
||||
result = sms_server.execute_termux_command(['termux-contact-list'])
|
||||
|
||||
if not result['success']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': result.get('error', 'Unknown error'),
|
||||
'stderr': result.get('stderr', ''),
|
||||
'return_code': result.get('return_code')
|
||||
}), 400
|
||||
|
||||
# Try to parse JSON
|
||||
raw_output = result['stdout']
|
||||
contacts_data = None
|
||||
parse_error = None
|
||||
|
||||
try:
|
||||
contacts_data = json.loads(raw_output)
|
||||
except json.JSONDecodeError as e:
|
||||
parse_error = str(e)
|
||||
|
||||
# Analyze structure
|
||||
analysis = {
|
||||
'total_contacts': len(contacts_data) if isinstance(contacts_data, list) else 'N/A',
|
||||
'is_array': isinstance(contacts_data, list),
|
||||
'sample_contact': None,
|
||||
'all_fields': set()
|
||||
}
|
||||
|
||||
if isinstance(contacts_data, list) and len(contacts_data) > 0:
|
||||
# Get first contact as sample
|
||||
analysis['sample_contact'] = contacts_data[0]
|
||||
|
||||
# Collect all unique field names across contacts
|
||||
for contact in contacts_data[:10]: # Check first 10
|
||||
if isinstance(contact, dict):
|
||||
analysis['all_fields'].update(contact.keys())
|
||||
|
||||
analysis['all_fields'] = list(analysis['all_fields'])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'raw_output': raw_output,
|
||||
'parsed_data': contacts_data,
|
||||
'parse_error': parse_error,
|
||||
'analysis': analysis,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Contact test error: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'traceback': str(e.__traceback__)
|
||||
}), 500
|
||||
|
||||
@app.route('/api/contacts/list', methods=['GET'])
|
||||
def list_contacts():
|
||||
"""List all contacts from phone"""
|
||||
try:
|
||||
result = sms_server.execute_termux_command(['termux-contact-list'])
|
||||
|
||||
if result['success'] and result['stdout']:
|
||||
try:
|
||||
contacts = json.loads(result['stdout'])
|
||||
|
||||
# Optional filtering
|
||||
search = request.args.get('search', '').lower()
|
||||
if search:
|
||||
contacts = [c for c in contacts
|
||||
if search in str(c.get('name', '')).lower()
|
||||
or search in str(c.get('number', '')).lower()]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'contacts': contacts,
|
||||
'count': len(contacts),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except json.JSONDecodeError as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to parse contact data: {str(e)}',
|
||||
'raw_output': result['stdout']
|
||||
}), 500
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': result.get('error', 'Failed to retrieve contacts'),
|
||||
'stderr': result.get('stderr')
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Contact list error: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Create logs directory
|
||||
os.makedirs('/data/data/com.termux/files/home/logs', exist_ok=True)
|
||||
|
||||
@ -18,8 +18,20 @@ services:
|
||||
environment:
|
||||
PHONE_IP: ${PHONE_IP:-10.0.0.193}
|
||||
ADB_PORT: ${ADB_PORT:-5555}
|
||||
TERMUX_API_PORT: ${TERMUX_API_PORT:-5001}
|
||||
FLASK_ENV: ${FLASK_ENV:-production}
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
ADMIN_API_KEY: ${ADMIN_API_KEY}
|
||||
USER_API_KEY: ${USER_API_KEY}
|
||||
TERMUX_API_KEY: ${TERMUX_API_KEY}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
# Rate limiting configuration
|
||||
RATE_LIMIT_DEFAULT: ${RATE_LIMIT_DEFAULT:-200 per hour, 1000 per day}
|
||||
RATE_LIMIT_LOGIN: ${RATE_LIMIT_LOGIN:-5 per minute}
|
||||
RATE_LIMIT_SMS: ${RATE_LIMIT_SMS:-10 per minute, 100 per hour, 500 per day}
|
||||
RATE_LIMIT_UPLOAD: ${RATE_LIMIT_UPLOAD:-10 per hour, 50 per day}
|
||||
RATE_LIMIT_DATABASE_RESET: ${RATE_LIMIT_DATABASE_RESET:-2 per hour}
|
||||
network_mode: host # Required for ADB network connection (host mode needed for ADB)
|
||||
privileged: true # Required for USB access
|
||||
restart: unless-stopped
|
||||
|
||||
650
docs/api/endpoints.md
Normal file
650
docs/api/endpoints.md
Normal file
@ -0,0 +1,650 @@
|
||||
# API Endpoints Reference
|
||||
|
||||
Complete reference for all SMS Campaign Manager API endpoints.
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints (except `/health` and `/login`) require authentication via:
|
||||
- **API Key** in `X-API-Key` header, or
|
||||
- **Bearer Token** in `Authorization` header, or
|
||||
- **Session Cookie** from web login
|
||||
|
||||
## Base URLs
|
||||
|
||||
- **Ubuntu Server**: `http://localhost:5000`
|
||||
- **Tailscale**: `http://YOUR_TAILSCALE_IP:5000`
|
||||
- **Android Termux API**: `http://YOUR_ANDROID_IP:5001`
|
||||
|
||||
## Health & Status
|
||||
|
||||
### GET /health
|
||||
Check application health.
|
||||
|
||||
**Authentication**: None required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "2.0"
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Endpoints
|
||||
|
||||
### POST /api/auth/login
|
||||
User login (web dashboard).
|
||||
|
||||
**Authentication**: None required
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "your-password"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Login successful",
|
||||
"user": {
|
||||
"username": "admin",
|
||||
"role": "admin"
|
||||
},
|
||||
"redirect": "/"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/auth/logout
|
||||
User logout.
|
||||
|
||||
**Authentication**: Session cookie required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Logged out successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/auth/status
|
||||
Check authentication status.
|
||||
|
||||
**Authentication**: Session cookie required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"user": {
|
||||
"username": "admin",
|
||||
"role": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Campaign Management
|
||||
|
||||
### GET /api/campaign/list
|
||||
List all campaigns.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Spring Sale",
|
||||
"status": "active",
|
||||
"created_at": "2025-12-30 10:00:00",
|
||||
"total_contacts": 100,
|
||||
"sent": 50,
|
||||
"failed": 2
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### POST /api/campaign/create
|
||||
Create new campaign.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"name": "Campaign Name",
|
||||
"message_template": "Hi {name}, special offer for you!",
|
||||
"contact_list_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"campaign_id": 5,
|
||||
"message": "Campaign created successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/campaign/start
|
||||
Start a campaign.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"campaign_id": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Campaign started"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/campaign/pause
|
||||
Pause running campaign.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"campaign_id": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Campaign paused"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/campaign/resume
|
||||
Resume paused campaign.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"campaign_id": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Campaign resumed"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/campaign/status
|
||||
Get campaign status and progress.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Query Parameters**:
|
||||
- `campaign_id` (required): Campaign ID
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Spring Sale",
|
||||
"status": "running",
|
||||
"progress": {
|
||||
"total": 100,
|
||||
"sent": 45,
|
||||
"failed": 2,
|
||||
"pending": 53,
|
||||
"percentage": 45
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SMS Operations
|
||||
|
||||
### POST /api/sms/send/enhanced
|
||||
Send SMS via Termux API with retry logic.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"phone": "+1234567890",
|
||||
"message": "Hello from SMS Campaign Manager!"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"method": "termux_api",
|
||||
"message": "SMS sent successfully",
|
||||
"timestamp": "2025-12-30 14:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/sms/test
|
||||
Send test SMS (simulation, no actual SMS sent).
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"phone": "+1234567890",
|
||||
"message": "Test message"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Test SMS simulated successfully",
|
||||
"details": {
|
||||
"phone": "+1234567890",
|
||||
"message_length": 12,
|
||||
"estimated_cost": 0.01
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/sms/status
|
||||
Get SMS sending status and statistics.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"total_sent": 1250,
|
||||
"total_failed": 15,
|
||||
"success_rate": 98.8,
|
||||
"last_24h": {
|
||||
"sent": 120,
|
||||
"failed": 2
|
||||
},
|
||||
"connection_method": "termux_api"
|
||||
}
|
||||
```
|
||||
|
||||
## File Upload
|
||||
|
||||
### POST /api/csv/upload
|
||||
Upload CSV file with contacts.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Request**: `multipart/form-data`
|
||||
- `file`: CSV file with columns: `phone`, `name` (optional: `message`, `email`, etc.)
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"filename": "contacts_20251230.csv",
|
||||
"contacts_imported": 150,
|
||||
"list_id": 3
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/campaign/upload
|
||||
Upload CSV and create campaign in one step.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Request**: `multipart/form-data`
|
||||
- `file`: CSV file
|
||||
- `campaign_name`: Campaign name
|
||||
- `message_template`: Message template with {variables}
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"campaign_id": 6,
|
||||
"contacts_imported": 150,
|
||||
"message": "Campaign created and ready to start"
|
||||
}
|
||||
```
|
||||
|
||||
## Phone/Device Status
|
||||
|
||||
### GET /api/phone/status
|
||||
Check Android device connection and status.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"connected": true,
|
||||
"ip_address": "100.107.173.66",
|
||||
"termux_api": {
|
||||
"available": true,
|
||||
"port": 5001,
|
||||
"last_check": "2025-12-30 14:35:00"
|
||||
},
|
||||
"adb": {
|
||||
"available": false,
|
||||
"fallback_mode": false
|
||||
},
|
||||
"battery": {
|
||||
"percentage": 85,
|
||||
"status": "charging"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Operations
|
||||
|
||||
### GET /api/database/stats
|
||||
Get database statistics.
|
||||
|
||||
**Authentication**: User API key required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"campaigns": 12,
|
||||
"contacts": 5430,
|
||||
"messages_sent": 15280,
|
||||
"success_rate": 98.2,
|
||||
"database_size_mb": 45.6,
|
||||
"users": 5
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/database/reset
|
||||
Reset database (destructive operation).
|
||||
|
||||
**Authentication**: **Admin API key required**
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"confirm": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Database reset successfully",
|
||||
"warning": "All data has been deleted"
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Endpoints
|
||||
|
||||
### GET /api/admin/users
|
||||
List all users (admin only).
|
||||
|
||||
**Authentication**: Session cookie + admin role required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"role": "admin",
|
||||
"created_at": "2025-12-30 10:00:00",
|
||||
"last_login": "2025-12-30 14:00:00",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"created_at": "2025-12-30 11:00:00",
|
||||
"is_active": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### POST /api/admin/users/create
|
||||
Create new user (admin only).
|
||||
|
||||
**Authentication**: Session cookie + admin role required
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"username": "newuser",
|
||||
"password": "SecurePassword123!",
|
||||
"role": "user",
|
||||
"email": "user@example.com",
|
||||
"full_name": "New User"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"user_id": 3,
|
||||
"message": "User created successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/admin/users/<username>
|
||||
Delete user (admin only).
|
||||
|
||||
**Authentication**: Session cookie + admin role required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "User deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
## Android Termux API Endpoints
|
||||
|
||||
Base URL: `http://YOUR_ANDROID_IP:5001`
|
||||
|
||||
### GET /health
|
||||
Check Termux API server health.
|
||||
|
||||
**Authentication**: None required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"uptime_seconds": 3600,
|
||||
"version": "1.0"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/sms/send
|
||||
Send SMS via Termux API (internal use).
|
||||
|
||||
**Authentication**: Termux API key required
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"phone": "+1234567890",
|
||||
"message": "Hello!"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "SMS sent successfully",
|
||||
"timestamp": "2025-12-30 14:40:00"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/device/battery
|
||||
Get Android device battery status.
|
||||
|
||||
**Authentication**: Termux API key required
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"percentage": 85,
|
||||
"status": "charging",
|
||||
"temperature": 32.5,
|
||||
"health": "good"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints may return these error statuses:
|
||||
|
||||
### 401 Unauthorized
|
||||
```json
|
||||
{
|
||||
"error": "Authentication required",
|
||||
"message": "Please provide valid API key or login"
|
||||
}
|
||||
```
|
||||
|
||||
### 403 Forbidden
|
||||
```json
|
||||
{
|
||||
"error": "Insufficient permissions",
|
||||
"message": "Admin role required for this operation"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
```json
|
||||
{
|
||||
"error": "Resource not found",
|
||||
"message": "Campaign with ID 999 does not exist"
|
||||
}
|
||||
```
|
||||
|
||||
### 429 Too Many Requests
|
||||
```json
|
||||
{
|
||||
"error": "Rate limit exceeded",
|
||||
"message": "Too many requests. Please wait before trying again."
|
||||
}
|
||||
```
|
||||
|
||||
### 500 Internal Server Error
|
||||
```json
|
||||
{
|
||||
"error": "Internal server error",
|
||||
"message": "An unexpected error occurred",
|
||||
"details": "Error details here"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limits
|
||||
|
||||
Default rate limits per IP address:
|
||||
|
||||
- `/api/auth/login`: 5 requests/minute
|
||||
- `/api/sms/*`: 10 requests/minute, 100/hour, 500/day
|
||||
- `/api/csv/upload`: 10 requests/hour, 50/day
|
||||
- `/api/database/reset`: 2 requests/hour
|
||||
- All other endpoints: 200 requests/hour, 1000/day
|
||||
|
||||
Rate limit headers included in responses:
|
||||
```
|
||||
X-RateLimit-Limit: 200
|
||||
X-RateLimit-Remaining: 195
|
||||
X-RateLimit-Reset: 1767117854
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Using curl
|
||||
|
||||
```bash
|
||||
# With API key header
|
||||
curl -H "X-API-Key: YOUR_API_KEY" \
|
||||
http://localhost:5000/api/campaign/list
|
||||
|
||||
# With Bearer token
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
http://localhost:5000/api/campaign/list
|
||||
|
||||
# POST with JSON data
|
||||
curl -X POST \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"+1234567890","message":"Hello"}' \
|
||||
http://localhost:5000/api/sms/send/enhanced
|
||||
|
||||
# File upload
|
||||
curl -X POST \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-F "file=@contacts.csv" \
|
||||
http://localhost:5000/api/csv/upload
|
||||
```
|
||||
|
||||
### Using Python requests
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = "your-api-key-here"
|
||||
BASE_URL = "http://localhost:5000"
|
||||
|
||||
headers = {
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# List campaigns
|
||||
response = requests.get(f"{BASE_URL}/api/campaign/list", headers=headers)
|
||||
campaigns = response.json()
|
||||
|
||||
# Send SMS
|
||||
data = {
|
||||
"phone": "+1234567890",
|
||||
"message": "Hello from Python!"
|
||||
}
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/sms/send/enhanced",
|
||||
headers=headers,
|
||||
json=data
|
||||
)
|
||||
print(response.json())
|
||||
|
||||
# Upload CSV
|
||||
files = {"file": open("contacts.csv", "rb")}
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/csv/upload",
|
||||
headers={"X-API-Key": API_KEY},
|
||||
files=files
|
||||
)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [API Security](../security/api-security.md)
|
||||
- [Authentication Setup](../setup/authentication.md)
|
||||
- [Quick Start Guide](../setup/quick-start.md)
|
||||
@ -81,7 +81,7 @@ nano .env
|
||||
|
||||
**Deploy to Android:**
|
||||
```bash
|
||||
./deploy-android.sh
|
||||
./scripts/deploy-android.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
@ -202,7 +202,7 @@ sshd
|
||||
### "Termux API not responding"
|
||||
```bash
|
||||
# Redeploy services
|
||||
./deploy-android.sh
|
||||
./scripts/deploy-android.sh
|
||||
|
||||
# Or manually restart
|
||||
ssh -p 8022 100.107.173.66 "~/bin/start-all-services.sh"
|
||||
@ -1,365 +0,0 @@
|
||||
# Enhanced Conversations - WhatsApp-Style Messaging Interface
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
The Enhanced Conversations feature transforms the SMS Campaign Manager into a WhatsApp-style messaging interface with real-time bidirectional sync, message status tracking, and advanced conversation management.
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### 🔄 **Bidirectional SMS Sync**
|
||||
- Automatically syncs SMS messages from your Android device
|
||||
- Real-time message updates via WebSocket connection
|
||||
- Support for both campaign and manual messages
|
||||
- Smart conversation threading by phone number
|
||||
|
||||
### 📱 **WhatsApp-Style Interface**
|
||||
- Clean, modern messaging UI with message bubbles
|
||||
- Contact avatars with initials
|
||||
- Message timestamps and status indicators
|
||||
- Scrollable message history with pagination
|
||||
- Real-time typing and connection status
|
||||
|
||||
### 📊 **Message Status Tracking**
|
||||
- **Pending** ⏳ - Message queued for sending
|
||||
- **Sent** ✓ - Successfully sent via Termux API
|
||||
- **Delivered** ✓✓ - Confirmed delivery (when available)
|
||||
- **Failed** ❌ - Send attempt failed
|
||||
|
||||
### 🌟 **Advanced Conversation Management**
|
||||
- Star/unstar important conversations
|
||||
- Mark conversations as read/unread
|
||||
- Search conversations by name or phone number
|
||||
- Filter by status (All, Unread, Starred)
|
||||
- Contact name resolution from phone contacts
|
||||
|
||||
### ⚡ **Real-Time Updates**
|
||||
- WebSocket-powered live messaging
|
||||
- Instant message delivery notifications
|
||||
- Automatic conversation sync
|
||||
- Connection status indicators
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Backend Components
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Main App │ │ Sync Service │ │ Android Device │
|
||||
│ (Flask) │◄──►│ (Background) │◄──►│ (Termux API) │
|
||||
│ │ │ │ │ │
|
||||
│ • API Routes │ │ • SMS Pulling │ │ • SMS History │
|
||||
│ • WebSocket │ │ • Message Queue │ │ • Contact Names │
|
||||
│ • Database │ │ • Status Updates │ │ • Send SMS │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
**Enhanced Conversations Table:**
|
||||
- `is_starred` - Boolean flag for important conversations
|
||||
- `contact_name` - Resolved contact name from phone
|
||||
- `last_sync_timestamp` - Track sync progress
|
||||
- `total_message_count` - Message count optimization
|
||||
|
||||
**Enhanced Messages Table:**
|
||||
- `status` - Message delivery status
|
||||
- `direction` - Inbound/outbound classification
|
||||
- `timestamp` - Unix timestamp for sorting
|
||||
- `external_message_id` - Phone's SMS ID for deduplication
|
||||
- `sync_status` - Sync state tracking
|
||||
|
||||
## 🚀 Installation & Setup
|
||||
|
||||
### 1. Quick Setup
|
||||
|
||||
```bash
|
||||
# Run the automated deployment
|
||||
./scripts/deploy_enhanced_conversations.sh
|
||||
```
|
||||
|
||||
### 2. Manual Setup
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
cd src && pip install -r requirements.txt
|
||||
|
||||
# 2. Run database migration
|
||||
python scripts/migrate_conversations_db.py
|
||||
|
||||
# 3. Integrate with main app
|
||||
python scripts/integrate_enhanced_conversations.py
|
||||
|
||||
# 4. Update Android Termux API server
|
||||
scp android/termux-sms-api-server.py android-dev@your-phone-ip:~/projects/sms-campaign-manager/
|
||||
|
||||
# 5. Restart services
|
||||
docker-compose restart # OR python src/app.py
|
||||
```
|
||||
|
||||
### 3. Android Device Setup
|
||||
|
||||
The enhanced system requires updated Termux API endpoints:
|
||||
|
||||
```bash
|
||||
# On your Android device (via SSH)
|
||||
cd ~/projects/sms-campaign-manager
|
||||
pkill -f termux-sms-api-server.py
|
||||
python termux-sms-api-server.py &
|
||||
```
|
||||
|
||||
## 🎮 Usage Guide
|
||||
|
||||
### Starting a Conversation
|
||||
|
||||
1. **From Campaign**: Conversations automatically created when sending campaign messages
|
||||
2. **Manual Sync**: Click the sync button to pull message history from phone
|
||||
3. **Direct Access**: Navigate to Conversations tab in dashboard
|
||||
|
||||
### Messaging Interface
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [★] Contact Name 🔄 Sync [Last seen: 2m ago] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Hello! How can I help? [9:15 AM] │
|
||||
│ [You]: Thanks for reaching out ✓✓ [9:16 AM] │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Type a message... [Send] 📤 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Message Status Indicators
|
||||
|
||||
- ⏳ **Pending** - Message is being processed
|
||||
- ✓ **Sent** - Successfully sent to phone
|
||||
- ✓✓ **Delivered** - Confirmed delivery (when available)
|
||||
- ❌ **Failed** - Send attempt failed
|
||||
|
||||
### Conversation Management
|
||||
|
||||
**Filtering:**
|
||||
- **All** - Show all conversations
|
||||
- **Unread** - Only conversations with unread messages
|
||||
- **Starred** - Important/flagged conversations
|
||||
|
||||
**Actions:**
|
||||
- **Star/Unstar** - Mark conversations as important
|
||||
- **Mark as Read** - Clear unread indicators
|
||||
- **Sync History** - Pull latest messages from phone
|
||||
- **Search** - Find conversations by name or content
|
||||
|
||||
## 🔧 API Reference
|
||||
|
||||
### Enhanced Endpoints
|
||||
|
||||
```http
|
||||
# Get conversations with enhanced data
|
||||
GET /api/conversations/enhanced?search=john&starred=true
|
||||
|
||||
# Get paginated messages
|
||||
GET /api/conversations/{id}/messages?page=1&per_page=50
|
||||
|
||||
# Send message
|
||||
POST /api/conversations/{id}/send
|
||||
Content-Type: application/json
|
||||
{
|
||||
"message": "Hello there!"
|
||||
}
|
||||
|
||||
# Toggle star status
|
||||
PUT /api/conversations/{id}/star
|
||||
|
||||
# Mark as read
|
||||
PUT /api/conversations/{id}/mark-read
|
||||
|
||||
# Manual sync
|
||||
POST /api/conversations/{id}/sync
|
||||
POST /api/conversations/sync-all
|
||||
|
||||
# Get statistics
|
||||
GET /api/conversations/stats
|
||||
```
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
**Client → Server:**
|
||||
```javascript
|
||||
socket.emit('join_conversation', { conversation_id: 'conv_123' });
|
||||
socket.emit('send_message', {
|
||||
conversation_id: 'conv_123',
|
||||
phone: '5551234567',
|
||||
message: 'Hello!'
|
||||
});
|
||||
socket.emit('sync_conversation', { conversation_id: 'conv_123' });
|
||||
```
|
||||
|
||||
**Server → Client:**
|
||||
```javascript
|
||||
socket.on('new_message', (data) => { /* Handle new message */ });
|
||||
socket.on('message_status_update', (data) => { /* Update status */ });
|
||||
socket.on('conversation_update', (data) => { /* Update conversation */ });
|
||||
socket.on('sync_status', (data) => { /* Sync progress */ });
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Run Test Suite
|
||||
|
||||
```bash
|
||||
# Full test suite
|
||||
python scripts/test_enhanced_conversations.py
|
||||
|
||||
# Verbose output
|
||||
python scripts/test_enhanced_conversations.py --verbose
|
||||
|
||||
# Custom endpoints
|
||||
python scripts/test_enhanced_conversations.py --base-url http://localhost:5000 --phone-ip 192.168.1.100
|
||||
```
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] 🔌 WebSocket connection establishes
|
||||
- [ ] 📱 Android device API responds
|
||||
- [ ] 📤 Messages send successfully
|
||||
- [ ] 📥 Incoming messages appear in real-time
|
||||
- [ ] ⭐ Star/unstar functionality works
|
||||
- [ ] 🔍 Search and filters work
|
||||
- [ ] 📊 Status indicators update correctly
|
||||
- [ ] 🔄 Manual sync pulls message history
|
||||
- [ ] 📱 Contact names resolve from phone
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**WebSocket Connection Fails:**
|
||||
```bash
|
||||
# Check if Socket.IO is installed
|
||||
pip show flask-socketio
|
||||
|
||||
# Test WebSocket endpoint
|
||||
curl http://localhost:5000/socket.io/?EIO=4&transport=polling
|
||||
```
|
||||
|
||||
**Android API Not Responding:**
|
||||
```bash
|
||||
# Test connectivity
|
||||
ping your-phone-ip
|
||||
|
||||
# Check Termux API server
|
||||
curl http://your-phone-ip:5001/health
|
||||
|
||||
# Restart server on Android
|
||||
ssh android-dev "pkill -f termux-sms-api && python ~/projects/sms-campaign-manager/termux-sms-api-server.py &"
|
||||
```
|
||||
|
||||
**Messages Not Syncing:**
|
||||
```bash
|
||||
# Check sync service logs
|
||||
docker-compose logs -f sms-campaign
|
||||
|
||||
# Manual database inspection
|
||||
sqlite3 data/campaign.db "SELECT * FROM messages ORDER BY timestamp DESC LIMIT 10;"
|
||||
|
||||
# Test SMS history endpoint
|
||||
curl "http://your-phone-ip:5001/api/sms/history?limit=5"
|
||||
```
|
||||
|
||||
**Database Errors:**
|
||||
```bash
|
||||
# Re-run migration
|
||||
python scripts/migrate_conversations_db.py
|
||||
|
||||
# Check schema
|
||||
sqlite3 data/campaign.db ".schema conversations"
|
||||
|
||||
# Verify permissions
|
||||
ls -la data/campaign.db
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```python
|
||||
# In app.py
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# In JavaScript console
|
||||
localStorage.debug = 'socket.io-client:*';
|
||||
```
|
||||
|
||||
## 📈 Performance Optimization
|
||||
|
||||
### Database Optimization
|
||||
|
||||
- **Indexes**: Automatic indexes on conversation_id, timestamp, status
|
||||
- **Pagination**: Messages loaded in chunks of 50
|
||||
- **Caching**: Conversation list cached in memory
|
||||
|
||||
### Network Optimization
|
||||
|
||||
- **WebSocket**: Persistent connection reduces HTTP overhead
|
||||
- **Compression**: Message payloads automatically compressed
|
||||
- **Rate Limiting**: 2-second delay between SMS sends
|
||||
|
||||
### Memory Management
|
||||
|
||||
- **Connection Pooling**: SQLite WAL mode for concurrent access
|
||||
- **Message Cleanup**: Old messages archived after 30 days
|
||||
- **Client-side**: Virtual scrolling for large message lists
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
### Authentication
|
||||
|
||||
- **HMAC Signatures**: All Android API calls signed
|
||||
- **Whitelisted Commands**: Only approved Termux commands allowed
|
||||
- **Rate Limiting**: Prevents SMS spam and abuse
|
||||
|
||||
### Data Protection
|
||||
|
||||
- **Local Database**: Messages stored locally, not in cloud
|
||||
- **Encrypted Transport**: HTTPS/WSS for all communications
|
||||
- **Permission Model**: Respects Android SMS permissions
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone <repository>
|
||||
cd sms-campaign-manager
|
||||
|
||||
# Install development dependencies
|
||||
pip install -r src/requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# Run in development mode
|
||||
python src/app.py
|
||||
|
||||
# Run tests
|
||||
python scripts/test_enhanced_conversations.py --verbose
|
||||
```
|
||||
|
||||
### Architecture Guidelines
|
||||
|
||||
- **Backend**: Python with Flask, SQLite, asyncio
|
||||
- **Frontend**: Vanilla JavaScript with Alpine.js
|
||||
- **Real-time**: WebSocket via Socket.IO
|
||||
- **Styling**: Tailwind CSS utility classes
|
||||
- **Testing**: Python unittest + manual browser testing
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Termux API Documentation](https://wiki.termux.com/wiki/Termux:API)
|
||||
- [Flask-SocketIO Guide](https://flask-socketio.readthedocs.io/)
|
||||
- [Alpine.js Documentation](https://alpinejs.dev/)
|
||||
- [SMS Campaign Manager Wiki](../docs/)
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for efficient SMS campaign management and real-time messaging.**
|
||||
332
docs/guides/troubleshooting.md
Normal file
332
docs/guides/troubleshooting.md
Normal file
@ -0,0 +1,332 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
Common issues and solutions for SMS Campaign Manager.
|
||||
|
||||
## Application Issues
|
||||
|
||||
### Docker Container Won't Start
|
||||
|
||||
**Symptoms**: Container exits immediately or won't start
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Check logs for errors
|
||||
docker compose logs sms-campaign
|
||||
|
||||
# Verify environment variables are set
|
||||
cat .env | grep -E "(API_KEY|SECRET_KEY|PHONE_IP)"
|
||||
|
||||
# Rebuild the container
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Database Errors
|
||||
|
||||
**Symptoms**: "database is locked" or "unable to open database file"
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Stop all containers
|
||||
docker compose down
|
||||
|
||||
# Check database file permissions
|
||||
ls -la data/campaign.db
|
||||
|
||||
# Restart
|
||||
docker compose up -d
|
||||
|
||||
# If corrupted, reset database (WARNING: deletes all data)
|
||||
rm data/campaign.db
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Can't Access Web Dashboard
|
||||
|
||||
**Symptoms**: Connection refused or 404 errors
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Verify container is running
|
||||
docker compose ps
|
||||
|
||||
# Check if port 5000 is exposed
|
||||
docker compose port sms-campaign 5000
|
||||
|
||||
# Check firewall rules
|
||||
sudo ufw status | grep 5000
|
||||
|
||||
# Try localhost specifically
|
||||
curl http://127.0.0.1:5000/health
|
||||
```
|
||||
|
||||
## Authentication Issues
|
||||
|
||||
### API Key Not Working
|
||||
|
||||
**Symptoms**: "Authentication required" or "Invalid API key"
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Verify keys are loaded in container
|
||||
docker compose exec sms-campaign env | grep API_KEY
|
||||
|
||||
# Check .env file format (no spaces around =)
|
||||
cat .env | grep API_KEY
|
||||
|
||||
# Restart to reload environment
|
||||
docker compose restart
|
||||
|
||||
# Test with curl
|
||||
curl -H "X-API-Key: YOUR_KEY" http://localhost:5000/api/campaign/list
|
||||
```
|
||||
|
||||
### Can't Log In to Web Dashboard
|
||||
|
||||
**Symptoms**: "Invalid username or password"
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# List existing users
|
||||
python3 manage_users.py
|
||||
# Select option 2
|
||||
|
||||
# Reset admin password via .env
|
||||
nano .env
|
||||
# Update ADMIN_PASSWORD
|
||||
docker compose restart
|
||||
|
||||
# Create new user if needed
|
||||
python3 manage_users.py
|
||||
# Select option 1
|
||||
```
|
||||
|
||||
### Session Expires Immediately
|
||||
|
||||
**Symptoms**: Logged out after every page refresh
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Check browser cookies are enabled
|
||||
# Clear browser cache and cookies for localhost
|
||||
# Check session configuration in logs
|
||||
docker compose logs | grep -i session
|
||||
|
||||
# Verify SECRET_KEY is set in .env
|
||||
grep SECRET_KEY .env
|
||||
```
|
||||
|
||||
## Android/Termux Issues
|
||||
|
||||
### Can't Connect to Android Device
|
||||
|
||||
**Symptoms**: Connection timeouts, "device not found"
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Verify Tailscale is running on both devices
|
||||
tailscale status
|
||||
|
||||
# Ping Android device
|
||||
ping YOUR_ANDROID_TAILSCALE_IP
|
||||
|
||||
# Test SSH connection
|
||||
ssh -p 8022 android-dev@YOUR_ANDROID_IP "whoami"
|
||||
|
||||
# Check PHONE_IP in .env matches Android
|
||||
grep PHONE_IP .env
|
||||
```
|
||||
|
||||
### Termux API Server Not Responding
|
||||
|
||||
**Symptoms**: `/health` endpoint returns 404 or times out
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# SSH to Android
|
||||
ssh -p 8022 android-dev@YOUR_ANDROID_IP
|
||||
|
||||
# Check if service is running
|
||||
ps aux | grep termux-sms-api-server
|
||||
|
||||
# View service logs
|
||||
tail -f ~/logs/sms-api.log
|
||||
|
||||
# Restart service
|
||||
pkill -f termux-sms-api-server.py
|
||||
~/bin/start-sms-api.sh
|
||||
|
||||
# Or redeploy everything
|
||||
exit
|
||||
./scripts/deploy-android.sh
|
||||
```
|
||||
|
||||
### SMS Not Sending
|
||||
|
||||
**Symptoms**: Messages fail to send, stuck in queue
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Check Termux:API permissions on Android
|
||||
# Settings → Apps → Termux:API → Permissions → SMS (Allow)
|
||||
|
||||
# Test Termux API directly
|
||||
ssh -p 8022 android-dev@YOUR_ANDROID_IP
|
||||
termux-sms-list -l 1 # Should list recent SMS
|
||||
|
||||
# Check API server logs
|
||||
tail -20 ~/logs/sms-api.log
|
||||
|
||||
# Verify API key is set on Android
|
||||
grep SMS_API_SECRET ~/projects/sms-campaign-manager/.env
|
||||
|
||||
# Test SMS sending
|
||||
curl -X POST http://YOUR_ANDROID_IP:5001/api/sms/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: YOUR_TERMUX_API_KEY" \
|
||||
-d '{"phone":"YOUR_NUMBER","message":"Test"}'
|
||||
```
|
||||
|
||||
## Network Issues
|
||||
|
||||
### Tailscale Connection Problems
|
||||
|
||||
**Symptoms**: Can't reach devices over Tailscale
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Check Tailscale status on Ubuntu
|
||||
tailscale status
|
||||
tailscale ping YOUR_ANDROID_IP
|
||||
|
||||
# Restart Tailscale
|
||||
sudo systemctl restart tailscaled
|
||||
|
||||
# On Android (in Termux)
|
||||
# Open Tailscale app and reconnect
|
||||
|
||||
# Verify IPs haven't changed
|
||||
tailscale ip -4
|
||||
```
|
||||
|
||||
### SSH Connection Refused
|
||||
|
||||
**Symptoms**: Can't SSH to Android device
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# On Android (in Termux)
|
||||
# Start SSH server
|
||||
sshd
|
||||
|
||||
# Check if running
|
||||
ps aux | grep sshd
|
||||
|
||||
# Set password if not set
|
||||
passwd
|
||||
|
||||
# Check SSH port
|
||||
cat $PREFIX/etc/ssh/sshd_config | grep Port
|
||||
```
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### Slow SMS Sending
|
||||
|
||||
**Symptoms**: Messages take too long to send
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Check retry configuration in .env
|
||||
grep SMS_MAX_RETRIES .env
|
||||
grep DEFAULT_DELAY_SECONDS .env
|
||||
|
||||
# Reduce delays for faster sending
|
||||
# Edit .env:
|
||||
# DEFAULT_DELAY_SECONDS=1
|
||||
# SMS_RETRY_BASE_DELAY=1
|
||||
|
||||
# Restart
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### High CPU Usage
|
||||
|
||||
**Symptoms**: Container using excessive CPU
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Check container resource usage
|
||||
docker stats sms-campaign
|
||||
|
||||
# View active processes
|
||||
docker compose exec sms-campaign ps aux
|
||||
|
||||
# Check for infinite loops in logs
|
||||
docker compose logs --tail 100 sms-campaign
|
||||
|
||||
# Restart container
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
## Data Issues
|
||||
|
||||
### Lost Campaign Data
|
||||
|
||||
**Symptoms**: Campaigns or contacts disappeared
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Check if database file exists
|
||||
ls -lh data/campaign.db
|
||||
|
||||
# Restore from backup (if you have one)
|
||||
cp data/campaign.db.backup data/campaign.db
|
||||
docker compose restart
|
||||
|
||||
# Export data for backup
|
||||
sqlite3 data/campaign.db ".dump" > backup.sql
|
||||
```
|
||||
|
||||
### CSV Upload Fails
|
||||
|
||||
**Symptoms**: File upload errors or parsing failures
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Check file format
|
||||
head -5 your_file.csv
|
||||
|
||||
# Ensure CSV has proper headers
|
||||
# Required: phone, name (optional: message, email, etc.)
|
||||
|
||||
# Check file size
|
||||
ls -lh your_file.csv
|
||||
|
||||
# Verify upload directory permissions
|
||||
ls -ld uploads/
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
If issues persist:
|
||||
|
||||
1. **Check logs**: `docker compose logs -f sms-campaign`
|
||||
2. **Verify environment**: `docker compose exec sms-campaign env`
|
||||
3. **Test connectivity**: `curl http://localhost:5000/health`
|
||||
4. **Review configuration**: Check `.env` file for typos
|
||||
5. **Restart everything**:
|
||||
```bash
|
||||
docker compose down
|
||||
./scripts/deploy-android.sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting issues, include:
|
||||
- Error messages from logs
|
||||
- Steps to reproduce
|
||||
- Environment details (OS, Docker version)
|
||||
- Configuration (sanitized `.env` without secrets)
|
||||
566
docs/guides/user-management.md
Normal file
566
docs/guides/user-management.md
Normal file
@ -0,0 +1,566 @@
|
||||
# User Management Guide - SMS Campaign Manager
|
||||
|
||||
## 🎉 No More ModHeader Required!
|
||||
|
||||
Your application now has a complete user management system with session-based authentication. Users can log in through a web interface and stay logged in without needing browser extensions or API keys in headers.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ System Architecture
|
||||
|
||||
### Dual Authentication System
|
||||
|
||||
Your application now supports **two types of authentication**:
|
||||
|
||||
1. **Session-Based Authentication** (Web Dashboard)
|
||||
- Users log in with username/password
|
||||
- Sessions last 24 hours
|
||||
- Automatic login persistence
|
||||
- No browser extensions needed
|
||||
|
||||
2. **API Key Authentication** (Programmatic Access)
|
||||
- For external scripts and integrations
|
||||
- Uses `X-API-Key` header
|
||||
- Three roles: Admin, User, Termux
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Step 1: Add Default Admin to .env
|
||||
|
||||
Edit your `.env` file and add:
|
||||
|
||||
```env
|
||||
# User Management
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=YourSecurePassword123!
|
||||
```
|
||||
|
||||
### Step 2: Restart Application
|
||||
|
||||
```bash
|
||||
cd /mnt/storagessd1tb/campaign_connector
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
On first start, the system will automatically create the admin user from your .env file.
|
||||
|
||||
### Step 3: Access the Login Page
|
||||
|
||||
Open your browser and go to:
|
||||
```
|
||||
http://localhost:5000/login
|
||||
```
|
||||
|
||||
Or via Tailscale:
|
||||
```
|
||||
http://your-tailscale-ip:5000/login
|
||||
```
|
||||
|
||||
### Step 4: Log In
|
||||
|
||||
- Username: `admin` (or what you set in .env)
|
||||
- Password: Your password from .env
|
||||
|
||||
You'll be redirected to the dashboard and stay logged in for 24 hours!
|
||||
|
||||
---
|
||||
|
||||
## 👥 Managing Users
|
||||
|
||||
### Using the CLI Tool
|
||||
|
||||
The easiest way to manage users is with the command-line tool:
|
||||
|
||||
```bash
|
||||
cd /mnt/storagessd1tb/campaign_connector
|
||||
python3 manage_users.py
|
||||
```
|
||||
|
||||
This provides an interactive menu:
|
||||
```
|
||||
📱 SMS Campaign Manager - User Management
|
||||
══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Choose an option:
|
||||
1. Create new user
|
||||
2. List all users
|
||||
3. Delete user
|
||||
4. Change password
|
||||
5. Exit
|
||||
|
||||
Choice [1-5]:
|
||||
```
|
||||
|
||||
### Creating Users via CLI
|
||||
|
||||
```bash
|
||||
python3 manage_users.py
|
||||
# Select option 1
|
||||
# Follow prompts to create user
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
Username: john
|
||||
Password: ********
|
||||
Confirm password: ********
|
||||
|
||||
Select role:
|
||||
1. Admin (full access)
|
||||
2. User (regular access)
|
||||
Choice [1-2]: 2
|
||||
|
||||
Email (optional): john@example.com
|
||||
Full name (optional): John Doe
|
||||
|
||||
✅ User 'john' created successfully (role: user)
|
||||
```
|
||||
|
||||
### Creating Users via API (Admin Only)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/admin/users/create \
|
||||
-H "Cookie: session=YOUR_SESSION_COOKIE" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "jane",
|
||||
"password": "SecurePassword123!",
|
||||
"role": "user",
|
||||
"email": "jane@example.com",
|
||||
"full_name": "Jane Smith"
|
||||
}'
|
||||
```
|
||||
|
||||
### Listing Users
|
||||
|
||||
```bash
|
||||
# Via CLI
|
||||
python3 manage_users.py
|
||||
# Select option 2
|
||||
|
||||
# Via API (admin only)
|
||||
curl http://localhost:5000/api/admin/users \
|
||||
-H "Cookie: session=YOUR_SESSION_COOKIE"
|
||||
```
|
||||
|
||||
### Deleting Users
|
||||
|
||||
```bash
|
||||
# Via CLI
|
||||
python3 manage_users.py
|
||||
# Select option 3
|
||||
|
||||
# Via API (admin only)
|
||||
curl -X DELETE http://localhost:5000/api/admin/users/username \
|
||||
-H "Cookie: session=YOUR_SESSION_COOKIE"
|
||||
```
|
||||
|
||||
### Changing Passwords
|
||||
|
||||
```bash
|
||||
# Via CLI
|
||||
python3 manage_users.py
|
||||
# Select option 4
|
||||
|
||||
# Via Web Dashboard
|
||||
# User can change their own password through settings
|
||||
curl -X POST http://localhost:5000/api/auth/change-password \
|
||||
-H "Cookie: session=YOUR_SESSION_COOKIE" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"old_password": "OldPass123!",
|
||||
"new_password": "NewSecurePass456!"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 User Roles
|
||||
|
||||
### Admin Role
|
||||
**Full system access**
|
||||
|
||||
Permissions:
|
||||
- ✅ Everything User can do
|
||||
- ✅ Create/delete other users
|
||||
- ✅ View all users
|
||||
- ✅ Database reset
|
||||
- ✅ System configuration
|
||||
|
||||
Use cases:
|
||||
- System administrators
|
||||
- Primary account owners
|
||||
|
||||
### User Role
|
||||
**Regular application access**
|
||||
|
||||
Permissions:
|
||||
- ✅ Create and manage campaigns
|
||||
- ✅ Send SMS messages
|
||||
- ✅ Upload CSV files
|
||||
- ✅ View analytics
|
||||
- ✅ Manage conversations
|
||||
- ✅ Change own password
|
||||
- ❌ Cannot create/delete users
|
||||
- ❌ Cannot reset database
|
||||
|
||||
Use cases:
|
||||
- Team members
|
||||
- Campaign managers
|
||||
- Regular users
|
||||
|
||||
---
|
||||
|
||||
## 🌐 How Login Works
|
||||
|
||||
### Login Flow
|
||||
|
||||
1. **User visits `/` (dashboard)**
|
||||
- Not logged in → Redirected to `/login`
|
||||
- Logged in → Shows dashboard
|
||||
|
||||
2. **User enters credentials**
|
||||
- System checks username/password
|
||||
- Creates secure session token
|
||||
- Stores session in database
|
||||
- Sets HTTP-only session cookie
|
||||
|
||||
3. **User accesses protected pages**
|
||||
- Session cookie sent automatically
|
||||
- Server validates session token
|
||||
- User data available in `request.current_user`
|
||||
|
||||
4. **Session expires after 24 hours**
|
||||
- User must log in again
|
||||
- Old sessions automatically cleaned up
|
||||
|
||||
### Security Features
|
||||
|
||||
✅ **PBKDF2 password hashing** - 100,000 iterations
|
||||
✅ **HTTP-only cookies** - Prevents XSS attacks
|
||||
✅ **Session tokens** - Cryptographically secure
|
||||
✅ **Constant-time comparison** - Prevents timing attacks
|
||||
✅ **Session tracking** - IP and user agent logging
|
||||
✅ **Failed login protection** - Logged for monitoring
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Session Settings
|
||||
|
||||
Configured in `src/app.py`:
|
||||
|
||||
```python
|
||||
'SESSION_COOKIE_SECURE': False, # Set to True for HTTPS
|
||||
'SESSION_COOKIE_HTTPONLY': True, # Prevent JavaScript access
|
||||
'SESSION_COOKIE_SAMESITE': 'Lax', # CSRF protection
|
||||
'PERMANENT_SESSION_LIFETIME': 86400 # 24 hours
|
||||
```
|
||||
|
||||
For production with HTTPS:
|
||||
- Set `SESSION_COOKIE_SECURE` to `True`
|
||||
|
||||
### Database Tables
|
||||
|
||||
User system creates two tables:
|
||||
|
||||
**users table**:
|
||||
```sql
|
||||
- id (primary key)
|
||||
- username (unique)
|
||||
- password_hash (PBKDF2)
|
||||
- role (admin/user)
|
||||
- created_at
|
||||
- last_login
|
||||
- is_active
|
||||
- email
|
||||
- full_name
|
||||
```
|
||||
|
||||
**user_sessions table**:
|
||||
```sql
|
||||
- id (primary key)
|
||||
- user_id (foreign key)
|
||||
- session_token
|
||||
- ip_address
|
||||
- user_agent
|
||||
- created_at
|
||||
- expires_at
|
||||
- is_active
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Customizing the Login Page
|
||||
|
||||
The login page is at `src/templates/login.html`. You can customize:
|
||||
|
||||
- Logo/branding
|
||||
- Colors (uses Tailwind CSS)
|
||||
- Additional fields
|
||||
- Links to help/support
|
||||
|
||||
Example customization:
|
||||
```html
|
||||
<!-- Add company logo -->
|
||||
<div class="text-center mb-8">
|
||||
<img src="/static/logo.png" alt="Company Logo" class="mx-auto mb-4">
|
||||
<h1 class="text-3xl font-bold text-gray-800">Your Company Name</h1>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring and Auditing
|
||||
|
||||
### Check Active Sessions
|
||||
|
||||
```python
|
||||
# In your application code
|
||||
from core.user_auth import UserManager
|
||||
|
||||
user_manager = UserManager(config.DATABASE)
|
||||
sessions = user_manager.list_active_sessions() # Add this method if needed
|
||||
```
|
||||
|
||||
### View Login History
|
||||
|
||||
Check the `user_sessions` table:
|
||||
|
||||
```bash
|
||||
docker-compose exec sms-campaign sqlite3 /app/data/campaign.db
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT u.username, s.created_at, s.ip_address, s.user_agent
|
||||
FROM user_sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
### Monitor Failed Logins
|
||||
|
||||
Check application logs:
|
||||
|
||||
```bash
|
||||
docker-compose logs -f sms-campaign | grep "Failed login"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Migration from API Keys
|
||||
|
||||
If you were using ModHeader with API keys:
|
||||
|
||||
### Before (with ModHeader):
|
||||
```
|
||||
1. Install ModHeader
|
||||
2. Add X-API-Key header
|
||||
3. Set value to API key
|
||||
4. Access dashboard
|
||||
```
|
||||
|
||||
### After (with User Login):
|
||||
```
|
||||
1. Go to /login
|
||||
2. Enter username/password
|
||||
3. Click Sign In
|
||||
4. Access dashboard (stays logged in)
|
||||
```
|
||||
|
||||
**API keys still work for:**
|
||||
- External scripts
|
||||
- Automation
|
||||
- Mobile apps
|
||||
- Third-party integrations
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security Best Practices
|
||||
|
||||
### Password Requirements
|
||||
|
||||
Enforced by the system:
|
||||
- Minimum 8 characters
|
||||
- Recommended: Mix of letters, numbers, symbols
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Use strong passwords**
|
||||
- 12+ characters
|
||||
- Mix uppercase, lowercase, numbers, symbols
|
||||
- Use password manager
|
||||
|
||||
2. **Rotate admin credentials**
|
||||
- Change admin password every 90 days
|
||||
- Update .env file after changing
|
||||
|
||||
3. **Monitor access**
|
||||
- Review login logs regularly
|
||||
- Check for suspicious IPs
|
||||
- Disable inactive users
|
||||
|
||||
4. **Limit admin accounts**
|
||||
- Only create admin users when necessary
|
||||
- Most users should have 'user' role
|
||||
|
||||
5. **Use HTTPS in production**
|
||||
- Tailscale provides this automatically
|
||||
- Or set up reverse proxy with SSL
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Can't Log In
|
||||
|
||||
**Issue**: Invalid username or password
|
||||
|
||||
**Solutions**:
|
||||
1. Verify username is correct (case-sensitive)
|
||||
2. Reset password via CLI:
|
||||
```bash
|
||||
python3 manage_users.py
|
||||
# Choose option 4 (Change password)
|
||||
```
|
||||
3. Check if user exists:
|
||||
```bash
|
||||
python3 manage_users.py
|
||||
# Choose option 2 (List users)
|
||||
```
|
||||
|
||||
### Session Expires Too Quickly
|
||||
|
||||
**Issue**: Getting logged out frequently
|
||||
|
||||
**Solution**: Increase session lifetime in `src/app.py`:
|
||||
```python
|
||||
'PERMANENT_SESSION_LIFETIME': 604800 # 7 days instead of 24 hours
|
||||
```
|
||||
|
||||
### Database Locked Error
|
||||
|
||||
**Issue**: "database is locked" when creating users
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Stop application
|
||||
docker-compose down
|
||||
|
||||
# Start again
|
||||
docker-compose up -d
|
||||
|
||||
# Try creating user again
|
||||
python3 manage_users.py
|
||||
```
|
||||
|
||||
### Forgot Admin Password
|
||||
|
||||
**Solution 1**: Reset via .env
|
||||
```bash
|
||||
# Edit .env
|
||||
ADMIN_PASSWORD=NewSecurePassword789!
|
||||
|
||||
# Restart application
|
||||
docker-compose restart
|
||||
|
||||
# System will update the password
|
||||
```
|
||||
|
||||
**Solution 2**: Create new admin via Docker
|
||||
```bash
|
||||
docker-compose exec sms-campaign python3 manage_users.py
|
||||
```
|
||||
|
||||
### Login Page Not Showing
|
||||
|
||||
**Issue**: Redirects not working
|
||||
|
||||
**Solutions**:
|
||||
1. Clear browser cache
|
||||
2. Check if logged in: `http://localhost:5000/api/auth/status`
|
||||
3. Logout manually: `http://localhost:5000/api/auth/logout`
|
||||
|
||||
---
|
||||
|
||||
## 📱 Mobile/Remote Access
|
||||
|
||||
### Access via Tailscale
|
||||
|
||||
Your application is accessible via Tailscale VPN:
|
||||
|
||||
```
|
||||
https://your-tailscale-hostname:5000/login
|
||||
```
|
||||
|
||||
### Remember Me Feature
|
||||
|
||||
The "Remember me" checkbox (currently cosmetic) can be enhanced to extend session duration for trusted devices.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### Public Endpoints (No Auth Required)
|
||||
- `GET /login` - Login page
|
||||
- `POST /api/auth/login` - Login handler
|
||||
- `GET /health` - Health check
|
||||
|
||||
### User Endpoints (Login Required)
|
||||
- `GET /` - Dashboard
|
||||
- `GET /api/auth/status` - Check login status
|
||||
- `POST /api/auth/logout` - Logout
|
||||
- `POST /api/auth/change-password` - Change password
|
||||
|
||||
### Admin Endpoints (Admin Role Required)
|
||||
- `GET /api/admin/users` - List users
|
||||
- `POST /api/admin/users/create` - Create user
|
||||
- `DELETE /api/admin/users/<username>` - Delete user
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
### Recommended Setup
|
||||
|
||||
1. **Create initial admin**:
|
||||
```bash
|
||||
# Add to .env
|
||||
ADMIN_USERNAME=your_username
|
||||
ADMIN_PASSWORD=SecurePassword123!
|
||||
|
||||
# Restart
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
2. **Log in and test**:
|
||||
- Visit http://localhost:5000/login
|
||||
- Log in with credentials
|
||||
- Access dashboard
|
||||
|
||||
3. **Create additional users**:
|
||||
```bash
|
||||
python3 manage_users.py
|
||||
```
|
||||
|
||||
4. **Remove .env credentials** (optional for security):
|
||||
- After creating admin via database
|
||||
- Remove ADMIN_USERNAME and ADMIN_PASSWORD from .env
|
||||
- Users only in database (more secure)
|
||||
|
||||
5. **Set up monitoring**:
|
||||
- Monitor logs for failed logins
|
||||
- Review user list periodically
|
||||
- Disable inactive users
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-12-30
|
||||
**Version**: 2.0
|
||||
**Last Updated**: 2025-12-30
|
||||
|
||||
For API security documentation, see: [API Security](../security/api-security.md)
|
||||
140
docs/index.md
Normal file
140
docs/index.md
Normal file
@ -0,0 +1,140 @@
|
||||
# SMS Campaign Manager
|
||||
|
||||
**Dockerized SMS automation system with Android integration**
|
||||
|
||||
## Overview
|
||||
|
||||
SMS Campaign Manager is a Flask-based web application that automates sending SMS messages through an Android device. It uses Termux API for Android integration and runs in Docker for easy deployment.
|
||||
|
||||
## Key Features
|
||||
|
||||
### User Management
|
||||
|
||||
- Web dashboard login with username and password
|
||||
- API keys for programmatic access
|
||||
- Admin and user roles
|
||||
- 24-hour login sessions
|
||||
|
||||
### Android SMS Integration
|
||||
|
||||
- Send SMS through Android device via Termux API
|
||||
- Automatic fallback to ADB if Termux unavailable
|
||||
- Track message delivery status
|
||||
- Monitor device battery and connectivity
|
||||
- Auto-retry failed messages
|
||||
|
||||
### Campaign Features
|
||||
|
||||
- Import contacts from CSV files
|
||||
- Personalize messages with template variables
|
||||
- Schedule message batches
|
||||
- Real-time analytics dashboard
|
||||
- Track SMS replies
|
||||
|
||||
### Deployment
|
||||
|
||||
- Docker Compose setup
|
||||
- Environment-based configuration
|
||||
- Automatic health monitoring
|
||||
- One-command deployment scripts
|
||||
|
||||
## Architecture
|
||||
|
||||
The system has three main components:
|
||||
|
||||
1. **Flask Web Application** (Ubuntu server, port 5000)
|
||||
- Web dashboard for campaign management
|
||||
- REST API for external integrations
|
||||
- SQLite database for data storage
|
||||
|
||||
2. **Termux API Server** (Android device, port 5001)
|
||||
- Communicates with Android SMS system
|
||||
- Provides device status information
|
||||
- Handles message sending
|
||||
|
||||
3. **Android Monitor** (Android device, port 5000)
|
||||
- Dashboard running on Android
|
||||
- Device health monitoring
|
||||
- Service status tracking
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure environment
|
||||
cp .env.example .env
|
||||
nano .env # Set your Android device IP
|
||||
|
||||
# 2. Deploy to Android device
|
||||
./scripts/deploy-android.sh
|
||||
|
||||
# 3. Start the Flask application
|
||||
docker compose up -d
|
||||
|
||||
# 4. Open web dashboard
|
||||
open http://localhost:5000
|
||||
# Default login: admin / @thebunker
|
||||
```
|
||||
|
||||
See the [Quick Start Guide](setup/quick-start.md) for detailed setup instructions.
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
- Marketing campaigns to customer lists
|
||||
- Automated appointment reminders
|
||||
- System notifications and alerts
|
||||
- Testing SMS integrations
|
||||
- Personal message automation
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Android device with Termux and Termux:API apps
|
||||
- SSH access to Android device (typically port 8022)
|
||||
- Network connection between server and Android
|
||||
- Tailscale recommended for reliable connectivity
|
||||
- Local network also works
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
campaign_connector/
|
||||
├── src/ # Flask application code
|
||||
├── android/ # Android-side Python servers
|
||||
├── docs/ # Documentation (this site)
|
||||
├── scripts/ # Deployment and utility scripts
|
||||
├── docker/ # Docker configuration
|
||||
└── data/ # SQLite database (created at runtime)
|
||||
```
|
||||
|
||||
## Documentation Navigation
|
||||
|
||||
- **Getting Started**
|
||||
- [Quick Start](setup/quick-start.md) - Installation and first run
|
||||
- [Authentication Setup](setup/authentication.md) - User and API key setup
|
||||
|
||||
- **Deployment**
|
||||
- [Deployment Guide](deployment/deployment-guide.md) - Production deployment
|
||||
|
||||
- **User Guides**
|
||||
- [User Management](guides/user-management.md) - Adding and managing users
|
||||
|
||||
- **Development**
|
||||
- [Android Development](development/android-dev-setup.md) - Android setup details
|
||||
- [Termux Flask Setup](development/termux-flask-setup.md) - Termux configuration
|
||||
|
||||
- **Reference**
|
||||
- [File Structure](reference/files.md) - Detailed file organization
|
||||
- [Project Instructions](reference/project-instructions.md) - Development guidelines
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Flask 3.0.0 (Python web framework)
|
||||
- **Database**: SQLite (embedded database)
|
||||
- **Frontend**: HTML, Tailwind CSS, vanilla JavaScript
|
||||
- **Android**: Termux, Termux:API, ADB
|
||||
- **Deployment**: Docker, Docker Compose
|
||||
- **Networking**: Tailscale (optional but recommended)
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2025 Campaign Connector Team
|
||||
287
docs/reference/environment-variables.md
Normal file
287
docs/reference/environment-variables.md
Normal file
@ -0,0 +1,287 @@
|
||||
# Environment Variables Reference
|
||||
|
||||
Configuration guide for `.env` file settings.
|
||||
|
||||
## Required Variables
|
||||
|
||||
### Android Device Configuration
|
||||
|
||||
```env
|
||||
# Your Android device's IP address
|
||||
# Use Tailscale IP for best reliability
|
||||
PHONE_IP=100.107.173.66
|
||||
|
||||
# ADB wireless debugging port (optional, only for ADB fallback)
|
||||
ADB_PORT=5555
|
||||
|
||||
# Termux API server port
|
||||
TERMUX_API_PORT=5001
|
||||
```
|
||||
|
||||
**PHONE_IP**: IP address of your Android device
|
||||
- Recommended: Use Tailscale IP (e.g., `100.x.x.x`)
|
||||
- Alternative: Local network IP (e.g., `192.168.x.x`)
|
||||
- Find with: `tailscale ip -4` on Android
|
||||
|
||||
**ADB_PORT**: Port for ADB wireless debugging
|
||||
- Default: `5555`
|
||||
- Only needed if using ADB fallback
|
||||
- Can be omitted if using Termux API only
|
||||
|
||||
**TERMUX_API_PORT**: Port where Termux API server runs
|
||||
- Default: `5001`
|
||||
- Must match port in Android Termux server
|
||||
|
||||
### Flask Application
|
||||
|
||||
```env
|
||||
# Application environment
|
||||
FLASK_ENV=production
|
||||
|
||||
# Secret key for session encryption
|
||||
SECRET_KEY=your-very-secret-random-string-here
|
||||
|
||||
# Delay between SMS messages (seconds)
|
||||
DEFAULT_DELAY_SECONDS=3
|
||||
```
|
||||
|
||||
**FLASK_ENV**: Application environment mode
|
||||
- Values: `production`, `development`
|
||||
- Production: Disables debug mode, optimizes performance
|
||||
- Development: Enables debug mode, detailed errors
|
||||
|
||||
**SECRET_KEY**: Encryption key for sessions
|
||||
- Generate with: `python3 -c "import secrets; print(secrets.token_hex(32))"`
|
||||
- Must be random and secret
|
||||
- Change this from default!
|
||||
|
||||
**DEFAULT_DELAY_SECONDS**: Delay between sending SMS
|
||||
- Default: `3` seconds
|
||||
- Prevents carrier rate limiting
|
||||
- Lower = faster (but may trigger spam detection)
|
||||
- Higher = slower (but more reliable)
|
||||
|
||||
### SMS Retry Configuration
|
||||
|
||||
```env
|
||||
# Maximum retry attempts for failed SMS
|
||||
SMS_MAX_RETRIES=3
|
||||
|
||||
# Initial retry delay (exponential backoff)
|
||||
SMS_RETRY_BASE_DELAY=2
|
||||
|
||||
# Maximum retry delay
|
||||
SMS_MAX_RETRY_DELAY=8
|
||||
```
|
||||
|
||||
**SMS_MAX_RETRIES**: Number of retry attempts
|
||||
- Default: `3`
|
||||
- How many times to retry failed messages
|
||||
- Set to `0` to disable retries
|
||||
|
||||
**SMS_RETRY_BASE_DELAY**: Base delay for exponential backoff
|
||||
- Default: `2` seconds
|
||||
- First retry after 2s, second after 4s, third after 8s
|
||||
|
||||
**SMS_MAX_RETRY_DELAY**: Maximum delay cap
|
||||
- Default: `8` seconds
|
||||
- Prevents delays from growing too long
|
||||
|
||||
## Security Variables
|
||||
|
||||
### API Keys
|
||||
|
||||
```env
|
||||
# Admin API key (full access)
|
||||
ADMIN_API_KEY=your-admin-api-key-here
|
||||
|
||||
# User API key (regular access)
|
||||
USER_API_KEY=your-user-api-key-here
|
||||
|
||||
# Termux API key (Android communication)
|
||||
TERMUX_API_KEY=your-termux-api-key-here
|
||||
|
||||
# Termux API secret (same as TERMUX_API_KEY)
|
||||
TERMUX_API_SECRET=same-as-termux-api-key
|
||||
```
|
||||
|
||||
**Generate all keys with**:
|
||||
```bash
|
||||
python3 src/core/auth.py
|
||||
```
|
||||
|
||||
**ADMIN_API_KEY**: Full system access
|
||||
- Use for: Admin operations, database resets
|
||||
- Keep this secret and secure
|
||||
|
||||
**USER_API_KEY**: Regular operations
|
||||
- Use for: Campaigns, SMS sending, analytics
|
||||
- Share with trusted team members only
|
||||
|
||||
**TERMUX_API_KEY**: Android device communication
|
||||
- Use for: Internal communication with Termux server
|
||||
- Used automatically by connection manager
|
||||
|
||||
**TERMUX_API_SECRET**: Android server authentication
|
||||
- Must match `TERMUX_API_KEY`
|
||||
- Set on both Ubuntu server and Android device
|
||||
|
||||
### User Management
|
||||
|
||||
```env
|
||||
# Default admin user (created on first startup)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=ChangeThisPassword123!
|
||||
```
|
||||
|
||||
**ADMIN_USERNAME**: Initial admin username
|
||||
- Created automatically on first run
|
||||
- Can be changed via user management
|
||||
|
||||
**ADMIN_PASSWORD**: Initial admin password
|
||||
- Used to create first admin account
|
||||
- Should be strong and unique
|
||||
- Can be removed from .env after admin is created
|
||||
|
||||
## Optional Variables
|
||||
|
||||
### SMS Automation (ADB Fallback Only)
|
||||
|
||||
```env
|
||||
# Screen coordinates for send button (device-specific)
|
||||
SEND_BUTTON_X=1300
|
||||
SEND_BUTTON_Y=2900
|
||||
```
|
||||
|
||||
**SEND_BUTTON_X/Y**: Touch coordinates for SMS send button
|
||||
- Only needed if using ADB fallback
|
||||
- Device and screen resolution specific
|
||||
- Find with: `adb shell getevent` while tapping
|
||||
|
||||
### Database Configuration
|
||||
|
||||
```env
|
||||
# Database file path (relative to project root)
|
||||
DATABASE_PATH=data/campaign.db
|
||||
```
|
||||
|
||||
**DATABASE_PATH**: Location of SQLite database
|
||||
- Default: `data/campaign.db`
|
||||
- Automatically created if doesn't exist
|
||||
- Backed up by Docker volume
|
||||
|
||||
### Upload Configuration
|
||||
|
||||
```env
|
||||
# Maximum CSV file size (bytes)
|
||||
MAX_UPLOAD_SIZE=5242880 # 5MB
|
||||
|
||||
# Upload folder path
|
||||
UPLOAD_FOLDER=uploads
|
||||
```
|
||||
|
||||
**MAX_UPLOAD_SIZE**: Maximum CSV upload size
|
||||
- Default: 5MB (5242880 bytes)
|
||||
- Increase for larger contact lists
|
||||
|
||||
**UPLOAD_FOLDER**: CSV storage directory
|
||||
- Default: `uploads/`
|
||||
- Mounted as Docker volume
|
||||
|
||||
## Example .env File
|
||||
|
||||
Complete example configuration:
|
||||
|
||||
```env
|
||||
# Android Device (Tailscale)
|
||||
PHONE_IP=100.107.173.66
|
||||
ADB_PORT=5555
|
||||
TERMUX_API_PORT=5001
|
||||
|
||||
# Flask Application
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
DEFAULT_DELAY_SECONDS=3
|
||||
|
||||
# SMS Configuration
|
||||
SMS_MAX_RETRIES=3
|
||||
SMS_RETRY_BASE_DELAY=2
|
||||
SMS_MAX_RETRY_DELAY=8
|
||||
|
||||
# API Keys (generate with: python3 src/core/auth.py)
|
||||
ADMIN_API_KEY=your-generated-admin-key-here
|
||||
USER_API_KEY=your-generated-user-key-here
|
||||
TERMUX_API_KEY=your-generated-termux-key-here
|
||||
TERMUX_API_SECRET=your-generated-termux-key-here
|
||||
|
||||
# User Management
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=YourSecurePassword123!
|
||||
|
||||
# ADB Fallback (optional)
|
||||
SEND_BUTTON_X=1300
|
||||
SEND_BUTTON_Y=2900
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### DO:
|
||||
- Generate strong, random API keys
|
||||
- Use Tailscale IP for `PHONE_IP`
|
||||
- Keep `.env` file secret
|
||||
- Set restrictive file permissions: `chmod 600 .env`
|
||||
- Back up `.env` securely (encrypted)
|
||||
- Rotate API keys every 90 days
|
||||
|
||||
### DON'T:
|
||||
- Commit `.env` to version control (use `.env.example`)
|
||||
- Share `.env` file in plain text
|
||||
- Use default or weak SECRET_KEY
|
||||
- Hardcode values in application code
|
||||
- Log environment variables
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Changes Not Applied
|
||||
|
||||
After modifying `.env`:
|
||||
```bash
|
||||
# Restart Docker to reload environment
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Missing Variables
|
||||
|
||||
```bash
|
||||
# Check which variables are loaded
|
||||
docker compose exec sms-campaign env | grep -E "(API_KEY|PHONE_IP|SECRET_KEY)"
|
||||
|
||||
# Verify .env file format (no spaces around =)
|
||||
cat .env
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
```bash
|
||||
# Fix file permissions
|
||||
chmod 600 .env
|
||||
|
||||
# Verify ownership
|
||||
ls -l .env
|
||||
```
|
||||
|
||||
## Environment Variable Loading
|
||||
|
||||
Variables are loaded in this order:
|
||||
1. `.env` file in project root
|
||||
2. Docker Compose `environment` section
|
||||
3. System environment variables (highest priority)
|
||||
|
||||
Docker Compose automatically loads `.env` file if present.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Quick Start Guide](../setup/quick-start.md)
|
||||
- [Security Setup](../security/security-setup.md)
|
||||
- [Deployment Guide](../deployment/deployment-guide.md)
|
||||
355
docs/security/api-security.md
Normal file
355
docs/security/api-security.md
Normal file
@ -0,0 +1,355 @@
|
||||
# API Security Implementation Summary
|
||||
|
||||
## ✅ What Was Implemented
|
||||
|
||||
Your SMS Campaign Manager application has been secured with comprehensive API key authentication.
|
||||
|
||||
### 1. Authentication System
|
||||
**File**: [src/core/auth.py](src/core/auth.py)
|
||||
|
||||
- ✅ API key-based authentication system
|
||||
- ✅ Three-tier access control (Admin, User, Termux)
|
||||
- ✅ Constant-time comparison to prevent timing attacks
|
||||
- ✅ SHA-256 key hashing for secure storage
|
||||
- ✅ Cryptographically secure key generation
|
||||
|
||||
### 2. Protected Endpoints
|
||||
|
||||
All API endpoints now require authentication:
|
||||
|
||||
#### Flask Application Routes
|
||||
**Protected with `@require_auth()` decorator**:
|
||||
|
||||
- **Campaign Routes** ([src/routes/api/campaign_routes.py](src/routes/api/campaign_routes.py))
|
||||
- `/api/campaign/create` - User role required
|
||||
- `/api/campaign/start` - User role required
|
||||
- `/api/campaign/pause` - User role required
|
||||
- `/api/campaign/resume` - User role required
|
||||
- `/api/campaign/status` - User role required
|
||||
- `/api/campaign/list` - User role required
|
||||
- `/api/campaign/recent` - User role required
|
||||
|
||||
- **SMS Routes** ([src/routes/api/sms_routes.py](src/routes/api/sms_routes.py))
|
||||
- `/api/sms/test/real` - User role required
|
||||
- `/api/sms/test` - User role required
|
||||
- `/api/sms/send/enhanced` - User role required
|
||||
- `/api/sms/status` - User role required
|
||||
|
||||
- **Upload Routes** ([src/routes/api/upload_routes.py](src/routes/api/upload_routes.py))
|
||||
- `/api/csv/upload` - User role required
|
||||
- `/api/campaign/upload` - User role required
|
||||
|
||||
- **Database Routes** ([src/routes/api/database_routes.py](src/routes/api/database_routes.py))
|
||||
- `/api/database/reset` - **Admin role required** ⚠️
|
||||
- `/api/database/stats` - User role required
|
||||
|
||||
#### Termux API Server (Android)
|
||||
**Protected with `verify_api_key()` function**:
|
||||
|
||||
- `/api/sms/send` - Authentication required
|
||||
- `/api/sms/send-reply` - Authentication required
|
||||
|
||||
### 3. Configuration Updates
|
||||
|
||||
- ✅ Updated [.gitignore](.gitignore) to prevent secret leaks
|
||||
- ✅ Created [.env.example](.env.example) template
|
||||
- ✅ Updated [src/app.py](src/app.py) to initialize auth manager
|
||||
- ✅ Updated [android/termux-sms-api-server.py](android/termux-sms-api-server.py) with auth
|
||||
- ✅ Updated [src/services/sms/connection_manager.py](src/services/sms/connection_manager.py) to pass API keys
|
||||
|
||||
### 4. Documentation Created
|
||||
|
||||
- 📄 [SECURITY_SETUP.md](SECURITY_SETUP.md) - Complete setup guide
|
||||
- 📄 [API_SECURITY_SUMMARY.md](API_SECURITY_SUMMARY.md) - This file
|
||||
- 📄 [.env.example](.env.example) - Environment template
|
||||
- 🔧 [generate-api-keys.sh](generate-api-keys.sh) - Key generation script
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start Guide
|
||||
|
||||
### Step 1: Generate API Keys
|
||||
|
||||
```bash
|
||||
cd /mnt/storagessd1tb/campaign_connector
|
||||
./generate-api-keys.sh
|
||||
```
|
||||
|
||||
Or run directly:
|
||||
```bash
|
||||
python3 src/core/auth.py
|
||||
```
|
||||
|
||||
### Step 2: Update .env File
|
||||
|
||||
Copy the generated keys to your `.env` file:
|
||||
|
||||
```env
|
||||
ADMIN_API_KEY=<generated_admin_key>
|
||||
USER_API_KEY=<generated_user_key>
|
||||
TERMUX_API_KEY=<generated_termux_key>
|
||||
SECRET_KEY=<generated_secret_key>
|
||||
TERMUX_API_SECRET=<same_as_termux_api_key>
|
||||
```
|
||||
|
||||
### Step 3: Update Android Termux
|
||||
|
||||
SSH to Android and set the API key:
|
||||
```bash
|
||||
ssh android-dev@100.107.173.66 -p 8022
|
||||
echo "SMS_API_SECRET=<your_termux_api_key>" >> ~/projects/sms-campaign-manager/.env
|
||||
```
|
||||
|
||||
### Step 4: Restart Services
|
||||
|
||||
```bash
|
||||
# Restart Docker container
|
||||
docker-compose restart
|
||||
|
||||
# Restart Termux API server (on Android)
|
||||
ssh android-dev@100.107.173.66 -p 8022 "~/bin/sms-service.sh restart"
|
||||
```
|
||||
|
||||
### Step 5: Test Authentication
|
||||
|
||||
```bash
|
||||
# Should FAIL without API key
|
||||
curl http://localhost:5000/api/campaign/list
|
||||
|
||||
# Should SUCCEED with API key
|
||||
curl -H "X-API-Key: YOUR_USER_API_KEY" http://localhost:5000/api/campaign/list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 API Key Roles
|
||||
|
||||
### Admin API Key
|
||||
**Full system access including destructive operations**
|
||||
|
||||
Permissions:
|
||||
- ✅ All User permissions
|
||||
- ✅ Database reset
|
||||
- ✅ System configuration
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
curl -H "X-API-Key: ADMIN_API_KEY" http://localhost:5000/api/database/reset
|
||||
```
|
||||
|
||||
### User API Key
|
||||
**Regular application access for daily operations**
|
||||
|
||||
Permissions:
|
||||
- ✅ Create/manage campaigns
|
||||
- ✅ Send SMS messages
|
||||
- ✅ Upload CSV files
|
||||
- ✅ View analytics
|
||||
- ❌ Database reset
|
||||
- ❌ System config changes
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
curl -H "X-API-Key: USER_API_KEY" http://localhost:5000/api/campaign/create \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Test Campaign","message":"Hello {name}"}'
|
||||
```
|
||||
|
||||
### Termux API Key
|
||||
**Android device communication**
|
||||
|
||||
Permissions:
|
||||
- ✅ Send SMS via Termux
|
||||
- ✅ Query SMS history
|
||||
- ✅ Device status
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
curl -H "X-API-Key: TERMUX_API_KEY" http://100.107.173.66:5001/api/sms/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"1234567890","message":"Test"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 How Authentication Works
|
||||
|
||||
### Request Flow
|
||||
|
||||
1. **Client makes request** with API key in header
|
||||
```
|
||||
X-API-Key: abc123...xyz
|
||||
```
|
||||
|
||||
2. **AuthManager validates key**
|
||||
- Extracts key from `X-API-Key` or `Authorization: Bearer` header
|
||||
- Hashes provided key with SHA-256
|
||||
- Compares with stored key hashes using constant-time comparison
|
||||
- Determines user role (admin/user/termux)
|
||||
|
||||
3. **Authorization check**
|
||||
- Compares user role against endpoint requirements
|
||||
- Admin (level 3) > User (level 2) > Termux (level 1)
|
||||
- Allows access if user level >= required level
|
||||
|
||||
4. **Request processing**
|
||||
- If authorized: Request proceeds to endpoint
|
||||
- If unauthorized: Returns 401 or 403 error
|
||||
|
||||
### Security Features
|
||||
|
||||
✅ **Constant-time comparison** - Prevents timing attacks
|
||||
✅ **SHA-256 hashing** - Keys never stored in plaintext
|
||||
✅ **Role-based access** - Principle of least privilege
|
||||
✅ **Environment variables** - Secrets never in code
|
||||
✅ **Comprehensive logging** - All auth attempts logged
|
||||
✅ **Multiple auth methods** - Header or Bearer token
|
||||
|
||||
---
|
||||
|
||||
## 📱 Using with Web Dashboard
|
||||
|
||||
### Option 1: Browser Extension (Recommended)
|
||||
|
||||
Install **ModHeader** (Chrome/Firefox):
|
||||
1. Add header: `X-API-Key`
|
||||
2. Value: Your `USER_API_KEY`
|
||||
3. Filter: `http://localhost:5000/*`
|
||||
|
||||
### Option 2: Modify JavaScript
|
||||
|
||||
Edit [src/static/js/dashboard.js](src/static/js/dashboard.js):
|
||||
|
||||
```javascript
|
||||
// Add to all fetch() calls
|
||||
const headers = {
|
||||
'X-API-Key': 'YOUR_USER_API_KEY',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
fetch('/api/campaign/list', { headers })
|
||||
```
|
||||
|
||||
⚠️ **WARNING**: Don't commit API keys to JavaScript files!
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security Best Practices
|
||||
|
||||
### ✅ DO:
|
||||
- Store keys in environment variables only
|
||||
- Use different keys for different roles
|
||||
- Rotate keys every 90 days
|
||||
- Monitor logs for unauthorized attempts
|
||||
- Use HTTPS in production (Tailscale provides this)
|
||||
- Keep `.env` file permissions at 600
|
||||
- Back up keys securely (encrypted)
|
||||
|
||||
### ❌ DON'T:
|
||||
- Commit `.env` to git
|
||||
- Share keys in plain text
|
||||
- Use same key across environments
|
||||
- Store keys in JavaScript/HTML
|
||||
- Log API keys in application logs
|
||||
- Reuse compromised keys
|
||||
- Use weak or predictable keys
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Key Rotation
|
||||
|
||||
Rotate keys every 90 days:
|
||||
|
||||
1. Generate new keys: `./generate-api-keys.sh`
|
||||
2. Update `.env` files (server + Android)
|
||||
3. Restart all services
|
||||
4. Update any scripts/apps using old keys
|
||||
5. Test all functionality
|
||||
6. Securely delete old keys
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "Authentication required" error
|
||||
**Solution**: Add API key to request headers
|
||||
```bash
|
||||
curl -H "X-API-Key: YOUR_KEY" http://localhost:5000/api/endpoint
|
||||
```
|
||||
|
||||
### "Invalid API key" error
|
||||
**Causes**:
|
||||
- Wrong key copied (check for extra spaces)
|
||||
- Key not in .env file
|
||||
- Services not restarted after .env update
|
||||
- Using wrong role key (e.g., user key for admin endpoint)
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Verify .env has correct keys
|
||||
cat .env | grep API_KEY
|
||||
|
||||
# Restart services
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### Application won't start
|
||||
**Error**: "API keys must be configured"
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Generate keys
|
||||
python3 src/core/auth.py
|
||||
|
||||
# Add to .env
|
||||
nano .env
|
||||
|
||||
# Restart
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Web dashboard not working
|
||||
**Solution**: Use browser extension or update JavaScript (see above)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Security Improvements Made
|
||||
|
||||
| Issue | Severity | Status |
|
||||
|-------|----------|--------|
|
||||
| No authentication | 🔴 Critical | ✅ Fixed |
|
||||
| Database reset unprotected | 🔴 Critical | ✅ Fixed |
|
||||
| Hardcoded secrets in git | 🔴 Critical | ✅ Fixed |
|
||||
| Termux API unprotected | 🟠 High | ✅ Fixed |
|
||||
| .env in git history | 🟠 High | ⚠️ Action required |
|
||||
| No role-based access | 🟡 Medium | ✅ Fixed |
|
||||
| Weak secret keys | 🟡 Medium | ✅ Fixed |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps (Optional Enhancements)
|
||||
|
||||
1. **Rate limiting** - Add Flask-Limiter to prevent API abuse
|
||||
2. **Request logging** - Log all API calls for audit trail
|
||||
3. **IP whitelisting** - Restrict access by IP address
|
||||
4. **Tailscale ACLs** - Use Tailscale's access controls
|
||||
5. **Session tokens** - Implement JWT for web dashboard
|
||||
6. **2FA** - Add two-factor authentication for admin operations
|
||||
7. **API versioning** - Version your API endpoints
|
||||
8. **Monitoring** - Set up security alerts and dashboards
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check [SECURITY_SETUP.md](SECURITY_SETUP.md) for detailed guide
|
||||
2. Review logs: `docker-compose logs -f sms-campaign`
|
||||
3. Test connectivity: `curl http://localhost:5000/health`
|
||||
4. Verify environment: `docker-compose exec sms-campaign env | grep API_KEY`
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-12-30
|
||||
**Version**: 2.0 (Secured)
|
||||
**Last Updated**: 2025-12-30
|
||||
357
docs/security/security-setup.md
Normal file
357
docs/security/security-setup.md
Normal file
@ -0,0 +1,357 @@
|
||||
# Security Setup Guide - SMS Campaign Manager
|
||||
|
||||
## 🔒 Immediate Security Setup Required
|
||||
|
||||
This application now requires API key authentication for all endpoints. Follow these steps to complete the security setup.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Generate Secure API Keys
|
||||
|
||||
Run the key generation script to create cryptographically secure API keys:
|
||||
|
||||
```bash
|
||||
cd /mnt/storagessd1tb/campaign_connector
|
||||
python3 src/core/auth.py
|
||||
```
|
||||
|
||||
This will generate and display new API keys. **Copy these keys immediately** - they will only be shown once.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Update Environment Configuration
|
||||
|
||||
### On Ubuntu Homelab Server
|
||||
|
||||
1. **IMPORTANT**: Back up your current .env file (if it exists):
|
||||
```bash
|
||||
cp .env .env.backup
|
||||
```
|
||||
|
||||
2. Edit your `.env` file:
|
||||
```bash
|
||||
nano .env
|
||||
```
|
||||
|
||||
3. Replace the old keys with the newly generated ones:
|
||||
```env
|
||||
# Android Device Configuration (Tailscale IP)
|
||||
PHONE_IP=100.107.173.66
|
||||
ADB_PORT=5555
|
||||
TERMUX_API_PORT=5001
|
||||
|
||||
# Flask Application
|
||||
FLASK_ENV=production
|
||||
DEFAULT_DELAY_SECONDS=3
|
||||
|
||||
# SMS Automation (ADB coordinates for S24 Ultra)
|
||||
SEND_BUTTON_X=1300
|
||||
SEND_BUTTON_Y=2900
|
||||
|
||||
# SMS Retry Configuration
|
||||
SMS_MAX_RETRIES=3
|
||||
SMS_RETRY_BASE_DELAY=2
|
||||
SMS_MAX_RETRY_DELAY=8
|
||||
|
||||
# SECURITY - API KEYS (GENERATED KEYS GO HERE)
|
||||
ADMIN_API_KEY=<paste your generated ADMIN_API_KEY here>
|
||||
USER_API_KEY=<paste your generated USER_API_KEY here>
|
||||
TERMUX_API_KEY=<paste your generated TERMUX_API_KEY here>
|
||||
SECRET_KEY=<paste your generated SECRET_KEY here>
|
||||
TERMUX_API_SECRET=<paste your generated TERMUX_API_SECRET here>
|
||||
```
|
||||
|
||||
4. **Save the file** (Ctrl+O, Enter, Ctrl+X in nano)
|
||||
|
||||
5. **Secure the file permissions**:
|
||||
```bash
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
### On Android Device (Termux)
|
||||
|
||||
1. SSH into your Android device:
|
||||
```bash
|
||||
ssh android-dev@100.107.173.66 -p 8022
|
||||
```
|
||||
|
||||
2. Edit the Termux environment file:
|
||||
```bash
|
||||
nano ~/projects/sms-campaign-manager/.env
|
||||
```
|
||||
|
||||
3. Add the TERMUX_API_SECRET (use the same value as TERMUX_API_KEY from above):
|
||||
```env
|
||||
SMS_API_SECRET=<paste your generated TERMUX_API_KEY here>
|
||||
```
|
||||
|
||||
4. Save and secure:
|
||||
```bash
|
||||
chmod 600 ~/projects/sms-campaign-manager/.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Remove Old Secrets from Git History
|
||||
|
||||
⚠️ **CRITICAL**: Your old secrets are in git history. Follow these steps:
|
||||
|
||||
1. **Backup your repository**:
|
||||
```bash
|
||||
cd /mnt/storagessd1tb/campaign_connector
|
||||
tar -czf ../campaign_connector_backup_$(date +%Y%m%d).tar.gz .
|
||||
```
|
||||
|
||||
2. **Remove .env from git tracking** (if it's currently tracked):
|
||||
```bash
|
||||
git rm --cached .env
|
||||
git commit -m "Remove .env from version control"
|
||||
```
|
||||
|
||||
3. **Rewrite git history** to remove secrets (OPTIONAL but RECOMMENDED):
|
||||
```bash
|
||||
# Install git-filter-repo if not installed
|
||||
pip install git-filter-repo
|
||||
|
||||
# Remove .env from all commits
|
||||
git filter-repo --path .env --invert-paths
|
||||
|
||||
# Force push to remote (if you use a remote)
|
||||
git push origin --force --all
|
||||
```
|
||||
|
||||
4. **Verify .env is in .gitignore**:
|
||||
```bash
|
||||
grep "^.env$" .gitignore
|
||||
```
|
||||
Should return: `.env`
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Restart Services
|
||||
|
||||
### Restart Docker Container
|
||||
|
||||
```bash
|
||||
cd /mnt/storagessd1tb/campaign_connector
|
||||
docker-compose down
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Restart Termux SMS API Server
|
||||
|
||||
```bash
|
||||
ssh android-dev@100.107.173.66 -p 8022
|
||||
~/bin/sms-service.sh restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Test Authentication
|
||||
|
||||
### Test Flask API
|
||||
|
||||
```bash
|
||||
# This should FAIL (no API key)
|
||||
curl http://localhost:5000/api/campaign/list
|
||||
|
||||
# This should SUCCEED (with your USER_API_KEY)
|
||||
curl -H "X-API-Key: YOUR_USER_API_KEY_HERE" http://localhost:5000/api/campaign/list
|
||||
```
|
||||
|
||||
### Test Termux API
|
||||
|
||||
```bash
|
||||
# This should FAIL (no API key)
|
||||
curl http://100.107.173.66:5001/api/sms/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"1234567890","message":"test"}'
|
||||
|
||||
# This should SUCCEED (with your TERMUX_API_KEY)
|
||||
curl http://100.107.173.66:5001/api/sms/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: YOUR_TERMUX_API_KEY_HERE" \
|
||||
-d '{"phone":"1234567890","message":"test"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Key Roles and Permissions
|
||||
|
||||
### Admin API Key (`ADMIN_API_KEY`)
|
||||
**Permissions**: Full access to all endpoints including:
|
||||
- ✅ All user permissions
|
||||
- ✅ Database reset (`/api/database/reset`)
|
||||
- ✅ System configuration changes
|
||||
|
||||
**Use**: Personal admin access, automated admin scripts
|
||||
|
||||
### User API Key (`USER_API_KEY`)
|
||||
**Permissions**: Regular application access:
|
||||
- ✅ Create and manage campaigns
|
||||
- ✅ Send SMS messages
|
||||
- ✅ Upload CSV files
|
||||
- ✅ View analytics and reports
|
||||
- ❌ Cannot reset database
|
||||
- ❌ Cannot modify system configuration
|
||||
|
||||
**Use**: Web dashboard, regular API access, automated campaigns
|
||||
|
||||
### Termux API Key (`TERMUX_API_KEY`)
|
||||
**Permissions**: Android device communication:
|
||||
- ✅ Send SMS via Termux API
|
||||
- ✅ Query SMS history
|
||||
- ✅ Device status endpoints
|
||||
|
||||
**Use**: Communication between Flask server and Android device
|
||||
|
||||
---
|
||||
|
||||
## Using API Keys in the Web Dashboard
|
||||
|
||||
### Option 1: Browser Extension (Recommended)
|
||||
|
||||
Install "ModHeader" Chrome/Firefox extension:
|
||||
1. Add header: `X-API-Key`
|
||||
2. Value: Your `USER_API_KEY`
|
||||
3. Filter: `http://localhost:5000/*` or your Tailscale IP
|
||||
|
||||
### Option 2: Update Dashboard JavaScript
|
||||
|
||||
Edit `src/static/js/dashboard.js` and add the API key to all fetch requests:
|
||||
|
||||
```javascript
|
||||
// Find all fetch() calls and add headers
|
||||
fetch('/api/campaign/list', {
|
||||
headers: {
|
||||
'X-API-Key': 'YOUR_USER_API_KEY_HERE'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**⚠️ WARNING**: Only do this for local development. Never commit API keys to JavaScript files.
|
||||
|
||||
### Option 3: Nginx Proxy with Authentication
|
||||
|
||||
Set up nginx reverse proxy that adds the API key header automatically (advanced).
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### ✅ DO:
|
||||
- Store API keys in environment variables only
|
||||
- Use different keys for admin, user, and Termux access
|
||||
- Rotate keys every 90 days
|
||||
- Use HTTPS/TLS in production (Tailscale provides this)
|
||||
- Monitor logs for unauthorized access attempts
|
||||
- Back up your .env file securely (encrypted)
|
||||
- Use strong, randomly generated keys (64+ characters)
|
||||
|
||||
### ❌ DON'T:
|
||||
- Commit .env files to git
|
||||
- Share API keys in plain text (email, chat, etc.)
|
||||
- Use the same key across multiple environments
|
||||
- Store keys in JavaScript files
|
||||
- Log API keys in application logs
|
||||
- Reuse old compromised keys
|
||||
|
||||
---
|
||||
|
||||
## Rotating API Keys
|
||||
|
||||
To rotate keys (recommended every 90 days):
|
||||
|
||||
1. Generate new keys:
|
||||
```bash
|
||||
python3 src/core/auth.py
|
||||
```
|
||||
|
||||
2. Update .env with new keys
|
||||
3. Restart all services
|
||||
4. Update any scripts/applications using the old keys
|
||||
5. Test all functionality
|
||||
6. Securely delete old keys
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "Authentication required" error
|
||||
|
||||
**Solution**: Ensure you're passing the API key in the request:
|
||||
```bash
|
||||
curl -H "X-API-Key: YOUR_API_KEY" http://localhost:5000/api/endpoint
|
||||
```
|
||||
|
||||
### Issue: "Invalid API key" error
|
||||
|
||||
**Solutions**:
|
||||
1. Verify you copied the key correctly (no extra spaces)
|
||||
2. Check .env file has the correct key
|
||||
3. Restart Docker container to reload environment
|
||||
4. Verify key hasn't been rotated
|
||||
|
||||
### Issue: Application won't start - "API keys must be configured"
|
||||
|
||||
**Solution**:
|
||||
1. Generate keys: `python3 src/core/auth.py`
|
||||
2. Add keys to .env file
|
||||
3. Restart: `docker-compose restart`
|
||||
|
||||
### Issue: Web dashboard not working
|
||||
|
||||
**Solution**: Add API key to dashboard JavaScript or use browser extension (see "Using API Keys in the Web Dashboard" above)
|
||||
|
||||
---
|
||||
|
||||
## Emergency Access
|
||||
|
||||
If you lose your API keys:
|
||||
|
||||
1. **Stop the application**:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
2. **Generate new keys**:
|
||||
```bash
|
||||
python3 src/core/auth.py
|
||||
```
|
||||
|
||||
3. **Update .env file** with new keys
|
||||
|
||||
4. **Restart application**:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
1. Check logs: `docker-compose logs -f sms-campaign`
|
||||
2. Verify environment: `docker-compose exec sms-campaign env | grep API_KEY`
|
||||
3. Test connectivity: `curl http://localhost:5000/health`
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Additional Security Hardening)
|
||||
|
||||
After completing this setup, consider:
|
||||
|
||||
1. **Add rate limiting** to prevent API abuse
|
||||
2. **Implement request logging** for audit trails
|
||||
3. **Set up HTTPS** with proper TLS certificates
|
||||
4. **Enable Tailscale ACLs** to restrict access by device
|
||||
5. **Add IP whitelisting** for additional security
|
||||
6. **Implement session tokens** for web dashboard
|
||||
7. **Set up security monitoring** and alerts
|
||||
8. **Regular security audits** of logs and access patterns
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-12-30
|
||||
**Version**: 2.0 (Secured)
|
||||
223
docs/setup/authentication.md
Normal file
223
docs/setup/authentication.md
Normal file
@ -0,0 +1,223 @@
|
||||
# Quick Setup: User Authentication System
|
||||
|
||||
## ✅ What's Been Added
|
||||
|
||||
Your SMS Campaign Manager now has **complete user management** with web-based login. No more ModHeader or API keys in headers for the web dashboard!
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Setup (5 Minutes)
|
||||
|
||||
### Step 1: Update Your .env File
|
||||
|
||||
Add these lines to your existing `.env` file:
|
||||
|
||||
```bash
|
||||
# Add to /mnt/storagessd1tb/campaign_connector/.env
|
||||
|
||||
# User Management (create initial admin)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=ChangeThisSecurePassword123!
|
||||
```
|
||||
|
||||
### Step 2: Restart Docker
|
||||
|
||||
```bash
|
||||
cd /mnt/storagessd1tb/campaign_connector
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
The system will automatically create the admin user on startup.
|
||||
|
||||
### Step 3: Access the Login Page
|
||||
|
||||
Open your browser:
|
||||
```
|
||||
http://localhost:5000/login
|
||||
```
|
||||
|
||||
Or via Tailscale:
|
||||
```
|
||||
http://your-tailscale-ip:5000/login
|
||||
```
|
||||
|
||||
### Step 4: Log In
|
||||
|
||||
- **Username**: `admin`
|
||||
- **Password**: Whatever you set in `.env`
|
||||
|
||||
You're done! You'll stay logged in for 24 hours.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What Changed
|
||||
|
||||
### Before (API Keys Only)
|
||||
```
|
||||
❌ Install ModHeader extension
|
||||
❌ Add X-API-Key header manually
|
||||
❌ Remember to enable it for localhost
|
||||
❌ Different keys for different roles
|
||||
```
|
||||
|
||||
### After (User Login)
|
||||
```
|
||||
✅ Visit /login
|
||||
✅ Enter username/password
|
||||
✅ Click "Sign In"
|
||||
✅ Stay logged in for 24 hours
|
||||
✅ No browser extensions needed!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 Managing Users
|
||||
|
||||
### Create Additional Users
|
||||
|
||||
```bash
|
||||
cd /mnt/storagessd1tb/campaign_connector
|
||||
python3 manage_users.py
|
||||
```
|
||||
|
||||
Interactive menu will guide you through:
|
||||
- Creating new users
|
||||
- Listing existing users
|
||||
- Deleting users
|
||||
- Changing passwords
|
||||
|
||||
### User Roles
|
||||
|
||||
**Admin**: Full access (you should be admin)
|
||||
**User**: Regular access (for team members)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Both Authentication Methods Work
|
||||
|
||||
### Session-Based (Web Dashboard)
|
||||
- Log in with username/password
|
||||
- Stay logged in for 24 hours
|
||||
- Automatic session management
|
||||
- **Use this for the web interface**
|
||||
|
||||
### API Key-Based (External Scripts)
|
||||
- Still works for automation
|
||||
- Use `X-API-Key` header
|
||||
- Three keys: ADMIN_API_KEY, USER_API_KEY, TERMUX_API_KEY
|
||||
- **Use this for scripts and integrations**
|
||||
|
||||
---
|
||||
|
||||
## 📝 Files Created
|
||||
|
||||
1. **src/core/user_auth.py** - User authentication system
|
||||
2. **src/routes/auth_routes.py** - Login/logout routes
|
||||
3. **src/templates/login.html** - Beautiful login page
|
||||
4. **manage_users.py** - CLI tool for user management (in project root)
|
||||
5. **[User Management Guide](../guides/user-management.md)** - Complete guide
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Login
|
||||
```bash
|
||||
# Should redirect to login page
|
||||
curl -i http://localhost:5000/
|
||||
|
||||
# Should show login page
|
||||
curl http://localhost:5000/login
|
||||
```
|
||||
|
||||
### Test Authentication
|
||||
```bash
|
||||
# Login via API
|
||||
curl -X POST http://localhost:5000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"YourPassword"}'
|
||||
|
||||
# Should return:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Login successful",
|
||||
"user": {
|
||||
"username": "admin",
|
||||
"role": "admin"
|
||||
},
|
||||
"redirect": "/"
|
||||
}
|
||||
```
|
||||
|
||||
### Test Session
|
||||
```bash
|
||||
# Check auth status
|
||||
curl http://localhost:5000/api/auth/status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Migration Path
|
||||
|
||||
If you were using ModHeader before:
|
||||
|
||||
1. **Keep your API keys** - still work for automation
|
||||
2. **Add user login** - new feature for web dashboard
|
||||
3. **Choose your preference**:
|
||||
- Web browsing: Use username/password login
|
||||
- Scripts/automation: Use API keys
|
||||
|
||||
Both work simultaneously!
|
||||
|
||||
---
|
||||
|
||||
## 📚 Full Documentation
|
||||
|
||||
For complete details, see:
|
||||
- **[User Management Guide](../guides/user-management.md)** - Comprehensive user guide
|
||||
- **[API Security](../security/api-security.md)** - API key documentation
|
||||
- **[Security Setup](../security/security-setup.md)** - Security setup guide
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Quick Troubleshooting
|
||||
|
||||
**Can't log in?**
|
||||
```bash
|
||||
# List users to verify admin exists
|
||||
python3 manage_users.py
|
||||
# Choose option 2 to list users
|
||||
```
|
||||
|
||||
**Forgot password?**
|
||||
```bash
|
||||
# Change it via .env
|
||||
echo "ADMIN_PASSWORD=NewPassword123!" >> .env
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
**Database error?**
|
||||
```bash
|
||||
docker-compose logs -f sms-campaign
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- ✅ Secure password hashing (PBKDF2, 100k iterations)
|
||||
- ✅ Session management (24-hour sessions)
|
||||
- ✅ HTTP-only cookies (XSS protection)
|
||||
- ✅ Role-based access control
|
||||
- ✅ User administration (admin only)
|
||||
- ✅ Password change functionality
|
||||
- ✅ Login tracking and auditing
|
||||
- ✅ Beautiful, responsive login page
|
||||
- ✅ CLI management tool
|
||||
- ✅ Database-backed user storage
|
||||
|
||||
---
|
||||
|
||||
**Ready to use!** Just add the env variables and restart. 🎉
|
||||
|
||||
**Questions?** Check the full guides or the troubleshooting sections.
|
||||
205
docs/setup/quick-start.md
Normal file
205
docs/setup/quick-start.md
Normal file
@ -0,0 +1,205 @@
|
||||
# 🚀 Quick Start - Deployment & Testing
|
||||
|
||||
## Prerequisites
|
||||
✅ `.env` file updated with API keys and admin credentials
|
||||
✅ Android device accessible via Tailscale
|
||||
✅ Docker installed and running
|
||||
|
||||
---
|
||||
|
||||
## 📦 Deploy Everything (3 Commands)
|
||||
|
||||
### 1. Deploy to Android
|
||||
```bash
|
||||
cd /mnt/storagessd1tb/campaign_connector
|
||||
./scripts/deploy-android.sh
|
||||
```
|
||||
**Wait for:** `🎉 Deployment Complete!`
|
||||
|
||||
### 2. Restart Docker
|
||||
```bash
|
||||
docker-compose down && docker-compose build && docker-compose up -d
|
||||
```
|
||||
**Wait for:** Container to be `healthy`
|
||||
|
||||
### 3. Verify Health
|
||||
```bash
|
||||
curl http://localhost:5000/health && \
|
||||
curl http://100.107.173.66:5001/health
|
||||
```
|
||||
**Expected:** Both return healthy status
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Quick Tests (5 Minutes)
|
||||
|
||||
### Test 1: Web Login (Browser)
|
||||
1. Open: **http://localhost:5000/**
|
||||
2. Should redirect to login page
|
||||
3. Login with:
|
||||
- Username: `admin`
|
||||
- Password: `Campaign2025!Secure`
|
||||
4. Should access dashboard **without ModHeader!** ✅
|
||||
|
||||
### Test 2: API Authentication (Terminal)
|
||||
```bash
|
||||
# Should FAIL (no key)
|
||||
curl http://localhost:5000/api/campaign/list
|
||||
|
||||
# Should SUCCEED (with key)
|
||||
curl http://localhost:5000/api/campaign/list \
|
||||
-H "X-API-Key: 2dd80622e868a9365bc037106fd5b2bda8c520805faaf3aa2267269c0b9303f8"
|
||||
```
|
||||
|
||||
### Test 3: Create User (Terminal)
|
||||
```bash
|
||||
python3 manage_users.py
|
||||
# Select: 1 (Create new user)
|
||||
# Username: testuser
|
||||
# Password: TestPass123!
|
||||
# Role: 2 (User)
|
||||
```
|
||||
|
||||
### Test 4: Send Test SMS (Terminal)
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/sms/test/real \
|
||||
-H "X-API-Key: 2dd80622e868a9365bc037106fd5b2bda8c520805faaf3aa2267269c0b9303f8" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"YOUR_NUMBER","message":"Test from secured API!"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Your Credentials
|
||||
|
||||
### Web Dashboard Login
|
||||
- URL: `http://localhost:5000/login`
|
||||
- Username: `admin`
|
||||
- Password: `Campaign2025!Secure`
|
||||
|
||||
### API Keys (for scripts)
|
||||
```bash
|
||||
# User operations (most common)
|
||||
USER_API_KEY="2dd80622e868a9365bc037106fd5b2bda8c520805faaf3aa2267269c0b9303f8"
|
||||
|
||||
# Admin operations (database reset, user management)
|
||||
ADMIN_API_KEY="208da9821e9f945355cd4c65e22a0570d8cf367483cfaef42cfd858cefacb7dd"
|
||||
|
||||
# Android communication
|
||||
TERMUX_API_KEY="aee141babda29fb0e68b5eb462c7feb5885f29b12c735d29ea337c360b00d351"
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
```bash
|
||||
# With API key
|
||||
curl http://localhost:5000/api/endpoint \
|
||||
-H "X-API-Key: YOUR_API_KEY_HERE"
|
||||
|
||||
# Or with Bearer token
|
||||
curl http://localhost:5000/api/endpoint \
|
||||
-H "Authorization: Bearer YOUR_API_KEY_HERE"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 User Management
|
||||
|
||||
### Create New User
|
||||
```bash
|
||||
python3 manage_users.py
|
||||
# Option 1: Create new user
|
||||
```
|
||||
|
||||
### List All Users
|
||||
```bash
|
||||
python3 manage_users.py
|
||||
# Option 2: List all users
|
||||
```
|
||||
|
||||
### Change Password
|
||||
```bash
|
||||
python3 manage_users.py
|
||||
# Option 4: Change password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Can't Login?
|
||||
```bash
|
||||
# Check if admin was created
|
||||
docker-compose logs | grep "Created admin"
|
||||
|
||||
# Or create manually
|
||||
docker-compose exec sms-campaign python3 manage_users.py
|
||||
```
|
||||
|
||||
### API Key Not Working?
|
||||
```bash
|
||||
# Verify keys loaded
|
||||
docker-compose exec sms-campaign env | grep API_KEY
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f | grep "Authentication"
|
||||
```
|
||||
|
||||
### Termux API Error?
|
||||
```bash
|
||||
# Check Android service
|
||||
ssh android-dev@100.107.173.66 -p 8022
|
||||
pgrep -f termux-sms-api-server.py
|
||||
|
||||
# View logs
|
||||
tail -f ~/projects/sms-campaign-manager/logs/sms-api.log
|
||||
```
|
||||
|
||||
### Need to Restart?
|
||||
```bash
|
||||
# Restart Docker
|
||||
docker-compose restart
|
||||
|
||||
# Restart Android service
|
||||
./deploy-to-android.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Full Documentation
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| **[Deployment Guide](../deployment/deployment-guide.md)** | Complete deployment instructions |
|
||||
| **[User Management](../guides/user-management.md)** | User system guide |
|
||||
| **[Authentication Setup](authentication.md)** | Quick auth setup |
|
||||
| **[API Security](../security/api-security.md)** | API key documentation |
|
||||
| **[Security Setup](../security/security-setup.md)** | Security configuration |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Checklist
|
||||
|
||||
- [ ] Deployed to Android successfully
|
||||
- [ ] Docker container running and healthy
|
||||
- [ ] Can access login page at `/login`
|
||||
- [ ] Can log in as admin
|
||||
- [ ] Dashboard works without ModHeader
|
||||
- [ ] API calls require authentication
|
||||
- [ ] Can create new users via CLI
|
||||
- [ ] SMS sending works with authentication
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Test the web dashboard** - Login and explore
|
||||
2. **Create team users** - Use `manage_users.py`
|
||||
3. **Try API calls** - Test with and without keys
|
||||
4. **Send test SMS** - Verify end-to-end flow
|
||||
5. **Review logs** - Monitor authentication attempts
|
||||
|
||||
---
|
||||
|
||||
**Everything is configured and ready to test!** 🚀
|
||||
|
||||
**Support:** See [Deployment Guide](../deployment/deployment-guide.md) for detailed deployment instructions.
|
||||
192
docs/workplan.md
192
docs/workplan.md
@ -1,192 +0,0 @@
|
||||
# Android-Homelab Integration Workplan
|
||||
## SMS Campaign Manager + Termux API Integration
|
||||
|
||||
**📁 Project Status:** Codebase has been reorganized into logical directories. All file references below now point to new locations described in `../PROJECT_STRUCTURE.md`.
|
||||
|
||||
**🚀 Current Development:** Use `../run.sh dev` for development mode or `../run.sh start` for production deployment.
|
||||
|
||||
### Phase 1: Foundation Setup ✓ (Current State)
|
||||
- [x] **Core SMS automation working** - ADB-based message sending (`../src/app.py`)
|
||||
- [x] **Flask web application** - Campaign management interface
|
||||
- [x] **Docker containerization** - Production deployment ready (`../docker/dockerfile`)
|
||||
- [x] **CSV contact management** - Flexible column detection
|
||||
- [x] **Phone connectivity scripts** - Auto-discovery and monitoring (`../scripts/`)
|
||||
|
||||
### Phase 2: Termux API Integration (Priority 1) ✅ **COMPLETED**
|
||||
|
||||
#### 2.1 Android Environment Setup ✅ **FULLY OPERATIONAL**
|
||||
- [x] **Install Termux + Termux:API** from F-Droid on S24 Ultra
|
||||
- [x] **Configure Termux packages** - Python, pip, git, openssh, nodejs, termux-api
|
||||
- [x] **Test Termux API commands** - All functionality verified and operational
|
||||
```bash
|
||||
termux-sms-list # ✅ Working - SMS history access
|
||||
termux-sms-send # ✅ Working - Native SMS sending
|
||||
termux-notification # ✅ Working - Android notifications
|
||||
termux-battery-status # ✅ Working - JSON battery data
|
||||
termux-location # ✅ Working - GPS with permissions
|
||||
```
|
||||
- [x] **Set up SSH server** in Termux for remote access (port 8022)
|
||||
- [x] **Configure passwordless SSH** - Key-based authentication working
|
||||
- [x] **Create development environment** - Full remote development via VS Code SSH
|
||||
|
||||
#### 2.2 API Server Development ✅ **PRODUCTION READY**
|
||||
- [x] **Create Flask API server** in Termux environment (`../src/termux-sms-api-server.py`)
|
||||
- ✅ Production server running on 10.0.0.193:5001
|
||||
- ✅ Native SMS sending via termux-sms-send
|
||||
- ✅ Comprehensive error handling and logging
|
||||
- ✅ Web interface for testing and monitoring
|
||||
- [x] **Production SMS Campaign API** - Fully integrated with existing `../src/app.py`
|
||||
- [x] **Implement SMS endpoints** - All endpoints operational
|
||||
```python
|
||||
/api/sms/send # ✅ Send SMS via Termux API with name substitution
|
||||
/api/sms/list # ✅ Retrieve message history
|
||||
/api/sms/inbox # ✅ Check for responses
|
||||
/api/campaign/status # ✅ Campaign progress updates
|
||||
```
|
||||
- [x] **Add device status endpoints** - All operational
|
||||
```python
|
||||
/api/device/battery # ✅ Working - Comprehensive battery data
|
||||
/api/device/location # ✅ Working - GPS with accuracy metrics
|
||||
/api/device/info # ✅ System information and uptime
|
||||
/api/device/network # ✅ Connection status monitoring
|
||||
```
|
||||
|
||||
#### 2.3 Network Connectivity ✅ **SSH SOLUTION OPERATIONAL**
|
||||
- [x] **SSH over local network** - Stable, reliable connection (10.0.0.193:8022)
|
||||
- [x] **Passwordless authentication** - Key-based SSH access established
|
||||
- [x] **Test persistent connectivity** - Works across network changes and device sleep
|
||||
- [x] **Document connection procedures** - Complete setup guide with 472+ pages
|
||||
- [x] **Remote development environment** - VS Code Remote SSH fully functional
|
||||
- ~~[ ] **Install Tailscale** - Not compatible with Termux (no root access)~~
|
||||
- ~~[ ] **Configure secure mesh network** - Replaced with superior SSH solution~~
|
||||
|
||||
### Phase 3: Flask Application Enhancement (Priority 2) ✅ **COMPLETED**
|
||||
|
||||
#### 3.1 Dual Connection Support ✅ **OPERATIONAL**
|
||||
- [x] **Modify `../src/app.py`** to support both ADB and Termux API connections
|
||||
- [x] **Add connection type detection** - Automatic detection and selection
|
||||
- [x] **Implement connection failover** - Seamless switching via `../src/sms_connection_manager.py`
|
||||
- [x] **Update phone status monitoring** for dual modes with real-time health checks
|
||||
|
||||
#### 3.2 Enhanced SMS Operations ✅ **FULLY IMPLEMENTED**
|
||||
- [x] **Native Android SMS access** via Termux API - 50% faster than ADB
|
||||
- [x] **Real-time delivery status** - Comprehensive status tracking and logging
|
||||
- [x] **Improved error handling** - Network and permission issue recovery
|
||||
- [x] **Message queue management** - Retry logic and scheduling with dual connections
|
||||
- [x] **Connection performance tracking** - Success rates and timing metrics
|
||||
|
||||
#### 3.3 Advanced Features ✅ **OPERATIONAL**
|
||||
- [x] **Response classification** - Automated parsing of SMS replies (basic implementation)
|
||||
- [x] **Location-based campaigns** - GPS targeting via Termux location API
|
||||
- [x] **Sensor integration** - Environmental data collection (battery, location)
|
||||
- [x] **Device monitoring** - Comprehensive Android device status integration
|
||||
- [ ] **Photo/media attachments** - MMS capability (future enhancement)
|
||||
|
||||
### Phase 4: Monitoring & Analytics (Priority 3) ✅ **IMPLEMENTED**
|
||||
|
||||
#### 4.1 Device Monitoring ✅ **OPERATIONAL**
|
||||
- [x] **Battery level tracking** - Real-time monitoring with alerts
|
||||
- [x] **Network quality monitoring** - Connection stability metrics
|
||||
- [x] **Location history** - GPS tracking and movement logging
|
||||
- [x] **Sensor data logging** - Environmental conditions via Termux APIs
|
||||
- [x] **SSH connection health** - Remote development environment monitoring
|
||||
|
||||
#### 4.2 Campaign Analytics Enhancement ✅ **ENHANCED**
|
||||
- [x] **Delivery confirmation** - SMS delivery status via dual connections
|
||||
- [x] **Response rate analytics** - Reply tracking and classification
|
||||
- [x] **Connection performance** - Success rates and timing for both ADB and Termux API
|
||||
- [x] **Device health monitoring** - Battery, storage, performance metrics integrated
|
||||
- [x] **Geographic analytics** - Location-based insights (basic implementation)
|
||||
|
||||
#### 4.3 Dashboard Integration ✅ **COMPLETED**
|
||||
- [x] **Real-time device status** in web dashboard - Full Android integration
|
||||
- [x] **Connection health indicators** - Visual status for both SMS methods
|
||||
- [x] **Advanced analytics charts** - Delivery and response metrics
|
||||
- [x] **Device management panel** - Remote monitoring and control interface
|
||||
- [x] **SSH development integration** - Remote coding environment status
|
||||
|
||||
### Android Side (Lightweight)
|
||||
- **Termux** - Linux environment
|
||||
- **Termux:API** - Hardware access
|
||||
- **Python 3.11+** - API server
|
||||
- **Flask minimal** - Lightweight web framework
|
||||
- **Tailscale** - Secure networking
|
||||
- **SSH client/server** - Remote access
|
||||
|
||||
### Homelab Side (Full Featured)
|
||||
- **Docker** - Container orchestration
|
||||
- **Python Flask** - Main application
|
||||
- **SQLite/PostgreSQL** - Database
|
||||
- **ADB tools** - Android debugging
|
||||
- **Tailscale** - Mesh networking
|
||||
- **Monitoring stack** - Prometheus/Grafana integration
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Phase 2 Success Criteria ✅ **ALL ACHIEVED**
|
||||
- [x] Termux API server responds to authenticated requests
|
||||
- [x] SMS sending works via both ADB and Termux API with automatic failover
|
||||
- [x] Network connectivity remains stable across reconnections and device sleep
|
||||
- [x] Device status monitoring operational with comprehensive metrics
|
||||
|
||||
### Phase 3 Success Criteria ✅ **ALL ACHIEVED**
|
||||
- [x] Seamless failover between ADB and API connections (sub-second switching)
|
||||
- [x] Real-time SMS delivery confirmation via native Android APIs
|
||||
- [x] Response classification accuracy >90% (basic implementation complete)
|
||||
- [x] Zero message loss during network transitions
|
||||
|
||||
### Phase 4 Success Criteria ✅ **ALL ACHIEVED**
|
||||
- [x] Comprehensive device health monitoring integrated
|
||||
- [x] Enhanced analytics dashboard with real-time data
|
||||
- [x] Geographic and temporal campaign insights
|
||||
- [x] Automated alerting for system issues
|
||||
|
||||
### Current System Performance
|
||||
- **SMS Sending Speed**: 50% faster via Termux API vs ADB
|
||||
- **Connection Reliability**: 99%+ uptime with dual failover
|
||||
- **Failover Time**: <1 second automatic switching
|
||||
- **Remote Development**: Full VS Code SSH integration operational
|
||||
- **Device Battery Impact**: <2% additional drain (optimized)
|
||||
|
||||
## Risk Assessment & Mitigation
|
||||
|
||||
### High Risk Items - ✅ **MITIGATED**
|
||||
- **Android security restrictions** - API limitations
|
||||
- *Mitigation*: ✅ Comprehensive testing completed, dual connection fallback operational
|
||||
- **Network connectivity issues** - WiFi changes, mobile switching
|
||||
- *Mitigation*: ✅ Multiple connection methods, automatic reconnection, SSH persistence
|
||||
- **Battery optimization conflicts** - Android killing background services
|
||||
- *Mitigation*: ✅ Proper service configuration, Termux whitelist, SSH optimization
|
||||
|
||||
### Medium Risk Items - ✅ **MANAGED**
|
||||
- **Performance impact** - Battery drain from continuous services
|
||||
- *Mitigation*: ✅ Efficient polling intervals, optimized SSH connections, <2% battery impact
|
||||
- **Data usage** - Continuous API communications
|
||||
- *Mitigation*: ✅ Compression, efficient protocols, local network usage monitoring
|
||||
|
||||
### Low Risk Items - ✅ **RESOLVED**
|
||||
- **Development complexity** - Multiple connection methods
|
||||
- *Mitigation*: ✅ Unified interface via connection manager, comprehensive testing
|
||||
- **Documentation maintenance** - Keeping guides current
|
||||
- *Mitigation*: ✅ 472+ pages of documentation, regularly updated
|
||||
|
||||
## Next Immediate Actions ✅ **PROJECT COMPLETE - MAINTENANCE PHASE**
|
||||
|
||||
### Completed Major Phases
|
||||
1. ✅ **Phase 2.1** - Termux and Termux:API fully operational on S24 Ultra
|
||||
2. ✅ **Phase 2.2** - Production API server deployed and tested
|
||||
3. ✅ **Phase 2.3** - SSH remote development environment established
|
||||
4. ✅ **Phase 3** - Flask app enhanced with dual connections
|
||||
5. ✅ **Phase 4** - Monitoring and analytics integrated
|
||||
|
||||
### Current Maintenance Focus
|
||||
1. **Monitor system performance** - Track connection reliability and battery usage
|
||||
2. **Documentation updates** - Keep guides current as Android/Termux updates occur
|
||||
3. **Performance optimization** - Fine-tune connection timeouts and polling intervals
|
||||
4. **Security updates** - Maintain SSH keys and API authentication tokens
|
||||
|
||||
### Future Enhancement Opportunities (Phase 5+)
|
||||
1. **Advanced Analytics** - Machine learning for response classification
|
||||
2. **Multi-device Support** - Extend to multiple Android devices
|
||||
3. **Integration Expansion** - Connect with other homelab services
|
||||
4. **Mobile App** - Native Android management interface
|
||||
182
manage_users.py
Executable file
182
manage_users.py
Executable file
@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
User Management CLI Tool
|
||||
Create, list, delete, and manage users for SMS Campaign Manager
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import getpass
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
from core.user_auth import UserManager
|
||||
from core.config import config
|
||||
|
||||
def print_header(text):
|
||||
"""Print a formatted header"""
|
||||
print("\n" + "="*70)
|
||||
print(f" {text}")
|
||||
print("="*70 + "\n")
|
||||
|
||||
def create_user_interactive(user_manager):
|
||||
"""Interactively create a new user"""
|
||||
print_header("CREATE NEW USER")
|
||||
|
||||
username = input("Username: ").strip()
|
||||
if not username:
|
||||
print("❌ Username cannot be empty")
|
||||
return
|
||||
|
||||
# Check if user exists
|
||||
if user_manager.get_user_by_username(username):
|
||||
print(f"❌ User '{username}' already exists")
|
||||
return
|
||||
|
||||
password = getpass.getpass("Password (min 8 characters): ")
|
||||
if len(password) < 8:
|
||||
print("❌ Password must be at least 8 characters")
|
||||
return
|
||||
|
||||
password_confirm = getpass.getpass("Confirm password: ")
|
||||
if password != password_confirm:
|
||||
print("❌ Passwords do not match")
|
||||
return
|
||||
|
||||
print("\nSelect role:")
|
||||
print(" 1. Admin (full access)")
|
||||
print(" 2. User (regular access)")
|
||||
role_choice = input("Choice [1-2]: ").strip()
|
||||
|
||||
role = 'admin' if role_choice == '1' else 'user'
|
||||
|
||||
email = input("Email (optional): ").strip() or None
|
||||
full_name = input("Full name (optional): ").strip() or None
|
||||
|
||||
# Create user
|
||||
success = user_manager.create_user(username, password, role, email, full_name)
|
||||
|
||||
if success:
|
||||
print(f"\n✅ User '{username}' created successfully (role: {role})")
|
||||
else:
|
||||
print(f"\n❌ Failed to create user '{username}'")
|
||||
|
||||
def list_users(user_manager):
|
||||
"""List all users"""
|
||||
print_header("ALL USERS")
|
||||
|
||||
users = user_manager.list_users()
|
||||
|
||||
if not users:
|
||||
print("No users found")
|
||||
return
|
||||
|
||||
# Print table header
|
||||
print(f"{'ID':<5} {'Username':<20} {'Role':<10} {'Created':<20} {'Last Login':<20}")
|
||||
print("-" * 80)
|
||||
|
||||
for user in users:
|
||||
user_id = user.get('id', 'N/A')
|
||||
username = user.get('username', 'N/A')
|
||||
role = user.get('role', 'N/A')
|
||||
created = user.get('created_at', 'N/A')[:19] if user.get('created_at') else 'N/A'
|
||||
last_login = user.get('last_login', 'Never')[:19] if user.get('last_login') else 'Never'
|
||||
|
||||
print(f"{user_id:<5} {username:<20} {role:<10} {created:<20} {last_login:<20}")
|
||||
|
||||
print(f"\nTotal users: {len(users)}")
|
||||
|
||||
def delete_user_interactive(user_manager):
|
||||
"""Interactively delete a user"""
|
||||
print_header("DELETE USER")
|
||||
|
||||
username = input("Username to delete: ").strip()
|
||||
if not username:
|
||||
print("❌ Username cannot be empty")
|
||||
return
|
||||
|
||||
# Check if user exists
|
||||
user = user_manager.get_user_by_username(username)
|
||||
if not user:
|
||||
print(f"❌ User '{username}' not found")
|
||||
return
|
||||
|
||||
confirm = input(f"Are you sure you want to delete '{username}'? (yes/no): ").strip().lower()
|
||||
if confirm != 'yes':
|
||||
print("❌ Deletion cancelled")
|
||||
return
|
||||
|
||||
success = user_manager.delete_user(username)
|
||||
|
||||
if success:
|
||||
print(f"\n✅ User '{username}' deleted successfully")
|
||||
else:
|
||||
print(f"\n❌ Failed to delete user '{username}'")
|
||||
|
||||
def change_password_interactive(user_manager):
|
||||
"""Interactively change user password"""
|
||||
print_header("CHANGE PASSWORD")
|
||||
|
||||
username = input("Username: ").strip()
|
||||
if not username:
|
||||
print("❌ Username cannot be empty")
|
||||
return
|
||||
|
||||
old_password = getpass.getpass("Current password: ")
|
||||
new_password = getpass.getpass("New password (min 8 characters): ")
|
||||
|
||||
if len(new_password) < 8:
|
||||
print("❌ New password must be at least 8 characters")
|
||||
return
|
||||
|
||||
new_password_confirm = getpass.getpass("Confirm new password: ")
|
||||
if new_password != new_password_confirm:
|
||||
print("❌ Passwords do not match")
|
||||
return
|
||||
|
||||
success = user_manager.change_password(username, old_password, new_password)
|
||||
|
||||
if success:
|
||||
print(f"\n✅ Password changed successfully for '{username}'")
|
||||
else:
|
||||
print(f"\n❌ Failed to change password. Check the current password.")
|
||||
|
||||
def main():
|
||||
"""Main CLI interface"""
|
||||
print_header("📱 SMS Campaign Manager - User Management")
|
||||
|
||||
# Initialize user manager
|
||||
try:
|
||||
user_manager = UserManager(config.DATABASE)
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to initialize user manager: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
while True:
|
||||
print("\nChoose an option:")
|
||||
print(" 1. Create new user")
|
||||
print(" 2. List all users")
|
||||
print(" 3. Delete user")
|
||||
print(" 4. Change password")
|
||||
print(" 5. Exit")
|
||||
|
||||
choice = input("\nChoice [1-5]: ").strip()
|
||||
|
||||
if choice == '1':
|
||||
create_user_interactive(user_manager)
|
||||
elif choice == '2':
|
||||
list_users(user_manager)
|
||||
elif choice == '3':
|
||||
delete_user_interactive(user_manager)
|
||||
elif choice == '4':
|
||||
change_password_interactive(user_manager)
|
||||
elif choice == '5':
|
||||
print("\n👋 Goodbye!")
|
||||
break
|
||||
else:
|
||||
print("❌ Invalid choice. Please select 1-5.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
109
mkdocs.yml
Normal file
109
mkdocs.yml
Normal file
@ -0,0 +1,109 @@
|
||||
site_name: SMS Campaign Manager
|
||||
site_description: Secure, Dockerized SMS automation system with Android integration
|
||||
site_author: Campaign Connector Team
|
||||
repo_url: https://github.com/yourusername/campaign_connector
|
||||
edit_uri: ""
|
||||
|
||||
theme:
|
||||
name: material
|
||||
palette:
|
||||
# Palette toggle for light mode
|
||||
- scheme: default
|
||||
primary: indigo
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
# Palette toggle for dark mode
|
||||
- scheme: slate
|
||||
primary: indigo
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.sections
|
||||
- navigation.top
|
||||
- navigation.tracking
|
||||
- search.highlight
|
||||
- search.share
|
||||
- search.suggest
|
||||
- content.tabs.link
|
||||
- content.code.annotation
|
||||
- content.code.copy
|
||||
language: en
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
|
||||
plugins:
|
||||
- search:
|
||||
separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
|
||||
- minify:
|
||||
minify_html: true
|
||||
|
||||
markdown_extensions:
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
line_spans: __span
|
||||
pygments_lang_class: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.superfences
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
- admonition
|
||||
- pymdownx.details
|
||||
- pymdownx.mark
|
||||
- attr_list
|
||||
- def_list
|
||||
- footnotes
|
||||
- md_in_html
|
||||
- toc:
|
||||
permalink: true
|
||||
- pymdownx.arithmatex:
|
||||
generic: true
|
||||
- pymdownx.betterem:
|
||||
smart_enable: all
|
||||
- pymdownx.caret
|
||||
- pymdownx.keys
|
||||
- pymdownx.mark
|
||||
- pymdownx.smartsymbols
|
||||
- pymdownx.tilde
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
- pymdownx.tasklist:
|
||||
custom_checkbox: true
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started:
|
||||
- Quick Start: setup/quick-start.md
|
||||
- Authentication Setup: setup/authentication.md
|
||||
- Deployment:
|
||||
- Deployment Guide: deployment/deployment-guide.md
|
||||
- User Guides:
|
||||
- User Management: guides/user-management.md
|
||||
- Troubleshooting: guides/troubleshooting.md
|
||||
- API Reference:
|
||||
- Endpoints: api/endpoints.md
|
||||
- Security:
|
||||
- Security Setup: security/security-setup.md
|
||||
- API Security: security/api-security.md
|
||||
- Development:
|
||||
- Android Development: development/android-dev-setup.md
|
||||
- Termux Flask Setup: development/termux-flask-setup.md
|
||||
- Reference:
|
||||
- Environment Variables: reference/environment-variables.md
|
||||
- File Structure: reference/files.md
|
||||
- Project Instructions: reference/project-instructions.md
|
||||
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/yourusername/campaign_connector
|
||||
version:
|
||||
provider: mike
|
||||
|
||||
copyright: Copyright © 2025 Campaign Connector Team
|
||||
74
scripts/README.md
Normal file
74
scripts/README.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Scripts Directory
|
||||
|
||||
This directory contains utility and deployment scripts for the SMS Campaign Manager.
|
||||
|
||||
## Deployment Scripts
|
||||
|
||||
### deploy-android.sh
|
||||
Main deployment script for Android device setup.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./scripts/deploy-android.sh
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Tests connectivity to Android device
|
||||
- Deploys Python servers to `~/projects/sms-campaign-manager/`
|
||||
- Deploys shell scripts to `~/bin/`
|
||||
- Starts all Android services
|
||||
- Verifies deployment success
|
||||
|
||||
### deploy-to-android.sh
|
||||
Alternative deployment script (legacy).
|
||||
|
||||
### update-termux-server.sh
|
||||
Updates the Termux SMS API server on Android device.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./scripts/update-termux-server.sh
|
||||
```
|
||||
|
||||
## Utility Scripts
|
||||
|
||||
### auto.sh
|
||||
Automatic ADB connection script.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./scripts/auto.sh
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Automatically connects to Android device via ADB
|
||||
- Handles device discovery and connection setup
|
||||
|
||||
### ui.sh
|
||||
Terminal UI script for interactive management.
|
||||
|
||||
### fix-database.sh
|
||||
Database maintenance and repair script.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./scripts/fix-database.sh
|
||||
```
|
||||
|
||||
**Caution:** This script modifies the database. Back up `data/campaign.db` before running.
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
All scripts expect these environment variables to be set in `.env`:
|
||||
- `PHONE_IP` - Android device IP address (Tailscale IP recommended)
|
||||
- `ADB_PORT` - ADB port (default: 5555)
|
||||
- `TERMUX_API_PORT` - Termux API port (default: 5001)
|
||||
|
||||
## SSH Configuration
|
||||
|
||||
Scripts use SSH to connect to Android device on port 8022:
|
||||
```bash
|
||||
ssh android-dev@YOUR_PHONE_IP -p 8022
|
||||
```
|
||||
|
||||
Ensure SSH keys are set up for passwordless authentication.
|
||||
122
scripts/deploy-to-android.sh
Executable file
122
scripts/deploy-to-android.sh
Executable file
@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
# Deploy updated Termux API server to Android device
|
||||
# This script copies the updated server file and sets the API secret
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "========================================================================"
|
||||
echo "📱 Deploying Updated Termux API Server to Android"
|
||||
echo "========================================================================"
|
||||
echo ""
|
||||
|
||||
# Configuration
|
||||
ANDROID_USER="android-dev"
|
||||
ANDROID_IP="100.107.173.66"
|
||||
ANDROID_PORT="8022"
|
||||
TERMUX_API_SECRET="aee141babda29fb0e68b5eb462c7feb5885f29b12c735d29ea337c360b00d351"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}Step 1: Copying updated Termux API server...${NC}"
|
||||
scp -P ${ANDROID_PORT} \
|
||||
android/termux-sms-api-server.py \
|
||||
${ANDROID_USER}@${ANDROID_IP}:~/projects/sms-campaign-manager/
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ Server file copied successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Failed to copy server file${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Step 2: Setting Termux API secret on Android...${NC}"
|
||||
ssh -p ${ANDROID_PORT} ${ANDROID_USER}@${ANDROID_IP} << 'EOF'
|
||||
cd ~/projects/sms-campaign-manager/
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
touch .env
|
||||
|
||||
# Remove old SMS_API_SECRET if exists
|
||||
grep -v "^SMS_API_SECRET=" .env > .env.tmp || true
|
||||
mv .env.tmp .env
|
||||
|
||||
# Add new API secret
|
||||
echo "SMS_API_SECRET=aee141babda29fb0e68b5eb462c7feb5885f29b12c735d29ea337c360b00d351" >> .env
|
||||
|
||||
echo "✅ API secret configured"
|
||||
EOF
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ API secret configured successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Failed to configure API secret${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Step 3: Restarting Termux SMS API service...${NC}"
|
||||
ssh -p ${ANDROID_PORT} ${ANDROID_USER}@${ANDROID_IP} << 'EOF'
|
||||
# Stop the service if running
|
||||
pkill -f termux-sms-api-server.py || true
|
||||
sleep 2
|
||||
|
||||
# Create necessary directories
|
||||
cd ~/projects/sms-campaign-manager/
|
||||
mkdir -p logs
|
||||
mkdir -p /data/data/com.termux/files/home/logs
|
||||
|
||||
# Start the service
|
||||
nohup python3 termux-sms-api-server.py > logs/sms-api.log 2>&1 &
|
||||
|
||||
sleep 3
|
||||
|
||||
# Check if it's running
|
||||
if pgrep -f termux-sms-api-server.py > /dev/null; then
|
||||
echo "✅ Termux SMS API service started successfully"
|
||||
else
|
||||
echo "❌ Failed to start service"
|
||||
echo "Check logs: cat ~/projects/sms-campaign-manager/logs/sms-api.log"
|
||||
exit 1
|
||||
fi
|
||||
EOF
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ Service restarted successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Failed to restart service${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Step 4: Testing Termux API health...${NC}"
|
||||
sleep 2
|
||||
response=$(curl -s http://${ANDROID_IP}:5001/health)
|
||||
|
||||
if echo "$response" | grep -q "healthy"; then
|
||||
echo -e "${GREEN}✅ Termux API is healthy and responding${NC}"
|
||||
echo "Response: $response"
|
||||
else
|
||||
echo -e "${RED}❌ Termux API health check failed${NC}"
|
||||
echo "Response: $response"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================================================"
|
||||
echo -e "${GREEN}🎉 Deployment Complete!${NC}"
|
||||
echo "========================================================================"
|
||||
echo ""
|
||||
echo "Termux API Server Status:"
|
||||
echo " - Server: http://${ANDROID_IP}:5001"
|
||||
echo " - Health: http://${ANDROID_IP}:5001/health"
|
||||
echo " - API Secret: Configured and active"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Restart your Docker container: docker-compose restart"
|
||||
echo " 2. Test the connection from your homelab server"
|
||||
echo ""
|
||||
@ -83,6 +83,9 @@ echo
|
||||
echo -e "${BLUE}📊 What was updated:${NC}"
|
||||
echo " ✅ Increased max message length from 160 to 1600 characters"
|
||||
echo " ✅ Reduced rate limit delay from 2.0 to 1.0 seconds"
|
||||
echo " ✅ Added contact list endpoints (termux-contact-list)"
|
||||
echo " ✅ Added /api/contacts/test for JSON structure analysis"
|
||||
echo " ✅ Added /api/contacts/list for fetching all contacts"
|
||||
echo " ✅ Added detailed error logging and validation"
|
||||
echo " ✅ Improved phone number cleaning"
|
||||
echo " ✅ Better debugging information"
|
||||
182
src/app.py
182
src/app.py
@ -7,8 +7,14 @@ Streamlined main application using modular components
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from flask import Flask, render_template
|
||||
from flask import Flask, render_template, request, jsonify, redirect, url_for
|
||||
from flask_login import LoginManager
|
||||
|
||||
# Generate cache version at startup (changes on each container restart)
|
||||
CACHE_VERSION = str(int(time.time()))
|
||||
|
||||
# Add src to Python path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@ -17,6 +23,9 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from core.config import config
|
||||
from core.logging_config import setup_logging
|
||||
from core.signal_handling import register_signal_handlers, shutdown_event
|
||||
from core.auth import AuthManager
|
||||
from core.user_auth import UserManager, require_login
|
||||
from core.rate_limiter import init_rate_limiter, get_rate_limit_config
|
||||
|
||||
from database import DatabaseManager, DatabaseHelper
|
||||
|
||||
@ -44,6 +53,8 @@ from routes.conversations import conversations_bp
|
||||
from routes.conversations_enhanced import conversations_enhanced_bp, set_services
|
||||
from routes.lists import lists_bp
|
||||
|
||||
from routes.auth_routes import auth_bp, init_auth_routes
|
||||
|
||||
# Services for enhanced conversations
|
||||
from services.termux_sync_service import TermuxSyncService
|
||||
from services.websocket_service import WebSocketService
|
||||
@ -54,13 +65,13 @@ logger = setup_logging()
|
||||
def create_app():
|
||||
"""Application factory"""
|
||||
logger.info("🚀 Starting SMS Campaign Manager...")
|
||||
|
||||
|
||||
# Initialize Flask app
|
||||
app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
app = Flask(__name__,
|
||||
app = Flask(__name__,
|
||||
static_folder=os.path.join(app_dir, 'static'),
|
||||
template_folder=os.path.join(app_dir, 'templates'))
|
||||
|
||||
|
||||
# Configure Flask app
|
||||
app.config.update({
|
||||
'SECRET_KEY': config.SECRET_KEY,
|
||||
@ -68,10 +79,98 @@ def create_app():
|
||||
'DATABASE': config.DATABASE,
|
||||
'MAX_CONTENT_LENGTH': config.MAX_CONTENT_LENGTH
|
||||
})
|
||||
|
||||
|
||||
# Configure session with Flask-Login settings
|
||||
use_https = os.environ.get('HTTPS', 'false').lower() == 'true'
|
||||
app.config.update({
|
||||
'SESSION_COOKIE_SECURE': use_https,
|
||||
'SESSION_COOKIE_HTTPONLY': True,
|
||||
'SESSION_COOKIE_SAMESITE': 'Lax',
|
||||
'PERMANENT_SESSION_LIFETIME': timedelta(hours=24),
|
||||
'REMEMBER_COOKIE_DURATION': timedelta(days=14),
|
||||
'REMEMBER_COOKIE_SECURE': use_https,
|
||||
'REMEMBER_COOKIE_HTTPONLY': True,
|
||||
})
|
||||
|
||||
# Initialize Flask-Login
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login_page'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'warning'
|
||||
app.login_manager = login_manager
|
||||
|
||||
# Inject cache version into all templates (auto-updates on container restart)
|
||||
@app.context_processor
|
||||
def inject_cache_version():
|
||||
return {'cache_version': CACHE_VERSION}
|
||||
|
||||
# Initialize authentication manager (for API keys)
|
||||
try:
|
||||
auth_manager = AuthManager()
|
||||
app.auth_manager = auth_manager # Make available to entire app
|
||||
logger.info("✅ API key authentication manager initialized")
|
||||
except ValueError as e:
|
||||
logger.error(f"❌ Failed to initialize authentication: {e}")
|
||||
logger.error("⚠️ Set ADMIN_API_KEY and USER_API_KEY environment variables")
|
||||
logger.error("⚠️ Run: python3 src/core/auth.py to generate keys")
|
||||
sys.exit(1)
|
||||
|
||||
# Initialize user manager (for session-based auth)
|
||||
try:
|
||||
user_manager = UserManager(config.DATABASE)
|
||||
app.user_manager = user_manager
|
||||
logger.info("✅ User authentication manager initialized")
|
||||
|
||||
# Create admin user from environment if it doesn't exist
|
||||
admin_username = os.environ.get('ADMIN_USERNAME', 'admin')
|
||||
admin_password = os.environ.get('ADMIN_PASSWORD')
|
||||
|
||||
if admin_password:
|
||||
existing_user = user_manager.get_user_by_username(admin_username)
|
||||
if not existing_user:
|
||||
user_manager.create_user(
|
||||
username=admin_username,
|
||||
password=admin_password,
|
||||
role='admin',
|
||||
email=f'{admin_username}@localhost',
|
||||
full_name='Administrator'
|
||||
)
|
||||
logger.info(f"✅ Created admin user from environment: {admin_username}")
|
||||
else:
|
||||
logger.info(f"ℹ️ Admin user already exists: {admin_username}")
|
||||
else:
|
||||
logger.warning("⚠️ ADMIN_PASSWORD not set in environment - no default admin created")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize user manager: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
# Configure Flask-Login user loader
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load user from session - called on every request"""
|
||||
return user_manager.get_user_by_id(int(user_id))
|
||||
|
||||
@login_manager.unauthorized_handler
|
||||
def unauthorized():
|
||||
"""Handle unauthorized access - return JSON for API, redirect for web"""
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'Authentication required',
|
||||
'message': 'Please log in to access this resource'
|
||||
}), 401
|
||||
return redirect(url_for('auth.login_page', next=request.url))
|
||||
|
||||
# Initialize rate limiter
|
||||
limiter = init_rate_limiter(app)
|
||||
app.limiter = limiter # Make available to entire app
|
||||
|
||||
# Create upload directory
|
||||
os.makedirs(config.UPLOAD_FOLDER, exist_ok=True)
|
||||
|
||||
|
||||
# Initialize database
|
||||
db_manager = DatabaseManager(config.DATABASE)
|
||||
if not db_manager.init_db():
|
||||
@ -110,6 +209,10 @@ def create_app():
|
||||
# Set services for enhanced conversations
|
||||
set_services(termux_sync, websocket_service)
|
||||
|
||||
# Initialize auth routes
|
||||
init_auth_routes(user_manager)
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
# Initialize and register API routes
|
||||
init_campaign_routes(campaign_manager, campaign_executor, db_helper)
|
||||
init_template_routes(db_helper)
|
||||
@ -128,18 +231,73 @@ def create_app():
|
||||
app.register_blueprint(upload_routes)
|
||||
app.register_blueprint(test_routes)
|
||||
app.register_blueprint(database_routes)
|
||||
|
||||
|
||||
# Apply rate limits to specific endpoints after blueprint registration
|
||||
# Get configured limits from environment
|
||||
rate_config = get_rate_limit_config()
|
||||
|
||||
limiter.limit(rate_config['login'])(app.view_functions['auth.login'])
|
||||
limiter.limit(rate_config['sms'])(app.view_functions['sms_routes.test_sms_real'])
|
||||
limiter.limit(rate_config['sms'])(app.view_functions['sms_routes.send_enhanced_sms'])
|
||||
limiter.limit(rate_config['upload'])(app.view_functions['upload_routes.upload_csv'])
|
||||
limiter.limit(rate_config['upload'])(app.view_functions['upload_routes.upload_campaign_csv'])
|
||||
limiter.limit(rate_config['database_reset'])(app.view_functions['database_routes.reset_database'])
|
||||
|
||||
# Exempt health and status endpoints from rate limiting for accurate real-time monitoring
|
||||
limiter.exempt(app.view_functions['connection_routes.phone_status'])
|
||||
limiter.exempt(app.view_functions['connection_routes.connection_status'])
|
||||
limiter.exempt(app.view_functions['connection_routes.device_status'])
|
||||
limiter.exempt(app.view_functions['connection_routes.termux_status'])
|
||||
|
||||
# Basic routes
|
||||
@app.route('/health')
|
||||
@limiter.exempt
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
"""Health check endpoint - exempt from rate limiting"""
|
||||
return {"status": "ok", "version": "2.0"}
|
||||
|
||||
@app.route('/')
|
||||
def dashboard():
|
||||
"""Main dashboard"""
|
||||
return render_template('dashboard.html')
|
||||
|
||||
@require_login()
|
||||
def index():
|
||||
"""Redirect to campaigns page - requires login"""
|
||||
return redirect(url_for('campaigns'))
|
||||
|
||||
@app.route('/campaigns')
|
||||
@require_login()
|
||||
def campaigns():
|
||||
"""Campaigns page - requires login"""
|
||||
return render_template('campaigns.html', phone_ip=config.PHONE_IP, current_page='campaigns')
|
||||
|
||||
@app.route('/templates')
|
||||
@require_login()
|
||||
def templates_page():
|
||||
"""Templates page - requires login"""
|
||||
return render_template('templates.html', phone_ip=config.PHONE_IP, current_page='templates')
|
||||
|
||||
@app.route('/lists')
|
||||
@require_login()
|
||||
def lists_page():
|
||||
"""Contact lists page - requires login"""
|
||||
return render_template('lists.html', phone_ip=config.PHONE_IP, current_page='lists')
|
||||
|
||||
@app.route('/testing')
|
||||
@require_login()
|
||||
def testing():
|
||||
"""System testing page - requires login"""
|
||||
return render_template('testing.html', phone_ip=config.PHONE_IP, current_page='testing')
|
||||
|
||||
@app.route('/conversations')
|
||||
@require_login()
|
||||
def conversations():
|
||||
"""Conversations page - requires login"""
|
||||
return render_template('conversations.html', phone_ip=config.PHONE_IP, current_page='conversations')
|
||||
|
||||
@app.route('/import-contacts')
|
||||
@require_login()
|
||||
def import_contacts():
|
||||
"""Import contacts from phone - requires login"""
|
||||
return render_template('import_contacts.html', phone_ip=config.PHONE_IP, current_page='import')
|
||||
|
||||
# Start background services
|
||||
phone_monitor.start()
|
||||
|
||||
|
||||
230
src/core/auth.py
Normal file
230
src/core/auth.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""
|
||||
Authentication and Authorization Module
|
||||
Provides API key authentication and role-based access control
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from functools import wraps
|
||||
from flask import request, jsonify
|
||||
from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication fails"""
|
||||
pass
|
||||
|
||||
class AuthorizationError(Exception):
|
||||
"""Raised when user lacks required permissions"""
|
||||
pass
|
||||
|
||||
class AuthManager:
|
||||
"""Manages API key authentication and authorization"""
|
||||
|
||||
def __init__(self):
|
||||
# Load API keys from environment
|
||||
self.admin_api_key = os.environ.get('ADMIN_API_KEY', '')
|
||||
self.user_api_key = os.environ.get('USER_API_KEY', '')
|
||||
self.termux_api_key = os.environ.get('TERMUX_API_KEY', '')
|
||||
|
||||
# Validate that keys are set
|
||||
if not self.admin_api_key or not self.user_api_key:
|
||||
logger.critical("⚠️ SECURITY WARNING: API keys not set in environment!")
|
||||
logger.critical("Set ADMIN_API_KEY and USER_API_KEY environment variables")
|
||||
raise ValueError("API keys must be configured")
|
||||
|
||||
# Hash the keys for comparison (prevent timing attacks)
|
||||
self.admin_key_hash = self._hash_key(self.admin_api_key)
|
||||
self.user_key_hash = self._hash_key(self.user_api_key)
|
||||
self.termux_key_hash = self._hash_key(self.termux_api_key) if self.termux_api_key else None
|
||||
|
||||
logger.info("✅ Authentication manager initialized with API keys")
|
||||
|
||||
def _hash_key(self, key: str) -> bytes:
|
||||
"""Hash API key using SHA-256"""
|
||||
return hashlib.sha256(key.encode()).digest()
|
||||
|
||||
def _constant_time_compare(self, a: bytes, b: bytes) -> bool:
|
||||
"""Constant-time comparison to prevent timing attacks"""
|
||||
return hmac.compare_digest(a, b)
|
||||
|
||||
def verify_api_key(self, provided_key: Optional[str]) -> tuple[bool, str]:
|
||||
"""
|
||||
Verify API key and return (is_valid, role)
|
||||
|
||||
Args:
|
||||
provided_key: API key from request
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid: bool, role: str)
|
||||
Roles: 'admin', 'user', 'termux', 'none'
|
||||
"""
|
||||
if not provided_key:
|
||||
return False, 'none'
|
||||
|
||||
provided_hash = self._hash_key(provided_key)
|
||||
|
||||
# Check admin key
|
||||
if self._constant_time_compare(provided_hash, self.admin_key_hash):
|
||||
return True, 'admin'
|
||||
|
||||
# Check user key
|
||||
if self._constant_time_compare(provided_hash, self.user_key_hash):
|
||||
return True, 'user'
|
||||
|
||||
# Check termux key
|
||||
if self.termux_key_hash and self._constant_time_compare(provided_hash, self.termux_key_hash):
|
||||
return True, 'termux'
|
||||
|
||||
# Invalid key
|
||||
logger.warning(f"⚠️ Invalid API key attempt from {request.remote_addr}")
|
||||
return False, 'none'
|
||||
|
||||
def get_api_key_from_request(self) -> Optional[str]:
|
||||
"""
|
||||
Extract API key from request headers or query params
|
||||
|
||||
Supports:
|
||||
- Header: X-API-Key
|
||||
- Header: Authorization: Bearer <key>
|
||||
- Query param: api_key
|
||||
"""
|
||||
# Check X-API-Key header
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# Check Authorization header (Bearer token)
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
return auth_header[7:] # Remove 'Bearer ' prefix
|
||||
|
||||
# Check query parameter (less secure, for testing only)
|
||||
api_key = request.args.get('api_key')
|
||||
if api_key:
|
||||
logger.warning("⚠️ API key passed in query string - use headers instead")
|
||||
return api_key
|
||||
|
||||
return None
|
||||
|
||||
def require_auth(self, min_role: str = 'user') -> Callable:
|
||||
"""
|
||||
Decorator to require authentication on routes
|
||||
|
||||
Args:
|
||||
min_role: Minimum role required ('admin', 'user', 'termux')
|
||||
|
||||
Usage:
|
||||
@app.route('/protected')
|
||||
@auth_manager.require_auth('admin')
|
||||
def protected_route():
|
||||
return {'data': 'secret'}
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract API key
|
||||
api_key = self.get_api_key_from_request()
|
||||
|
||||
if not api_key:
|
||||
logger.warning(f"⚠️ No API key provided for {request.path} from {request.remote_addr}")
|
||||
return jsonify({
|
||||
'error': 'Authentication required',
|
||||
'message': 'Please provide an API key via X-API-Key header or Authorization: Bearer header'
|
||||
}), 401
|
||||
|
||||
# Verify API key
|
||||
is_valid, role = self.verify_api_key(api_key)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"⚠️ Invalid API key for {request.path} from {request.remote_addr}")
|
||||
return jsonify({
|
||||
'error': 'Invalid API key',
|
||||
'message': 'The provided API key is not valid'
|
||||
}), 403
|
||||
|
||||
# Check role permissions
|
||||
role_hierarchy = {
|
||||
'admin': 3,
|
||||
'user': 2,
|
||||
'termux': 1,
|
||||
'none': 0
|
||||
}
|
||||
|
||||
required_level = role_hierarchy.get(min_role, 0)
|
||||
user_level = role_hierarchy.get(role, 0)
|
||||
|
||||
if user_level < required_level:
|
||||
logger.warning(f"⚠️ Insufficient permissions for {request.path}: role={role}, required={min_role}")
|
||||
return jsonify({
|
||||
'error': 'Insufficient permissions',
|
||||
'message': f'This endpoint requires {min_role} role or higher'
|
||||
}), 403
|
||||
|
||||
# Add role to request context
|
||||
request.user_role = role
|
||||
|
||||
logger.info(f"✅ Authenticated request to {request.path} with role={role}")
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def generate_secure_api_key(length: int = 64) -> str:
|
||||
"""
|
||||
Generate a cryptographically secure API key
|
||||
|
||||
Args:
|
||||
length: Length of the key in characters
|
||||
|
||||
Returns:
|
||||
Hex-encoded random key
|
||||
"""
|
||||
return secrets.token_hex(length // 2)
|
||||
|
||||
def generate_keys_for_env():
|
||||
"""
|
||||
Generate new API keys and print them for .env file
|
||||
For initial setup or key rotation
|
||||
"""
|
||||
admin_key = generate_secure_api_key(64)
|
||||
user_key = generate_secure_api_key(64)
|
||||
termux_key = generate_secure_api_key(64)
|
||||
secret_key = generate_secure_api_key(64)
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("🔑 SECURE API KEYS GENERATED")
|
||||
print("="*80)
|
||||
print("\nAdd these to your .env file (DO NOT commit .env to git!):\n")
|
||||
print(f"ADMIN_API_KEY={admin_key}")
|
||||
print(f"USER_API_KEY={user_key}")
|
||||
print(f"TERMUX_API_KEY={termux_key}")
|
||||
print(f"SECRET_KEY={secret_key}")
|
||||
print(f"TERMUX_API_SECRET={termux_key}")
|
||||
print("\n" + "="*80)
|
||||
print("⚠️ IMPORTANT SECURITY NOTES:")
|
||||
print("="*80)
|
||||
print("1. Store these keys securely - treat them like passwords")
|
||||
print("2. NEVER commit .env file to git")
|
||||
print("3. Rotate keys regularly (every 90 days)")
|
||||
print("4. Use ADMIN_API_KEY only for admin operations")
|
||||
print("5. Use USER_API_KEY for regular API access")
|
||||
print("6. Use TERMUX_API_KEY for Android <-> Server communication")
|
||||
print("="*80 + "\n")
|
||||
|
||||
return {
|
||||
'ADMIN_API_KEY': admin_key,
|
||||
'USER_API_KEY': user_key,
|
||||
'TERMUX_API_KEY': termux_key,
|
||||
'SECRET_KEY': secret_key,
|
||||
'TERMUX_API_SECRET': termux_key
|
||||
}
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Generate keys when run directly
|
||||
generate_keys_for_env()
|
||||
@ -33,10 +33,18 @@ class AppConfig:
|
||||
SMS_MAX_RETRY_DELAY: int = int(os.environ.get('SMS_MAX_RETRY_DELAY', '8'))
|
||||
|
||||
# Flask Settings
|
||||
SECRET_KEY: str = os.environ.get('SECRET_KEY', 'dev-key-change-in-production')
|
||||
SECRET_KEY: str = os.environ.get('SECRET_KEY', '')
|
||||
UPLOAD_FOLDER: str = './uploads'
|
||||
MAX_CONTENT_LENGTH: int = 16 * 1024 * 1024
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate required configuration"""
|
||||
if not self.SECRET_KEY:
|
||||
raise ValueError(
|
||||
"SECRET_KEY environment variable is required. "
|
||||
"Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
|
||||
)
|
||||
|
||||
@property
|
||||
def termux_api_url(self) -> str:
|
||||
return f"http://{self.PHONE_IP}:{self.TERMUX_API_PORT}"
|
||||
|
||||
121
src/core/rate_limiter.py
Normal file
121
src/core/rate_limiter.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""
|
||||
Rate Limiter Configuration
|
||||
Provides rate limiting to prevent abuse, brute force attacks, and DoS
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_identifier():
|
||||
"""
|
||||
Get identifier for rate limiting
|
||||
Uses IP address as the primary identifier
|
||||
"""
|
||||
return get_remote_address()
|
||||
|
||||
|
||||
def get_rate_limit_config():
|
||||
"""
|
||||
Get rate limit configuration from environment variables
|
||||
Returns a dictionary with all configured rate limits
|
||||
"""
|
||||
return {
|
||||
'default': os.environ.get('RATE_LIMIT_DEFAULT', '200 per hour, 1000 per day'),
|
||||
'login': os.environ.get('RATE_LIMIT_LOGIN', '5 per minute'),
|
||||
'sms': os.environ.get('RATE_LIMIT_SMS', '10 per minute, 100 per hour, 500 per day'),
|
||||
'upload': os.environ.get('RATE_LIMIT_UPLOAD', '10 per hour, 50 per day'),
|
||||
'database_reset': os.environ.get('RATE_LIMIT_DATABASE_RESET', '2 per hour'),
|
||||
}
|
||||
|
||||
|
||||
def init_rate_limiter(app):
|
||||
"""
|
||||
Initialize rate limiter with Flask app
|
||||
|
||||
Uses in-memory storage for simplicity. For production with multiple
|
||||
workers, consider using Redis:
|
||||
storage_uri="redis://localhost:6379"
|
||||
|
||||
Default limits are read from environment variables (RATE_LIMIT_DEFAULT)
|
||||
"""
|
||||
# Get configured rate limits from environment
|
||||
rate_config = get_rate_limit_config()
|
||||
|
||||
# Parse default limit for display
|
||||
default_limit = rate_config['default']
|
||||
|
||||
limiter = Limiter(
|
||||
get_identifier,
|
||||
app=app,
|
||||
default_limits=[default_limit],
|
||||
storage_uri="memory://",
|
||||
strategy="fixed-window",
|
||||
headers_enabled=True, # Add X-RateLimit-* headers to responses
|
||||
)
|
||||
|
||||
logger.info("✅ Rate limiter initialized (in-memory storage)")
|
||||
logger.info(f" Default limits: {default_limit}")
|
||||
logger.info(f" Login limit: {rate_config['login']}")
|
||||
logger.info(f" SMS limit: {rate_config['sms']}")
|
||||
logger.info(f" Upload limit: {rate_config['upload']}")
|
||||
logger.info(f" DB reset limit: {rate_config['database_reset']}")
|
||||
|
||||
return limiter
|
||||
|
||||
|
||||
# Custom rate limit decorators for common use cases
|
||||
|
||||
def strict_limit(limiter):
|
||||
"""
|
||||
Strict rate limit for sensitive operations
|
||||
5 requests per minute, 20 per hour
|
||||
|
||||
Use for: Login, password changes, admin operations
|
||||
"""
|
||||
return limiter.limit("5 per minute", error_message="Too many attempts. Please wait before trying again.")
|
||||
|
||||
|
||||
def sms_limit(limiter):
|
||||
"""
|
||||
Rate limit for SMS sending operations
|
||||
10 per minute, 100 per hour, 500 per day
|
||||
|
||||
Use for: SMS sending endpoints
|
||||
"""
|
||||
return limiter.limit("10 per minute, 100 per hour, 500 per day",
|
||||
error_message="SMS rate limit exceeded. Please wait before sending more messages.")
|
||||
|
||||
|
||||
def upload_limit(limiter):
|
||||
"""
|
||||
Rate limit for file upload operations
|
||||
10 per hour, 50 per day
|
||||
|
||||
Use for: File upload endpoints
|
||||
"""
|
||||
return limiter.limit("10 per hour, 50 per day",
|
||||
error_message="Upload limit exceeded. Please wait before uploading more files.")
|
||||
|
||||
|
||||
def api_limit(limiter):
|
||||
"""
|
||||
Standard API rate limit
|
||||
60 per minute, 1000 per hour
|
||||
|
||||
Use for: Regular API endpoints
|
||||
"""
|
||||
return limiter.limit("60 per minute, 1000 per hour",
|
||||
error_message="API rate limit exceeded. Please slow down your requests.")
|
||||
|
||||
|
||||
def get_rate_limit_key():
|
||||
"""
|
||||
Get the current rate limit key (IP address)
|
||||
Useful for logging and debugging
|
||||
"""
|
||||
return get_remote_address()
|
||||
507
src/core/user_auth.py
Normal file
507
src/core/user_auth.py
Normal file
@ -0,0 +1,507 @@
|
||||
"""
|
||||
User Authentication System
|
||||
Supports both session-based (web) and API key authentication
|
||||
Includes user management with password hashing
|
||||
Uses Flask-Login for reliable session management
|
||||
"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import sqlite3
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
from flask import session, request, jsonify, redirect, url_for
|
||||
from flask_login import UserMixin, current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class User(UserMixin):
|
||||
"""User data model - Flask-Login compatible"""
|
||||
id: int
|
||||
username: str
|
||||
password_hash: str
|
||||
role: str # 'admin' or 'user'
|
||||
created_at: str
|
||||
last_login: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
def get_id(self) -> str:
|
||||
"""Flask-Login requires this to return a string"""
|
||||
return str(self.id)
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Flask-Login: User is authenticated if active"""
|
||||
return self.is_active
|
||||
|
||||
@property
|
||||
def is_anonymous(self) -> bool:
|
||||
"""Flask-Login: Registered users are not anonymous"""
|
||||
return False
|
||||
|
||||
class UserManager:
|
||||
"""Manages user authentication and sessions"""
|
||||
|
||||
def __init__(self, database_path: str):
|
||||
self.database_path = database_path
|
||||
self.session_timeout = timedelta(hours=24)
|
||||
self._init_users_table()
|
||||
self._load_env_users()
|
||||
|
||||
def _init_users_table(self):
|
||||
"""Create users table if it doesn't exist"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
email TEXT,
|
||||
full_name TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Create sessions table for tracking
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
session_token TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("✅ User management tables initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize users table: {e}")
|
||||
raise
|
||||
|
||||
def _load_env_users(self):
|
||||
"""Load users from environment variables for initial setup"""
|
||||
# Check for default admin from env
|
||||
admin_user = os.environ.get('ADMIN_USERNAME', '')
|
||||
admin_pass = os.environ.get('ADMIN_PASSWORD', '')
|
||||
|
||||
if admin_user and admin_pass:
|
||||
# Create admin user if doesn't exist
|
||||
if not self.get_user_by_username(admin_user):
|
||||
self.create_user(admin_user, admin_pass, 'admin')
|
||||
logger.info(f"✅ Created admin user from environment: {admin_user}")
|
||||
|
||||
def hash_password(self, password: str, salt: Optional[bytes] = None) -> tuple[str, bytes]:
|
||||
"""
|
||||
Hash password with PBKDF2-HMAC-SHA256
|
||||
|
||||
Returns:
|
||||
Tuple of (hash_hex, salt)
|
||||
"""
|
||||
if salt is None:
|
||||
salt = secrets.token_bytes(32)
|
||||
|
||||
# Use PBKDF2 with 100,000 iterations
|
||||
pw_hash = hashlib.pbkdf2_hmac(
|
||||
'sha256',
|
||||
password.encode('utf-8'),
|
||||
salt,
|
||||
100000
|
||||
)
|
||||
|
||||
# Combine salt and hash
|
||||
combined = salt + pw_hash
|
||||
return combined.hex(), salt
|
||||
|
||||
def verify_password(self, password: str, stored_hash: str) -> bool:
|
||||
"""Verify password against stored hash"""
|
||||
try:
|
||||
# Decode the stored hash
|
||||
combined = bytes.fromhex(stored_hash)
|
||||
salt = combined[:32]
|
||||
stored_pw_hash = combined[32:]
|
||||
|
||||
# Hash the provided password with the same salt
|
||||
pw_hash = hashlib.pbkdf2_hmac(
|
||||
'sha256',
|
||||
password.encode('utf-8'),
|
||||
salt,
|
||||
100000
|
||||
)
|
||||
|
||||
# Constant-time comparison
|
||||
return hmac.compare_digest(pw_hash, stored_pw_hash)
|
||||
except Exception as e:
|
||||
logger.error(f"Password verification error: {e}")
|
||||
return False
|
||||
|
||||
def create_user(self, username: str, password: str, role: str = 'user',
|
||||
email: Optional[str] = None, full_name: Optional[str] = None) -> bool:
|
||||
"""Create a new user"""
|
||||
try:
|
||||
# Validate inputs
|
||||
if not username or not password:
|
||||
raise ValueError("Username and password are required")
|
||||
|
||||
if role not in ['admin', 'user']:
|
||||
raise ValueError("Role must be 'admin' or 'user'")
|
||||
|
||||
if len(password) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
|
||||
# Hash password
|
||||
password_hash, _ = self.hash_password(password)
|
||||
|
||||
# Insert user
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO users (username, password_hash, role, email, full_name)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (username, password_hash, role, email, full_name))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info(f"✅ Created user: {username} (role: {role})")
|
||||
return True
|
||||
|
||||
except sqlite3.IntegrityError:
|
||||
logger.error(f"❌ User already exists: {username}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to create user: {e}")
|
||||
return False
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
"""Get user by username"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, username, password_hash, role, created_at, last_login, is_active
|
||||
FROM users
|
||||
WHERE username = ? AND is_active = 1
|
||||
''', (username,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return User(
|
||||
id=row['id'],
|
||||
username=row['username'],
|
||||
password_hash=row['password_hash'],
|
||||
role=row['role'],
|
||||
created_at=row['created_at'],
|
||||
last_login=row['last_login'],
|
||||
is_active=bool(row['is_active'])
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching user: {e}")
|
||||
return None
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""Get user by ID - required for Flask-Login user_loader"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, username, password_hash, role, created_at, last_login, is_active
|
||||
FROM users
|
||||
WHERE id = ? AND is_active = 1
|
||||
''', (user_id,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return User(
|
||||
id=row['id'],
|
||||
username=row['username'],
|
||||
password_hash=row['password_hash'],
|
||||
role=row['role'],
|
||||
created_at=row['created_at'],
|
||||
last_login=row['last_login'],
|
||||
is_active=bool(row['is_active'])
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching user by ID: {e}")
|
||||
return None
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
||||
"""Authenticate user with username and password"""
|
||||
user = self.get_user_by_username(username)
|
||||
|
||||
if not user:
|
||||
logger.warning(f"⚠️ Login attempt for non-existent user: {username}")
|
||||
return None
|
||||
|
||||
if not self.verify_password(password, user.password_hash):
|
||||
logger.warning(f"⚠️ Failed login attempt for user: {username}")
|
||||
return None
|
||||
|
||||
# Update last login
|
||||
self._update_last_login(user.id)
|
||||
|
||||
logger.info(f"✅ Successful login: {username}")
|
||||
return user
|
||||
|
||||
def _update_last_login(self, user_id: int):
|
||||
"""Update user's last login timestamp"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE users
|
||||
SET last_login = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (user_id,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating last login: {e}")
|
||||
|
||||
def create_session(self, user: User, ip_address: str, user_agent: str) -> str:
|
||||
"""Create a new session for user"""
|
||||
try:
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
expires_at = datetime.now() + self.session_timeout
|
||||
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (user.id, session_token, ip_address, user_agent, expires_at))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info(f"✅ Created session for user: {user.username}")
|
||||
return session_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating session: {e}")
|
||||
return ""
|
||||
|
||||
def validate_session(self, session_token: str) -> Optional[User]:
|
||||
"""Validate session token and return user"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.last_login, u.is_active
|
||||
FROM users u
|
||||
JOIN user_sessions s ON u.id = s.user_id
|
||||
WHERE s.session_token = ?
|
||||
AND s.is_active = 1
|
||||
AND s.expires_at > CURRENT_TIMESTAMP
|
||||
AND u.is_active = 1
|
||||
''', (session_token,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return User(
|
||||
id=row['id'],
|
||||
username=row['username'],
|
||||
password_hash=row['password_hash'],
|
||||
role=row['role'],
|
||||
created_at=row['created_at'],
|
||||
last_login=row['last_login'],
|
||||
is_active=bool(row['is_active'])
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating session: {e}")
|
||||
return None
|
||||
|
||||
def invalidate_session(self, session_token: str):
|
||||
"""Invalidate a session (logout)"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE user_sessions
|
||||
SET is_active = 0
|
||||
WHERE session_token = ?
|
||||
''', (session_token,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info("✅ Session invalidated")
|
||||
except Exception as e:
|
||||
logger.error(f"Error invalidating session: {e}")
|
||||
|
||||
def change_password(self, username: str, old_password: str, new_password: str) -> bool:
|
||||
"""Change user password"""
|
||||
# Authenticate with old password
|
||||
user = self.authenticate_user(username, old_password)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# Validate new password
|
||||
if len(new_password) < 8:
|
||||
logger.error("New password too short")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Hash new password
|
||||
new_hash, _ = self.hash_password(new_password)
|
||||
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE users
|
||||
SET password_hash = ?
|
||||
WHERE id = ?
|
||||
''', (new_hash, user.id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info(f"✅ Password changed for user: {username}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error changing password: {e}")
|
||||
return False
|
||||
|
||||
def delete_user(self, username: str) -> bool:
|
||||
"""Soft delete user (set inactive)"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE users
|
||||
SET is_active = 0
|
||||
WHERE username = ?
|
||||
''', (username,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info(f"✅ Deactivated user: {username}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting user: {e}")
|
||||
return False
|
||||
|
||||
def list_users(self) -> list[Dict[str, Any]]:
|
||||
"""List all active users"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.database_path, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, username, role, created_at, last_login, email, full_name
|
||||
FROM users
|
||||
WHERE is_active = 1
|
||||
ORDER BY created_at DESC
|
||||
''')
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing users: {e}")
|
||||
return []
|
||||
|
||||
def require_login(role: Optional[str] = None):
|
||||
"""
|
||||
Decorator to require user login for routes.
|
||||
Uses Flask-Login's current_user for session management.
|
||||
|
||||
Args:
|
||||
role: Optional role requirement ('admin' or 'user')
|
||||
Admins can access user-level routes.
|
||||
|
||||
Usage:
|
||||
@app.route('/dashboard')
|
||||
@require_login()
|
||||
def dashboard():
|
||||
return render_template('dashboard.html')
|
||||
|
||||
@app.route('/admin')
|
||||
@require_login('admin')
|
||||
def admin_panel():
|
||||
return render_template('admin.html')
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if user is authenticated via Flask-Login
|
||||
if not current_user.is_authenticated:
|
||||
logger.warning(f"⚠️ Unauthorized access attempt to {request.path}")
|
||||
# Return JSON for API routes, redirect for web routes
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'Authentication required',
|
||||
'message': 'Please log in to access this resource'
|
||||
}), 401
|
||||
else:
|
||||
return redirect(url_for('auth.login_page', next=request.url))
|
||||
|
||||
# Check role if specified
|
||||
if role:
|
||||
user_role = current_user.role
|
||||
# Admin can access everything; otherwise check exact match
|
||||
if user_role != 'admin' and user_role != role:
|
||||
logger.warning(
|
||||
f"⚠️ Insufficient permissions for {current_user.username} "
|
||||
f"accessing {request.path}"
|
||||
)
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'Insufficient permissions',
|
||||
'message': f'This resource requires {role} role'
|
||||
}), 403
|
||||
else:
|
||||
return "Access Denied: Insufficient permissions", 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
@ -1,6 +1,14 @@
|
||||
import sqlite3
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
import requests
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from datetime import datetime
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from utils.phone_utils import normalize_phone_number, phones_match
|
||||
|
||||
|
||||
class ContactList:
|
||||
@ -170,3 +178,244 @@ class ContactList:
|
||||
success = cur.rowcount > 0
|
||||
conn.close()
|
||||
return success
|
||||
|
||||
def fetch_phone_contacts(self, termux_api_url: str) -> Tuple[bool, List[Dict], str]:
|
||||
"""
|
||||
Fetch contacts from Android phone via Termux API.
|
||||
|
||||
Args:
|
||||
termux_api_url: Base URL for Termux API server (e.g., http://10.0.0.193:5001)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, contacts_list, error_message)
|
||||
"""
|
||||
try:
|
||||
response = requests.get(f"{termux_api_url}/api/contacts/list", timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get('success'):
|
||||
contacts = data.get('contacts', [])
|
||||
return True, contacts, ""
|
||||
else:
|
||||
error = data.get('error', 'Unknown error fetching contacts')
|
||||
return False, [], error
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return False, [], f"Failed to connect to Termux API: {str(e)}"
|
||||
except json.JSONDecodeError as e:
|
||||
return False, [], f"Failed to parse contact data: {str(e)}"
|
||||
except Exception as e:
|
||||
return False, [], f"Unexpected error: {str(e)}"
|
||||
|
||||
def check_for_duplicates(self, contacts: List[Dict]) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
Check which contacts already exist in the database.
|
||||
|
||||
Args:
|
||||
contacts: List of contact dicts with 'phone' and 'name' fields
|
||||
|
||||
Returns:
|
||||
Dict with 'new', 'existing', 'conflicts' keys containing lists of contacts
|
||||
"""
|
||||
conn = self._conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
new_contacts = []
|
||||
existing_contacts = []
|
||||
conflicts = []
|
||||
|
||||
# Get all existing phone numbers from database
|
||||
cur.execute("SELECT DISTINCT phone, name FROM contact_list_entries")
|
||||
db_contacts = {normalize_phone_number(row[0]): row[1] for row in cur.fetchall()}
|
||||
|
||||
for contact in contacts:
|
||||
phone = contact.get('number') or contact.get('phone', '')
|
||||
name = contact.get('name', '')
|
||||
|
||||
if not phone:
|
||||
continue
|
||||
|
||||
normalized_phone = normalize_phone_number(phone)
|
||||
|
||||
if normalized_phone in db_contacts:
|
||||
# Contact exists
|
||||
existing_name = db_contacts[normalized_phone]
|
||||
|
||||
# Check if name is different (conflict)
|
||||
if name != existing_name:
|
||||
conflicts.append({
|
||||
'phone': phone,
|
||||
'name': name,
|
||||
'existing_name': existing_name,
|
||||
'normalized_phone': normalized_phone
|
||||
})
|
||||
else:
|
||||
existing_contacts.append({
|
||||
'phone': phone,
|
||||
'name': name,
|
||||
'normalized_phone': normalized_phone
|
||||
})
|
||||
else:
|
||||
# New contact
|
||||
new_contacts.append({
|
||||
'phone': phone,
|
||||
'name': name,
|
||||
'normalized_phone': normalized_phone
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'new': new_contacts,
|
||||
'existing': existing_contacts,
|
||||
'conflicts': conflicts
|
||||
}
|
||||
|
||||
def import_phone_contacts(
|
||||
self,
|
||||
list_id: int,
|
||||
contacts: List[Dict],
|
||||
update_conflicts: bool = False
|
||||
) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Import contacts from phone into an existing contact list.
|
||||
|
||||
Args:
|
||||
list_id: ID of the contact list to import into
|
||||
contacts: List of contact dicts with 'phone'/'number' and 'name' fields
|
||||
update_conflicts: If True, update existing contacts with new names
|
||||
|
||||
Returns:
|
||||
Tuple of (added_count, skipped_count, updated_count)
|
||||
"""
|
||||
conn = self._conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
updated = 0
|
||||
|
||||
try:
|
||||
# Get existing contacts in this list
|
||||
cur.execute("SELECT phone, name FROM contact_list_entries WHERE list_id = ?", (list_id,))
|
||||
existing_in_list = {normalize_phone_number(row[0]): row[1] for row in cur.fetchall()}
|
||||
|
||||
for contact in contacts:
|
||||
phone = contact.get('number') or contact.get('phone', '')
|
||||
name = contact.get('name', '')
|
||||
|
||||
if not phone:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
normalized_phone = normalize_phone_number(phone)
|
||||
|
||||
if normalized_phone in existing_in_list:
|
||||
# Already in this list
|
||||
if update_conflicts and name and name != existing_in_list[normalized_phone]:
|
||||
# Update the name
|
||||
cur.execute(
|
||||
"UPDATE contact_list_entries SET name = ? WHERE list_id = ? AND phone = ?",
|
||||
(name, list_id, phone)
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
else:
|
||||
# Add new contact to list
|
||||
cur.execute(
|
||||
"INSERT INTO contact_list_entries (list_id, phone, name, created_at) VALUES (?, ?, ?, ?)",
|
||||
(list_id, phone, name, datetime.now().isoformat())
|
||||
)
|
||||
added += 1
|
||||
|
||||
# Update total_contacts count
|
||||
cur.execute(
|
||||
"UPDATE contact_lists SET total_contacts = (SELECT COUNT(*) FROM contact_list_entries WHERE list_id = ?), updated_at = ? WHERE id = ?",
|
||||
(list_id, datetime.now().isoformat(), list_id)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return added, skipped, updated
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def create_list_from_phone_contacts(
|
||||
self,
|
||||
list_name: str,
|
||||
contacts: List[Dict],
|
||||
skip_duplicates: bool = True
|
||||
) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Create a new contact list from phone contacts.
|
||||
|
||||
Args:
|
||||
list_name: Name for the new contact list
|
||||
contacts: List of contact dicts from phone
|
||||
skip_duplicates: If True, skip contacts that exist in other lists
|
||||
|
||||
Returns:
|
||||
Tuple of (list_id, added_count, skipped_count)
|
||||
"""
|
||||
conn = self._conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Create the list
|
||||
cur.execute(
|
||||
"INSERT INTO contact_lists (name, original_filename, created_at, total_contacts) VALUES (?, ?, ?, ?)",
|
||||
(list_name, "Phone Contacts Import", datetime.now().isoformat(), 0)
|
||||
)
|
||||
list_id = cur.lastrowid
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
|
||||
# Get all existing numbers if skip_duplicates is True
|
||||
existing_numbers = set()
|
||||
if skip_duplicates:
|
||||
cur.execute("SELECT DISTINCT phone FROM contact_list_entries")
|
||||
existing_numbers = {normalize_phone_number(row[0]) for row in cur.fetchall()}
|
||||
|
||||
# Add contacts
|
||||
for contact in contacts:
|
||||
phone = contact.get('number') or contact.get('phone', '')
|
||||
name = contact.get('name', '')
|
||||
|
||||
if not phone:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
normalized_phone = normalize_phone_number(phone)
|
||||
|
||||
if skip_duplicates and normalized_phone in existing_numbers:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Add contact
|
||||
cur.execute(
|
||||
"INSERT INTO contact_list_entries (list_id, phone, name, created_at) VALUES (?, ?, ?, ?)",
|
||||
(list_id, phone, name, datetime.now().isoformat())
|
||||
)
|
||||
added += 1
|
||||
|
||||
# Update total count
|
||||
cur.execute(
|
||||
"UPDATE contact_lists SET total_contacts = ? WHERE id = ?",
|
||||
(added, list_id)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return list_id, added, skipped
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
Flask==3.0.0
|
||||
Flask-Login==0.6.3
|
||||
Werkzeug==3.0.1
|
||||
requests==2.31.0
|
||||
typing-extensions==4.8.0
|
||||
flask-socketio==5.3.5
|
||||
python-socketio==5.10.0
|
||||
aiohttp==3.9.1
|
||||
aiohttp==3.9.1
|
||||
Flask-Limiter==3.5.0
|
||||
@ -6,6 +6,7 @@ Handles campaign analytics and reporting
|
||||
import logging
|
||||
import csv
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from core.user_auth import require_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -20,6 +21,7 @@ def init_analytics_routes(db):
|
||||
db_helper = db
|
||||
|
||||
@analytics_routes.route('/analytics')
|
||||
@require_login()
|
||||
def get_analytics():
|
||||
"""Get campaign analytics"""
|
||||
try:
|
||||
@ -69,6 +71,7 @@ def get_analytics():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@analytics_routes.route('/followups')
|
||||
@require_login()
|
||||
def get_followups():
|
||||
"""Get contacts needing follow-up"""
|
||||
try:
|
||||
@ -87,6 +90,7 @@ def get_followups():
|
||||
return jsonify([]), 500
|
||||
|
||||
@analytics_routes.route('/export/<int:campaign_id>')
|
||||
@require_login()
|
||||
def export_campaign(campaign_id):
|
||||
"""Export campaign data as CSV"""
|
||||
try:
|
||||
|
||||
34
src/routes/api/auth_decorator.py
Normal file
34
src/routes/api/auth_decorator.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""
|
||||
Authentication decorator helper for API routes
|
||||
Provides a consistent way to apply auth across blueprints
|
||||
"""
|
||||
|
||||
from flask import current_app
|
||||
from functools import wraps
|
||||
|
||||
def require_auth(min_role='user'):
|
||||
"""
|
||||
Get authentication decorator from the app's auth manager
|
||||
|
||||
Args:
|
||||
min_role: Minimum role required ('admin', 'user', 'termux')
|
||||
|
||||
Returns:
|
||||
Authentication decorator function
|
||||
|
||||
Usage in routes:
|
||||
from routes.api.auth_decorator import require_auth
|
||||
|
||||
@blueprint.route('/endpoint')
|
||||
@require_auth('admin')
|
||||
def my_endpoint():
|
||||
return {'data': 'secret'}
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Access current_app only during request, not at import time
|
||||
auth_decorator = current_app.auth_manager.require_auth(min_role)
|
||||
return auth_decorator(f)(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
@ -8,6 +8,8 @@ from datetime import datetime
|
||||
from threading import Thread
|
||||
from flask import Blueprint, request, jsonify
|
||||
from database import DatabaseHelper
|
||||
from routes.api.auth_decorator import require_auth
|
||||
from core.user_auth import require_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -26,6 +28,7 @@ def init_campaign_routes(cm, ce, db):
|
||||
db_helper = db
|
||||
|
||||
@campaign_routes.route('/create', methods=['POST'])
|
||||
@require_login()
|
||||
def create_campaign():
|
||||
"""Create a new campaign with contact preview"""
|
||||
try:
|
||||
@ -72,6 +75,7 @@ def create_campaign():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@campaign_routes.route('/start', methods=['POST'])
|
||||
@require_login()
|
||||
def start_campaign():
|
||||
"""Start SMS campaign"""
|
||||
try:
|
||||
@ -98,6 +102,7 @@ def start_campaign():
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@campaign_routes.route('/pause', methods=['POST'])
|
||||
@require_login()
|
||||
def pause_campaign():
|
||||
"""Pause running campaign"""
|
||||
try:
|
||||
@ -108,6 +113,7 @@ def pause_campaign():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@campaign_routes.route('/resume', methods=['POST'])
|
||||
@require_login()
|
||||
def resume_campaign():
|
||||
"""Resume paused campaign"""
|
||||
try:
|
||||
@ -118,6 +124,7 @@ def resume_campaign():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@campaign_routes.route('/status')
|
||||
@require_login()
|
||||
def campaign_status():
|
||||
"""Get current campaign status"""
|
||||
try:
|
||||
@ -128,6 +135,7 @@ def campaign_status():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@campaign_routes.route('/list')
|
||||
@require_login()
|
||||
def list_campaigns():
|
||||
"""List all campaigns"""
|
||||
try:
|
||||
@ -145,6 +153,7 @@ def list_campaigns():
|
||||
return jsonify([]), 500
|
||||
|
||||
@campaign_routes.route('/recent') # /api/campaigns/recent
|
||||
@require_login()
|
||||
def get_recent_campaigns():
|
||||
"""Get recent campaigns"""
|
||||
try:
|
||||
|
||||
@ -7,6 +7,7 @@ import logging
|
||||
import requests
|
||||
import subprocess
|
||||
from flask import Blueprint, request, jsonify
|
||||
from core.user_auth import require_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -25,6 +26,7 @@ def init_connection_routes(manager, sync_service, app_config):
|
||||
config = app_config
|
||||
|
||||
@connection_routes.route('/connections/status')
|
||||
@require_login()
|
||||
def connection_status():
|
||||
"""Get current SMS connection status"""
|
||||
try:
|
||||
@ -34,6 +36,7 @@ def connection_status():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@connection_routes.route('/device/status')
|
||||
@require_login()
|
||||
def device_status():
|
||||
"""Get device status from available connection"""
|
||||
try:
|
||||
@ -43,6 +46,7 @@ def device_status():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@connection_routes.route('/phone/status')
|
||||
@require_login()
|
||||
def phone_status():
|
||||
"""Check phone connection status"""
|
||||
try:
|
||||
@ -77,6 +81,7 @@ def phone_status():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@connection_routes.route('/phone/connect', methods=['POST'])
|
||||
@require_login()
|
||||
def connect_phone():
|
||||
"""Manually trigger phone connection"""
|
||||
try:
|
||||
@ -95,6 +100,7 @@ def connect_phone():
|
||||
return jsonify({"connected": False, "error": str(e)}), 500
|
||||
|
||||
@connection_routes.route('/termux/status')
|
||||
@require_login()
|
||||
def termux_status():
|
||||
"""Check Termux API status"""
|
||||
try:
|
||||
|
||||
@ -5,7 +5,8 @@ Administrative endpoints for database operations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from flask import Blueprint, jsonify
|
||||
from flask import Blueprint, jsonify, current_app
|
||||
from core.user_auth import require_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -22,9 +23,11 @@ def init_database_routes(dbm, cfg):
|
||||
config = cfg
|
||||
|
||||
@database_routes.route('/reset', methods=['POST'])
|
||||
@require_login('admin')
|
||||
def reset_database():
|
||||
"""
|
||||
Reset the entire database - WARNING: This deletes ALL data
|
||||
REQUIRES: Admin API key
|
||||
|
||||
This endpoint:
|
||||
1. Closes existing database connections
|
||||
@ -80,9 +83,11 @@ def reset_database():
|
||||
}), 500
|
||||
|
||||
@database_routes.route('/stats', methods=['GET'])
|
||||
@require_login()
|
||||
def database_stats():
|
||||
"""
|
||||
Get database statistics
|
||||
REQUIRES: User or Admin API key
|
||||
|
||||
Returns:
|
||||
JSON response with database size, table counts, etc.
|
||||
|
||||
@ -5,6 +5,8 @@ Handles SMS testing and sending operations
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
from routes.api.auth_decorator import require_auth
|
||||
from core.user_auth import require_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -21,6 +23,7 @@ def init_sms_routes(sender, manager):
|
||||
sms_manager = manager
|
||||
|
||||
@sms_routes.route('/test/real', methods=['POST'])
|
||||
@require_login()
|
||||
def test_sms_real():
|
||||
"""Test SMS by actually sending a real SMS"""
|
||||
try:
|
||||
@ -56,6 +59,7 @@ def test_sms_real():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@sms_routes.route('/test', methods=['POST'])
|
||||
@require_login()
|
||||
def test_sms_connection():
|
||||
"""Test SMS connection without actually sending"""
|
||||
try:
|
||||
@ -77,6 +81,7 @@ def test_sms_connection():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@sms_routes.route('/send/enhanced', methods=['POST'])
|
||||
@require_login()
|
||||
def send_enhanced_sms():
|
||||
"""Send SMS with enhanced dual connection support"""
|
||||
try:
|
||||
@ -102,6 +107,7 @@ def send_enhanced_sms():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@sms_routes.route('/status')
|
||||
@require_login()
|
||||
def get_sms_status():
|
||||
"""Get SMS connection status"""
|
||||
try:
|
||||
|
||||
@ -5,6 +5,7 @@ Handles message template CRUD operations
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
from core.user_auth import require_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -19,6 +20,7 @@ def init_template_routes(db):
|
||||
db_helper = db
|
||||
|
||||
@template_routes.route('')
|
||||
@require_login()
|
||||
def get_templates():
|
||||
"""Get message templates"""
|
||||
try:
|
||||
@ -34,6 +36,7 @@ def get_templates():
|
||||
return jsonify([]), 500
|
||||
|
||||
@template_routes.route('', methods=['POST'])
|
||||
@require_login()
|
||||
def save_template():
|
||||
"""Save message template"""
|
||||
try:
|
||||
@ -50,6 +53,7 @@ def save_template():
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@template_routes.route('/<int:template_id>', methods=['GET'])
|
||||
@require_login()
|
||||
def get_template_by_id(template_id):
|
||||
"""Get specific template by ID"""
|
||||
try:
|
||||
@ -70,6 +74,7 @@ def get_template_by_id(template_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@template_routes.route('/<int:template_id>', methods=['PUT'])
|
||||
@require_login()
|
||||
def update_template_by_id(template_id):
|
||||
"""Update existing template"""
|
||||
try:
|
||||
@ -118,6 +123,7 @@ def update_template_by_id(template_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@template_routes.route('/<int:template_id>', methods=['DELETE'])
|
||||
@require_login()
|
||||
def delete_template_by_id(template_id):
|
||||
"""Delete template"""
|
||||
try:
|
||||
@ -140,6 +146,7 @@ def delete_template_by_id(template_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@template_routes.route('/<int:template_id>/use', methods=['POST'])
|
||||
@require_login()
|
||||
def use_template_by_id(template_id):
|
||||
"""Mark template as used (increment usage counter)"""
|
||||
try:
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
"""
|
||||
Test API Routes - Flask Blueprint
|
||||
System testing endpoints for debugging connections and SMS functionality
|
||||
Admin-only access required - these endpoints can send SMS
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
from flask import Blueprint, request, jsonify
|
||||
from core.user_auth import require_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -23,6 +25,7 @@ def init_test_routes(sm, cfg):
|
||||
config = cfg
|
||||
|
||||
@test_routes.route('/termux', methods=['POST'])
|
||||
@require_login('admin')
|
||||
def test_termux_endpoint():
|
||||
"""Test Termux API endpoint"""
|
||||
try:
|
||||
@ -50,6 +53,7 @@ def test_termux_endpoint():
|
||||
})
|
||||
|
||||
@test_routes.route('/adb', methods=['POST'])
|
||||
@require_login('admin')
|
||||
def test_adb_endpoint():
|
||||
"""Test ADB connection"""
|
||||
try:
|
||||
@ -81,6 +85,7 @@ def test_adb_endpoint():
|
||||
})
|
||||
|
||||
@test_routes.route('/sms', methods=['POST'])
|
||||
@require_login('admin')
|
||||
def test_sms_send():
|
||||
"""Test SMS send with specified method"""
|
||||
try:
|
||||
@ -111,6 +116,7 @@ def test_sms_send():
|
||||
})
|
||||
|
||||
@test_routes.route('/connections', methods=['GET'])
|
||||
@require_login('admin')
|
||||
def test_connections():
|
||||
"""Test all connection types"""
|
||||
try:
|
||||
|
||||
@ -9,6 +9,8 @@ import csv
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify
|
||||
from werkzeug.utils import secure_filename
|
||||
from routes.api.auth_decorator import require_auth
|
||||
from core.user_auth import require_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -23,6 +25,7 @@ def init_upload_routes(config):
|
||||
app_config = config
|
||||
|
||||
@upload_routes.route('/csv/upload', methods=['POST'])
|
||||
@require_login()
|
||||
def upload_csv():
|
||||
"""Upload and parse CSV file"""
|
||||
try:
|
||||
@ -102,6 +105,7 @@ def upload_csv():
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@upload_routes.route('/campaign/upload', methods=['POST'])
|
||||
@require_login()
|
||||
def upload_campaign_csv():
|
||||
"""Handle CSV file upload with preview for campaigns"""
|
||||
try:
|
||||
@ -171,3 +175,95 @@ def upload_campaign_csv():
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading campaign CSV: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@upload_routes.route('/contacts/fetch-from-phone', methods=['GET'])
|
||||
@require_login()
|
||||
def fetch_phone_contacts():
|
||||
"""Fetch contacts from Android phone via Termux API"""
|
||||
try:
|
||||
from models.contact_list import ContactList
|
||||
|
||||
cl = ContactList(app_config.DATABASE)
|
||||
termux_api_url = f"http://{app_config.PHONE_IP}:5001"
|
||||
|
||||
success, contacts, error = cl.fetch_phone_contacts(termux_api_url)
|
||||
|
||||
if not success:
|
||||
return jsonify({"success": False, "error": error}), 400
|
||||
|
||||
# Check for duplicates against existing database
|
||||
dup_check = cl.check_for_duplicates(contacts)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"contacts": contacts,
|
||||
"total_count": len(contacts),
|
||||
"new_count": len(dup_check['new']),
|
||||
"existing_count": len(dup_check['existing']),
|
||||
"conflicts_count": len(dup_check['conflicts']),
|
||||
"duplicate_analysis": dup_check
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fetch phone contacts error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@upload_routes.route('/contacts/import-from-phone', methods=['POST'])
|
||||
@require_login()
|
||||
def import_phone_contacts():
|
||||
"""Import selected contacts from phone into a contact list"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||
|
||||
contacts = data.get('contacts', [])
|
||||
list_id = data.get('list_id')
|
||||
list_name = data.get('list_name')
|
||||
update_conflicts = data.get('update_conflicts', False)
|
||||
skip_duplicates = data.get('skip_duplicates', True)
|
||||
|
||||
if not contacts:
|
||||
return jsonify({"success": False, "error": "No contacts provided"}), 400
|
||||
|
||||
from models.contact_list import ContactList
|
||||
cl = ContactList(app_config.DATABASE)
|
||||
cl.ensure_schema()
|
||||
|
||||
# Import into existing list or create new one
|
||||
if list_id:
|
||||
# Import into existing list
|
||||
added, skipped, updated = cl.import_phone_contacts(
|
||||
list_id, contacts, update_conflicts
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"list_id": list_id,
|
||||
"added": added,
|
||||
"skipped": skipped,
|
||||
"updated": updated,
|
||||
"message": f"Imported {added} contacts ({updated} updated, {skipped} skipped)"
|
||||
})
|
||||
else:
|
||||
# Create new list
|
||||
if not list_name:
|
||||
list_name = f"Phone Contacts {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
list_id, added, skipped = cl.create_list_from_phone_contacts(
|
||||
list_name, contacts, skip_duplicates
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"list_id": list_id,
|
||||
"list_name": list_name,
|
||||
"added": added,
|
||||
"skipped": skipped,
|
||||
"message": f"Created list '{list_name}' with {added} contacts ({skipped} skipped)"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Import phone contacts error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
272
src/routes/auth_routes.py
Normal file
272
src/routes/auth_routes.py
Normal file
@ -0,0 +1,272 @@
|
||||
"""
|
||||
Authentication Routes
|
||||
Handles user login, logout, and session management
|
||||
Uses Flask-Login for reliable session management
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify, session, render_template, redirect, url_for, current_app
|
||||
from flask_login import login_user, logout_user, current_user, login_required
|
||||
from core.user_auth import UserManager, require_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
# Will be injected
|
||||
user_manager: UserManager = None
|
||||
|
||||
def init_auth_routes(um: UserManager):
|
||||
"""Initialize auth routes with user manager"""
|
||||
global user_manager
|
||||
user_manager = um
|
||||
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login_page():
|
||||
"""Display login page"""
|
||||
# If already logged in, redirect to dashboard
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@auth_bp.route('/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
"""Handle login request"""
|
||||
try:
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
remember = data.get('remember', True) # Remember me by default
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Username and password are required'
|
||||
}), 400
|
||||
|
||||
# Authenticate user
|
||||
user = user_manager.authenticate_user(username, password)
|
||||
|
||||
if not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid username or password'
|
||||
}), 401
|
||||
|
||||
# Create database session for tracking/audit
|
||||
session_token = user_manager.create_session(
|
||||
user,
|
||||
request.remote_addr,
|
||||
request.headers.get('User-Agent', '')
|
||||
)
|
||||
|
||||
# Flask-Login: Log in the user
|
||||
login_user(user, remember=remember)
|
||||
|
||||
# Store session token for logout (for audit trail)
|
||||
session['session_token'] = session_token
|
||||
|
||||
logger.info(f"✅ User logged in: {username} (role: {user.role})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Login successful',
|
||||
'user': {
|
||||
'username': user.username,
|
||||
'role': user.role
|
||||
},
|
||||
'redirect': url_for('dashboard')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'An error occurred during login'
|
||||
}), 500
|
||||
|
||||
@auth_bp.route('/api/auth/logout', methods=['POST', 'GET'])
|
||||
def logout():
|
||||
"""Handle logout request"""
|
||||
try:
|
||||
# Invalidate database session (for audit trail)
|
||||
session_token = session.get('session_token')
|
||||
if session_token:
|
||||
user_manager.invalidate_session(session_token)
|
||||
|
||||
# Flask-Login: Log out the user
|
||||
logout_user()
|
||||
|
||||
logger.info("✅ User logged out")
|
||||
|
||||
if request.method == 'GET':
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Logged out successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Logout error: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'An error occurred during logout'
|
||||
}), 500
|
||||
|
||||
@auth_bp.route('/api/auth/status', methods=['GET'])
|
||||
def auth_status():
|
||||
"""Check if user is logged in"""
|
||||
if current_user.is_authenticated:
|
||||
return jsonify({
|
||||
'authenticated': True,
|
||||
'user': {
|
||||
'username': current_user.username,
|
||||
'role': current_user.role
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'authenticated': False
|
||||
})
|
||||
|
||||
@auth_bp.route('/api/auth/change-password', methods=['POST'])
|
||||
@require_login()
|
||||
def change_password():
|
||||
"""Change user password"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
user_data = session.get('user')
|
||||
|
||||
old_password = data.get('old_password', '')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not old_password or not new_password:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Both old and new passwords are required'
|
||||
}), 400
|
||||
|
||||
if len(new_password) < 8:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'New password must be at least 8 characters'
|
||||
}), 400
|
||||
|
||||
# Change password
|
||||
success = user_manager.change_password(
|
||||
user_data['username'],
|
||||
old_password,
|
||||
new_password
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Password changed successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to change password. Check your old password.'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Password change error: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'An error occurred while changing password'
|
||||
}), 500
|
||||
|
||||
# Admin-only routes
|
||||
@auth_bp.route('/api/admin/users', methods=['GET'])
|
||||
@require_login('admin')
|
||||
def list_users():
|
||||
"""List all users (admin only)"""
|
||||
try:
|
||||
users = user_manager.list_users()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'users': users
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing users: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to list users'
|
||||
}), 500
|
||||
|
||||
@auth_bp.route('/api/admin/users/create', methods=['POST'])
|
||||
@require_login('admin')
|
||||
def create_user():
|
||||
"""Create a new user (admin only)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
role = data.get('role', 'user')
|
||||
email = data.get('email')
|
||||
full_name = data.get('full_name')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Username and password are required'
|
||||
}), 400
|
||||
|
||||
success = user_manager.create_user(username, password, role, email, full_name)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'User {username} created successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to create user. User may already exist.'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@auth_bp.route('/api/admin/users/<username>', methods=['DELETE'])
|
||||
@require_login('admin')
|
||||
def delete_user(username):
|
||||
"""Delete a user (admin only)"""
|
||||
try:
|
||||
current_user = session.get('user')
|
||||
|
||||
# Prevent self-deletion
|
||||
if current_user['username'] == username:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Cannot delete your own account'
|
||||
}), 400
|
||||
|
||||
success = user_manager.delete_user(username)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'User {username} deleted successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to delete user'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting user: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
@ -5,6 +5,7 @@ Conversations API Routes - RESTful endpoints for conversation management
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from models.conversation import Conversation
|
||||
from core.user_auth import require_login
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -18,6 +19,7 @@ conversation_model = Conversation()
|
||||
|
||||
@conversations_bp.route('/')
|
||||
@conversations_bp.route('')
|
||||
@require_login()
|
||||
def list_conversations():
|
||||
"""List all conversation threads with optional filters"""
|
||||
try:
|
||||
@ -46,6 +48,7 @@ def list_conversations():
|
||||
|
||||
|
||||
@conversations_bp.route('/<conversation_id>')
|
||||
@require_login()
|
||||
def get_conversation_detail(conversation_id):
|
||||
"""Get specific conversation with all messages"""
|
||||
try:
|
||||
@ -65,6 +68,7 @@ def get_conversation_detail(conversation_id):
|
||||
|
||||
|
||||
@conversations_bp.route('/<conversation_id>/read', methods=['PUT'])
|
||||
@require_login()
|
||||
def mark_conversation_read(conversation_id):
|
||||
"""Mark conversation as read"""
|
||||
try:
|
||||
@ -84,6 +88,7 @@ def mark_conversation_read(conversation_id):
|
||||
|
||||
|
||||
@conversations_bp.route('/<conversation_id>/notes', methods=['PUT'])
|
||||
@require_login()
|
||||
def update_conversation_notes(conversation_id):
|
||||
"""Update conversation notes"""
|
||||
try:
|
||||
@ -106,6 +111,7 @@ def update_conversation_notes(conversation_id):
|
||||
|
||||
|
||||
@conversations_bp.route('/<conversation_id>/tags', methods=['POST'])
|
||||
@require_login()
|
||||
def manage_conversation_tags(conversation_id):
|
||||
"""Add, remove, or set tags for conversation"""
|
||||
try:
|
||||
@ -132,6 +138,7 @@ def manage_conversation_tags(conversation_id):
|
||||
|
||||
|
||||
@conversations_bp.route('/search')
|
||||
@require_login()
|
||||
def search_conversations():
|
||||
"""Search conversations by phone, name, notes, or message content"""
|
||||
try:
|
||||
@ -156,6 +163,7 @@ def search_conversations():
|
||||
|
||||
|
||||
@conversations_bp.route('/stats')
|
||||
@require_login()
|
||||
def get_conversation_stats():
|
||||
"""Get overall conversation statistics"""
|
||||
try:
|
||||
@ -172,6 +180,7 @@ def get_conversation_stats():
|
||||
|
||||
|
||||
@conversations_bp.route('/migrate', methods=['POST'])
|
||||
@require_login('admin')
|
||||
def migrate_existing_messages():
|
||||
"""One-time migration to create conversations from existing messages"""
|
||||
try:
|
||||
|
||||
@ -7,6 +7,7 @@ from flask import Blueprint, jsonify, request
|
||||
from models.conversation import Conversation
|
||||
from services.termux_sync_service import TermuxSyncService
|
||||
from services.websocket_service import WebSocketService
|
||||
from core.user_auth import require_login
|
||||
import logging
|
||||
import asyncio
|
||||
import sqlite3
|
||||
@ -28,6 +29,7 @@ def set_services(sync_svc: TermuxSyncService, ws_svc: WebSocketService):
|
||||
|
||||
@conversations_enhanced_bp.route('/')
|
||||
@conversations_enhanced_bp.route('')
|
||||
@require_login()
|
||||
def list_enhanced_conversations():
|
||||
"""List all conversations with enhanced features"""
|
||||
try:
|
||||
@ -88,6 +90,7 @@ def list_enhanced_conversations():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@conversations_enhanced_bp.route('/<conversation_id>/messages')
|
||||
@require_login()
|
||||
def get_conversation_messages(conversation_id):
|
||||
"""Get paginated messages for a conversation"""
|
||||
try:
|
||||
@ -135,6 +138,7 @@ def get_conversation_messages(conversation_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@conversations_enhanced_bp.route('/<conversation_id>/send', methods=['POST'])
|
||||
@require_login()
|
||||
def send_message(conversation_id):
|
||||
"""Send a message in a conversation"""
|
||||
try:
|
||||
@ -185,6 +189,7 @@ def send_message(conversation_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@conversations_enhanced_bp.route('/<conversation_id>/star', methods=['PUT'])
|
||||
@require_login()
|
||||
def toggle_star(conversation_id):
|
||||
"""Toggle starred status of conversation"""
|
||||
try:
|
||||
@ -223,6 +228,7 @@ def toggle_star(conversation_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@conversations_enhanced_bp.route('/<conversation_id>/sync', methods=['POST'])
|
||||
@require_login()
|
||||
def sync_conversation(conversation_id):
|
||||
"""Manually trigger full history sync for a conversation"""
|
||||
try:
|
||||
@ -255,6 +261,7 @@ def sync_conversation(conversation_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@conversations_enhanced_bp.route('/sync-all', methods=['POST'])
|
||||
@require_login()
|
||||
def sync_all_conversations():
|
||||
"""Sync all campaign conversations with phone"""
|
||||
try:
|
||||
@ -281,6 +288,7 @@ def sync_all_conversations():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@conversations_enhanced_bp.route('/<conversation_id>/mark-read', methods=['PUT'])
|
||||
@require_login()
|
||||
def mark_conversation_read(conversation_id):
|
||||
"""Mark all messages in conversation as read"""
|
||||
try:
|
||||
@ -315,6 +323,7 @@ def mark_conversation_read(conversation_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@conversations_enhanced_bp.route('/stats')
|
||||
@require_login()
|
||||
def get_conversation_stats():
|
||||
"""Get conversation statistics for dashboard"""
|
||||
try:
|
||||
|
||||
@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime
|
||||
import os
|
||||
from models.contact_list import ContactList
|
||||
from core.user_auth import require_login
|
||||
|
||||
lists_bp = Blueprint('lists', __name__)
|
||||
model = ContactList()
|
||||
@ -9,6 +10,7 @@ model.ensure_schema()
|
||||
|
||||
|
||||
@lists_bp.route('/api/lists', methods=['GET'])
|
||||
@require_login()
|
||||
def get_lists():
|
||||
try:
|
||||
lists = model.get_all_lists()
|
||||
@ -18,6 +20,7 @@ def get_lists():
|
||||
|
||||
|
||||
@lists_bp.route('/api/lists', methods=['POST'])
|
||||
@require_login()
|
||||
def create_list():
|
||||
try:
|
||||
data = request.get_json()
|
||||
@ -38,6 +41,7 @@ def create_list():
|
||||
|
||||
|
||||
@lists_bp.route('/api/lists/<int:list_id>', methods=['GET'])
|
||||
@require_login()
|
||||
def get_list(list_id):
|
||||
try:
|
||||
data = model.get_list(list_id)
|
||||
@ -49,6 +53,7 @@ def get_list(list_id):
|
||||
|
||||
|
||||
@lists_bp.route('/api/lists/<int:list_id>/contacts/<path:phone>', methods=['PUT'])
|
||||
@require_login()
|
||||
def update_contact(list_id, phone):
|
||||
try:
|
||||
updates = request.get_json() or {}
|
||||
@ -61,6 +66,7 @@ def update_contact(list_id, phone):
|
||||
|
||||
|
||||
@lists_bp.route('/api/lists/<int:list_id>', methods=['DELETE'])
|
||||
@require_login()
|
||||
def delete_list(list_id):
|
||||
try:
|
||||
success = model.soft_delete(list_id)
|
||||
@ -72,6 +78,7 @@ def delete_list(list_id):
|
||||
|
||||
|
||||
@lists_bp.route('/api/lists/<int:list_id>/use', methods=['POST'])
|
||||
@require_login()
|
||||
def use_list(list_id):
|
||||
try:
|
||||
model.mark_used(list_id)
|
||||
|
||||
@ -6,6 +6,7 @@ import subprocess
|
||||
import requests
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Any
|
||||
@ -37,7 +38,12 @@ class SMSConnectionManager:
|
||||
self.adb_port = config.get('ADB_PORT', '5555')
|
||||
self.termux_api_port = config.get('TERMUX_API_PORT', '5001')
|
||||
self.termux_api_url = f"http://{self.phone_ip}:{self.termux_api_port}"
|
||||
|
||||
|
||||
# API authentication
|
||||
self.termux_api_key = os.environ.get('TERMUX_API_KEY', '')
|
||||
if not self.termux_api_key:
|
||||
logger.warning("⚠️ TERMUX_API_KEY not set - Termux API calls may fail")
|
||||
|
||||
# Connection preferences and status
|
||||
self.primary_connection = ConnectionType.TERMUX_API # Prefer native API
|
||||
self.fallback_connection = ConnectionType.ADB
|
||||
@ -70,8 +76,10 @@ class SMSConnectionManager:
|
||||
# Check Termux API
|
||||
prev_termux_status = self.connection_status.get(ConnectionType.TERMUX_API, None)
|
||||
try:
|
||||
# Health endpoint doesn't require auth, but we should add header for consistency
|
||||
response = requests.get(
|
||||
f"{self.termux_api_url}/health",
|
||||
f"{self.termux_api_url}/health",
|
||||
headers={'X-API-Key': self.termux_api_key} if self.termux_api_key else {},
|
||||
timeout=5
|
||||
)
|
||||
current_status = (
|
||||
@ -185,6 +193,7 @@ class SMSConnectionManager:
|
||||
response = requests.post(
|
||||
f"{self.termux_api_url}/api/sms/send",
|
||||
json=payload,
|
||||
headers={'X-API-Key': self.termux_api_key} if self.termux_api_key else {},
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
@ -417,7 +426,11 @@ class SMSConnectionManager:
|
||||
"""Get device status from available connection"""
|
||||
if self.connection_status.get(ConnectionType.TERMUX_API, False):
|
||||
try:
|
||||
response = requests.get(f"{self.termux_api_url}/api/device/battery", timeout=5)
|
||||
response = requests.get(
|
||||
f"{self.termux_api_url}/api/device/battery",
|
||||
headers={'X-API-Key': self.termux_api_key} if self.termux_api_key else {},
|
||||
timeout=5
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {'success': True, 'battery': data}
|
||||
|
||||
@ -17,15 +17,22 @@ class WebSocketService:
|
||||
|
||||
def __init__(self, app, sync_service=None):
|
||||
self.socketio = SocketIO(
|
||||
app,
|
||||
cors_allowed_origins="*",
|
||||
app,
|
||||
cors_allowed_origins="*",
|
||||
async_mode='threading',
|
||||
logger=False,
|
||||
engineio_logger=False
|
||||
engineio_logger=False,
|
||||
# Keepalive settings to prevent disconnections
|
||||
ping_timeout=60, # Wait 60s for ping response before considering connection dead
|
||||
ping_interval=25, # Send ping every 25s to keep connection alive
|
||||
# Connection settings
|
||||
max_http_buffer_size=1e8, # 100MB max message size
|
||||
allow_upgrades=True,
|
||||
transports=['websocket', 'polling'] # Try websocket first, fallback to polling
|
||||
)
|
||||
self.sync_service = sync_service
|
||||
self.connected_clients = {}
|
||||
|
||||
|
||||
self.setup_handlers()
|
||||
|
||||
def setup_handlers(self):
|
||||
|
||||
433
src/static/js/campaigns.js
Normal file
433
src/static/js/campaigns.js
Normal file
@ -0,0 +1,433 @@
|
||||
// SMS Campaign Manager - Campaigns Page JavaScript
|
||||
|
||||
/**
|
||||
* Campaigns Alpine.js app
|
||||
*/
|
||||
function campaignsApp() {
|
||||
return {
|
||||
// Phone IP - will be set from template
|
||||
phoneIP: '',
|
||||
|
||||
// Campaign variables
|
||||
campaignName: '',
|
||||
messageTemplate: '',
|
||||
uploadedFile: null,
|
||||
selectedList: '',
|
||||
savedLists: [],
|
||||
campaignReady: false,
|
||||
|
||||
// Contact preview variables
|
||||
contactsPreview: [],
|
||||
totalContacts: 0,
|
||||
uploadedContacts: [],
|
||||
|
||||
// Campaign state
|
||||
campaignState: {
|
||||
status: 'idle',
|
||||
current: 0,
|
||||
total: 0,
|
||||
errors: []
|
||||
},
|
||||
currentCampaignId: null,
|
||||
|
||||
// Analytics and data
|
||||
analytics: {},
|
||||
responseTypes: [],
|
||||
recentCampaigns: [],
|
||||
|
||||
// Template management
|
||||
selectedTemplate: '',
|
||||
savedTemplates: [],
|
||||
_lastLoadedTemplate: '', // Track the last loaded template content
|
||||
|
||||
/**
|
||||
* Initialize campaigns app
|
||||
*/
|
||||
async init() {
|
||||
// Load initial data
|
||||
await this.loadSavedLists();
|
||||
await this.loadSavedTemplates();
|
||||
await this.loadAnalytics();
|
||||
await this.loadRecentCampaigns();
|
||||
|
||||
// Periodic updates
|
||||
setInterval(() => this.loadAnalytics(), 10000); // Every 10 seconds
|
||||
setInterval(() => this.loadRecentCampaigns(), 15000); // Every 15 seconds
|
||||
|
||||
// Check for list ID from localStorage (cross-page navigation)
|
||||
const listId = localStorage.getItem('selectedListId');
|
||||
if (listId) {
|
||||
this.selectedList = listId;
|
||||
await this.loadSavedList(listId);
|
||||
localStorage.removeItem('selectedListId');
|
||||
}
|
||||
|
||||
console.log('✅ Campaigns app initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Load campaign analytics
|
||||
*/
|
||||
async loadAnalytics() {
|
||||
try {
|
||||
const response = await fetch('/api/analytics', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
this.analytics = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load saved contact lists
|
||||
*/
|
||||
async loadSavedLists() {
|
||||
try {
|
||||
const response = await fetch('/api/lists', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
this.savedLists = data.success ? data.lists : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load lists:', error);
|
||||
this.savedLists = [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load recent campaigns
|
||||
*/
|
||||
async loadRecentCampaigns() {
|
||||
try {
|
||||
const response = await fetch('/api/campaign/recent', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const campaigns = await response.json();
|
||||
this.recentCampaigns = campaigns || [];
|
||||
console.log('Recent campaigns loaded:', this.recentCampaigns.length);
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent campaigns:', error);
|
||||
this.recentCampaigns = [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle CSV file upload
|
||||
*/
|
||||
async handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/campaign/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.uploadedFile = file.name;
|
||||
this.uploadedContacts = data.contacts || [];
|
||||
this.contactsPreview = data.preview || data.contacts.slice(0, 10) || [];
|
||||
this.totalContacts = data.total_contacts || data.contacts.length || 0;
|
||||
this.campaignReady = true;
|
||||
|
||||
// Store globally for campaign creation
|
||||
window.campaignContacts = data.contacts;
|
||||
|
||||
console.log(`Loaded ${this.totalContacts} contacts`);
|
||||
} else {
|
||||
alert('Error uploading file: ' + data.error);
|
||||
this.resetContactData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
alert('Upload failed: ' + error.message);
|
||||
this.resetContactData();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset contact data
|
||||
*/
|
||||
resetContactData() {
|
||||
this.uploadedFile = null;
|
||||
this.uploadedContacts = [];
|
||||
this.contactsPreview = [];
|
||||
this.totalContacts = 0;
|
||||
this.campaignReady = false;
|
||||
window.campaignContacts = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Load saved contact list
|
||||
*/
|
||||
async loadSavedList(listId) {
|
||||
if (!listId) {
|
||||
this.resetContactData();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lists/${listId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.list && data.list.contacts) {
|
||||
const list = data.list;
|
||||
this.uploadedContacts = list.contacts;
|
||||
this.contactsPreview = list.contacts.slice(0, 10);
|
||||
this.totalContacts = list.contacts.length;
|
||||
this.campaignReady = true;
|
||||
this.uploadedFile = `${list.name} (saved list)`;
|
||||
|
||||
// Store globally for campaign creation
|
||||
window.campaignContacts = list.contacts;
|
||||
|
||||
console.log(`Loaded saved list: ${list.name} with ${this.totalContacts} contacts`);
|
||||
} else {
|
||||
alert('Error loading saved list: ' + (data.error || 'Unknown error'));
|
||||
this.resetContactData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading saved list:', error);
|
||||
alert('Failed to load saved list');
|
||||
this.resetContactData();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start campaign
|
||||
*/
|
||||
async startCampaign() {
|
||||
if (!this.campaignReady || !this.messageTemplate.trim()) {
|
||||
alert('Please upload contacts and enter a message template');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create campaign with contact data
|
||||
try {
|
||||
const contactData = window.campaignContacts || this.uploadedContacts || [];
|
||||
|
||||
if (contactData.length === 0) {
|
||||
alert('No contacts loaded. Please upload a CSV file or select a saved list.');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/campaign/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: this.campaignName || `Campaign ${new Date().toISOString().split('T')[0]}`,
|
||||
message: this.messageTemplate,
|
||||
csv_data: contactData,
|
||||
list_id: this.selectedList
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.currentCampaignId = result.campaign_id;
|
||||
|
||||
alert(`Campaign "${result.campaign_name}" created with ${result.total_recipients} recipients!`);
|
||||
|
||||
// Start the campaign
|
||||
const startResponse = await fetch('/api/campaign/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ campaign_id: this.currentCampaignId })
|
||||
});
|
||||
|
||||
const startResult = await startResponse.json();
|
||||
if (startResult.success) {
|
||||
this.campaignState.status = 'running';
|
||||
this.campaignState.total = startResult.total || result.total_recipients;
|
||||
console.log(`Campaign started successfully with ${startResult.total} recipients`);
|
||||
alert(`Campaign started successfully! Sending to ${startResult.total} recipients.`);
|
||||
} else {
|
||||
console.error('Failed to start campaign:', startResult.error);
|
||||
alert(`Failed to start campaign: ${startResult.error}`);
|
||||
}
|
||||
|
||||
// Reload recent campaigns
|
||||
await this.loadRecentCampaigns();
|
||||
} else {
|
||||
alert(`Failed to create campaign: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting campaign:', error);
|
||||
alert('Failed to start campaign. Check console for details.');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load saved templates
|
||||
*/
|
||||
async loadSavedTemplates() {
|
||||
try {
|
||||
const response = await fetch('/api/templates', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
this.savedTemplates = data || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
this.savedTemplates = [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load template into message field
|
||||
*/
|
||||
async loadTemplate(templateId) {
|
||||
if (!templateId) {
|
||||
this.selectedTemplate = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert templateId to number for comparison since select values are strings
|
||||
const numericTemplateId = parseInt(templateId);
|
||||
|
||||
const template = this.savedTemplates.find(t => t.id === numericTemplateId);
|
||||
|
||||
if (template) {
|
||||
// Set the selected template ID first
|
||||
this.selectedTemplate = templateId;
|
||||
|
||||
// Apply template content to message template field
|
||||
// Handle both 'template' and 'content' fields for consistency
|
||||
const templateContent = template.template || template.content;
|
||||
|
||||
if (templateContent) {
|
||||
this.messageTemplate = templateContent;
|
||||
|
||||
// Store the template content for comparison
|
||||
this._lastLoadedTemplate = templateContent;
|
||||
|
||||
console.log(`✅ Loaded template: ${template.name}`);
|
||||
|
||||
// Mark template as used (but don't await to avoid blocking UI)
|
||||
fetch(`/api/templates/${templateId}/use`, { method: 'POST' })
|
||||
.catch(error => console.log('Usage tracking failed:', error));
|
||||
} else {
|
||||
console.error('❌ Template content is empty');
|
||||
alert('Template content is empty');
|
||||
}
|
||||
} else {
|
||||
console.error('❌ Template not found with ID:', numericTemplateId);
|
||||
alert(`Template not found with ID: ${numericTemplateId}`);
|
||||
this.selectedTemplate = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading template:', error);
|
||||
this.selectedTemplate = '';
|
||||
alert('Failed to load template: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear loaded template
|
||||
*/
|
||||
clearTemplate() {
|
||||
this.selectedTemplate = '';
|
||||
this.messageTemplate = '';
|
||||
this._lastLoadedTemplate = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if message template was manually modified
|
||||
*/
|
||||
onMessageTemplateChange() {
|
||||
// Only clear selected template if user manually modified the content
|
||||
if (this.selectedTemplate && this._lastLoadedTemplate &&
|
||||
this.messageTemplate !== this._lastLoadedTemplate) {
|
||||
this.selectedTemplate = '';
|
||||
this._lastLoadedTemplate = '';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save current message as template
|
||||
*/
|
||||
async saveTemplate() {
|
||||
const name = prompt('Template name:');
|
||||
if (!name) return;
|
||||
|
||||
const description = prompt('Template description (optional):') || '';
|
||||
const category = prompt('Category (general, volunteer, reminder, gratitude, followup):') || 'general';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
content: this.messageTemplate,
|
||||
description: description,
|
||||
category: category,
|
||||
is_favorite: 0
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert('Template saved!');
|
||||
await this.loadSavedTemplates();
|
||||
} else {
|
||||
alert('Error saving template: ' + result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving template:', error);
|
||||
alert('Error saving template: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Test SMS with current template
|
||||
*/
|
||||
async testSMS() {
|
||||
if (!this.messageTemplate) {
|
||||
alert('Please enter a message template first');
|
||||
return;
|
||||
}
|
||||
|
||||
const phone = prompt('Enter test phone number:', '7802921731');
|
||||
if (!phone) return;
|
||||
|
||||
const testMessage = this.messageTemplate.replace('{name}', 'Test User');
|
||||
const confirmed = confirm(`Send test SMS?\n\n⚠️ WARNING: This will send a REAL SMS message!\n\nTo: ${phone}\nMessage: ${testMessage.substring(0, 100)}${testMessage.length > 100 ? '...' : ''}\n\nProceed?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sms/test/real', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: phone,
|
||||
message: testMessage,
|
||||
name: 'Test User'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(`✅ Test SMS sent successfully!\n\nMethod: ${result.connection_type}\nPhone: ${phone}\nTime: ${new Date(result.timestamp * 1000).toLocaleTimeString()}`);
|
||||
} else {
|
||||
alert(`❌ Test SMS failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`❌ Test SMS error: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date - uses baseApp's formatDate
|
||||
*/
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
};
|
||||
}
|
||||
186
src/static/js/common.js
Normal file
186
src/static/js/common.js
Normal file
@ -0,0 +1,186 @@
|
||||
// SMS Campaign Manager - Common Utilities
|
||||
// Shared JavaScript functionality across all pages
|
||||
|
||||
// Global WebSocket instance (shared across all Alpine components)
|
||||
let globalSocket = null;
|
||||
|
||||
/**
|
||||
* Base Alpine.js app for shared functionality
|
||||
* Used on all pages via x-data="baseApp"
|
||||
*/
|
||||
function baseApp() {
|
||||
return {
|
||||
// Phone IP and status - will be set from template
|
||||
phoneIP: '',
|
||||
phoneStatus: {
|
||||
termux_connected: false,
|
||||
adb_connected: false,
|
||||
prefer_termux: true,
|
||||
last_check: null
|
||||
},
|
||||
|
||||
// Connection status (detailed)
|
||||
connectionStatus: {
|
||||
termux_api: { available: false, url: '', type: '' },
|
||||
adb: { available: false, target: '', type: '' },
|
||||
optimal_connection: null
|
||||
},
|
||||
|
||||
// Intervals for cleanup
|
||||
_intervals: [],
|
||||
|
||||
/**
|
||||
* Initialize base app - called on every page
|
||||
*/
|
||||
async init() {
|
||||
// Initial status check
|
||||
await this.checkConnectionStatus();
|
||||
|
||||
// Periodic status checks (every 10 seconds)
|
||||
this._intervals.push(setInterval(() => this.checkConnectionStatus(), 10000));
|
||||
|
||||
// Initialize WebSocket
|
||||
this.setupWebSocket();
|
||||
},
|
||||
|
||||
/**
|
||||
* Setup WebSocket connection (singleton pattern)
|
||||
* Only one WebSocket connection across all pages
|
||||
*/
|
||||
setupWebSocket() {
|
||||
// Don't create if already exists
|
||||
if (globalSocket) {
|
||||
console.log('✅ WebSocket already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof io === 'undefined') {
|
||||
console.warn('Socket.IO not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure Socket.IO with robust reconnection
|
||||
globalSocket = io({
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: Infinity,
|
||||
timeout: 20000,
|
||||
transports: ['websocket', 'polling'],
|
||||
upgrade: true,
|
||||
autoConnect: true,
|
||||
forceNew: false
|
||||
});
|
||||
|
||||
globalSocket.on('connect', () => {
|
||||
console.log('✅ WebSocket connected');
|
||||
});
|
||||
|
||||
globalSocket.on('disconnect', (reason) => {
|
||||
console.log('❌ WebSocket disconnected:', reason);
|
||||
if (reason === 'io server disconnect') {
|
||||
globalSocket.connect();
|
||||
}
|
||||
});
|
||||
|
||||
globalSocket.on('connect_error', (error) => {
|
||||
console.warn('⚠️ WebSocket connection error:', error.message);
|
||||
});
|
||||
|
||||
globalSocket.on('reconnect', (attemptNumber) => {
|
||||
console.log('🔄 WebSocket reconnected after', attemptNumber, 'attempts');
|
||||
});
|
||||
|
||||
globalSocket.on('reconnect_attempt', (attemptNumber) => {
|
||||
console.log('🔄 Reconnection attempt', attemptNumber);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error setting up WebSocket:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check phone connection status (Termux API & ADB)
|
||||
*/
|
||||
async checkConnectionStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/phone/status', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If unauthorized, redirect to login
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.warn('Unauthorized - session may have expired');
|
||||
// Still update status to show we tried
|
||||
this.phoneStatus = {
|
||||
termux_connected: false,
|
||||
adb_connected: false,
|
||||
connected: false,
|
||||
last_check: new Date().toISOString(),
|
||||
error: 'Authentication required'
|
||||
};
|
||||
return;
|
||||
}
|
||||
// Other HTTP errors
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.phoneStatus = {
|
||||
...data,
|
||||
last_check: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Status check failed:', error);
|
||||
// On error, mark both as offline but still update last_check
|
||||
// so the UI doesn't stay stuck in "Checking..." state
|
||||
this.phoneStatus = {
|
||||
termux_connected: false,
|
||||
adb_connected: false,
|
||||
connected: false,
|
||||
last_check: new Date().toISOString(),
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load detailed connection status
|
||||
*/
|
||||
async loadConnectionStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/connections/status', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
this.connectionStatus = {
|
||||
termux_api: data.connections?.termux_api || { available: false },
|
||||
adb: data.connections?.adb || { available: false },
|
||||
optimal_connection: data.optimal_connection
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading connection status:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Utility: Format date to locale date string
|
||||
*/
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
},
|
||||
|
||||
/**
|
||||
* Utility: Format date to locale time string
|
||||
*/
|
||||
formatTime(dateStr) {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleTimeString();
|
||||
}
|
||||
};
|
||||
}
|
||||
464
src/static/js/conversations.js
Normal file
464
src/static/js/conversations.js
Normal file
@ -0,0 +1,464 @@
|
||||
// SMS Campaign Manager - Conversations Page JavaScript
|
||||
|
||||
/**
|
||||
* Conversations Alpine.js app
|
||||
*/
|
||||
function conversationsApp() {
|
||||
return {
|
||||
// State properties
|
||||
conversations: [],
|
||||
selectedConversation: null,
|
||||
messages: [],
|
||||
conversationFilter: 'all',
|
||||
conversationSearch: '',
|
||||
newMessage: '',
|
||||
sendingMessage: false,
|
||||
hasMoreMessages: false,
|
||||
loadingMessages: false,
|
||||
syncing: false,
|
||||
|
||||
// Computed properties
|
||||
get filteredConversations() {
|
||||
let filtered = this.conversations;
|
||||
|
||||
// Apply search filter
|
||||
if (this.conversationSearch) {
|
||||
const search = this.conversationSearch.toLowerCase();
|
||||
filtered = filtered.filter(conv =>
|
||||
(conv.contact_name && conv.contact_name.toLowerCase().includes(search)) ||
|
||||
conv.phone.includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
switch (this.conversationFilter) {
|
||||
case 'unread':
|
||||
filtered = filtered.filter(conv => conv.unread_count > 0);
|
||||
break;
|
||||
case 'starred':
|
||||
filtered = filtered.filter(conv => conv.is_starred);
|
||||
break;
|
||||
default:
|
||||
// 'all' - no additional filtering
|
||||
break;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
|
||||
get unreadCount() {
|
||||
return this.conversations.reduce((total, conv) => total + (conv.unread_count || 0), 0);
|
||||
},
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
await this.loadConversations();
|
||||
|
||||
// Setup WebSocket handlers using global socket
|
||||
// Wait for global socket to be available (max 5 seconds)
|
||||
await this.waitForSocket();
|
||||
},
|
||||
|
||||
// Wait for global socket to be initialized
|
||||
async waitForSocket(maxAttempts = 10) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (globalSocket) {
|
||||
this.setupWebSocketHandlers(globalSocket);
|
||||
console.log('✅ Conversations connected to WebSocket');
|
||||
return;
|
||||
}
|
||||
// Wait 500ms before trying again
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
console.warn('⚠️ Global WebSocket not available after timeout');
|
||||
},
|
||||
|
||||
// Setup WebSocket event handlers on global socket
|
||||
setupWebSocketHandlers(socket) {
|
||||
// Remove any existing listeners to prevent duplicates
|
||||
socket.off('new_message');
|
||||
socket.off('message_status_update');
|
||||
socket.off('conversation_update');
|
||||
|
||||
// Add conversation-specific listeners
|
||||
socket.on('new_message', (data) => {
|
||||
this.handleNewMessage(data);
|
||||
});
|
||||
|
||||
socket.on('message_status_update', (data) => {
|
||||
this.updateMessageStatus(data.message_id, data.status);
|
||||
});
|
||||
|
||||
socket.on('conversation_update', (data) => {
|
||||
this.handleConversationUpdate(data);
|
||||
});
|
||||
},
|
||||
|
||||
// Load conversations from API
|
||||
async loadConversations() {
|
||||
try {
|
||||
const response = await fetch('/api/conversations/enhanced/', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.conversations = data.conversations || [];
|
||||
console.log('Loaded conversations:', this.conversations.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading conversations:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Select and load conversation messages
|
||||
async selectConversation(conversationId) {
|
||||
try {
|
||||
this.loadingMessages = true;
|
||||
|
||||
// Find and set selected conversation
|
||||
this.selectedConversation = this.conversations.find(conv => conv.phone === conversationId);
|
||||
|
||||
if (this.selectedConversation) {
|
||||
// Load messages for this conversation
|
||||
await this.loadMessages();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error selecting conversation:', error);
|
||||
} finally {
|
||||
this.loadingMessages = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Load messages for selected conversation
|
||||
async loadMessages() {
|
||||
if (!this.selectedConversation) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/conversations/enhanced/${this.selectedConversation.phone}/messages`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.messages = data.messages || [];
|
||||
this.hasMoreMessages = data.has_more || false;
|
||||
|
||||
// Scroll to bottom after messages load
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.messagesContainer) {
|
||||
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading messages:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Load more messages for pagination
|
||||
async loadMoreMessages() {
|
||||
if (!this.selectedConversation || this.loadingMessages || !this.hasMoreMessages) return;
|
||||
|
||||
try {
|
||||
this.loadingMessages = true;
|
||||
const oldestMessageId = this.messages.length > 0 ? this.messages[0].id : null;
|
||||
|
||||
const url = oldestMessageId
|
||||
? `/api/conversations/enhanced/${this.selectedConversation.phone}/messages?before=${oldestMessageId}`
|
||||
: `/api/conversations/enhanced/${this.selectedConversation.phone}/messages`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Prepend older messages
|
||||
this.messages = [...(data.messages || []), ...this.messages];
|
||||
this.hasMoreMessages = data.has_more || false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more messages:', error);
|
||||
} finally {
|
||||
this.loadingMessages = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Send a new message
|
||||
async sendMessage() {
|
||||
if (!this.newMessage.trim() || !this.selectedConversation || this.sendingMessage) return;
|
||||
|
||||
try {
|
||||
this.sendingMessage = true;
|
||||
|
||||
const response = await fetch(`/api/conversations/enhanced/${this.selectedConversation.phone}/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: this.newMessage.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Add optimistic message to UI
|
||||
const newMsg = {
|
||||
id: Date.now(), // Temporary ID
|
||||
message: this.newMessage.trim(),
|
||||
direction: 'outbound',
|
||||
status: 'pending',
|
||||
sent_at: new Date().toISOString(),
|
||||
phone: this.selectedConversation.phone
|
||||
};
|
||||
|
||||
this.messages.push(newMsg);
|
||||
this.newMessage = '';
|
||||
|
||||
// Scroll to bottom
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.messagesContainer) {
|
||||
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
// Update conversation in list
|
||||
this.selectedConversation.message_count = (this.selectedConversation.message_count || 0) + 1;
|
||||
this.selectedConversation.last_message_time = Date.now() / 1000;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
} finally {
|
||||
this.sendingMessage = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Filter and search functions
|
||||
setFilter(filter) {
|
||||
this.conversationFilter = filter;
|
||||
},
|
||||
|
||||
searchConversations() {
|
||||
// Reactive filtering happens automatically via computed property
|
||||
},
|
||||
|
||||
// Star/unstar conversation
|
||||
async toggleStar(conversationId) {
|
||||
try {
|
||||
const response = await fetch(`/api/conversations/enhanced/${conversationId}/star`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state
|
||||
const conv = this.conversations.find(c => c.phone === conversationId);
|
||||
if (conv) {
|
||||
conv.is_starred = !conv.is_starred;
|
||||
}
|
||||
if (this.selectedConversation && this.selectedConversation.phone === conversationId) {
|
||||
this.selectedConversation.is_starred = !this.selectedConversation.is_starred;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling star:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Sync functions
|
||||
async syncConversation(conversationId) {
|
||||
if (this.syncing) return;
|
||||
|
||||
try {
|
||||
this.syncing = true;
|
||||
|
||||
// Trigger sync for specific conversation
|
||||
await fetch(`/api/conversations/enhanced/${conversationId}/sync`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// Reload messages after sync
|
||||
if (this.selectedConversation && this.selectedConversation.phone === conversationId) {
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error syncing conversation:', error);
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async syncAllConversations() {
|
||||
if (this.syncing) return;
|
||||
|
||||
try {
|
||||
this.syncing = true;
|
||||
|
||||
// Trigger full sync
|
||||
await fetch('/api/responses/sync', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
// Reload conversations
|
||||
await this.loadConversations();
|
||||
|
||||
// Reload current conversation messages if any selected
|
||||
if (this.selectedConversation) {
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error syncing all conversations:', error);
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Handle real-time message updates
|
||||
handleNewMessage(messageData) {
|
||||
// Add to messages if it's for the current conversation
|
||||
if (this.selectedConversation && messageData.phone === this.selectedConversation.phone) {
|
||||
this.messages.push(messageData);
|
||||
|
||||
// Scroll to bottom
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.messagesContainer) {
|
||||
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update conversation in list
|
||||
this.updateConversationInList(messageData);
|
||||
},
|
||||
|
||||
updateMessageStatus(messageId, status) {
|
||||
// Update message status in current conversation
|
||||
const message = this.messages.find(m => m.id === messageId);
|
||||
if (message) {
|
||||
message.status = status;
|
||||
}
|
||||
},
|
||||
|
||||
handleConversationUpdate(data) {
|
||||
const conv = this.conversations.find(c => c.phone === data.phone);
|
||||
if (conv) {
|
||||
Object.assign(conv, data);
|
||||
}
|
||||
},
|
||||
|
||||
updateConversationInList(messageData) {
|
||||
let conv = this.conversations.find(c => c.phone === messageData.phone);
|
||||
|
||||
if (!conv) {
|
||||
// Create new conversation if it doesn't exist
|
||||
conv = {
|
||||
phone: messageData.phone,
|
||||
contact_name: messageData.name || messageData.phone,
|
||||
message_count: 1,
|
||||
unread_count: messageData.direction === 'inbound' ? 1 : 0,
|
||||
is_starred: false,
|
||||
last_message_time: messageData.timestamp || Date.now() / 1000
|
||||
};
|
||||
this.conversations.unshift(conv);
|
||||
} else {
|
||||
// Update existing conversation
|
||||
conv.message_count = (conv.message_count || 0) + 1;
|
||||
if (messageData.direction === 'inbound') {
|
||||
conv.unread_count = (conv.unread_count || 0) + 1;
|
||||
}
|
||||
conv.last_message_time = messageData.timestamp || Date.now() / 1000;
|
||||
|
||||
// Move to top of list
|
||||
const index = this.conversations.indexOf(conv);
|
||||
if (index > 0) {
|
||||
this.conversations.splice(index, 1);
|
||||
this.conversations.unshift(conv);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Utility functions
|
||||
getInitials(conversation) {
|
||||
if (!conversation) return '';
|
||||
|
||||
const name = conversation.contact_name || conversation.phone;
|
||||
if (name === conversation.phone) {
|
||||
// For phone numbers, just use the first two digits
|
||||
return name.slice(-4, -2) || name.slice(0, 2) || '??';
|
||||
}
|
||||
|
||||
const words = name.split(' ').filter(word => word.length > 0);
|
||||
if (words.length >= 2) {
|
||||
return (words[0][0] + words[1][0]).toUpperCase();
|
||||
} else if (words.length === 1) {
|
||||
return words[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
return '??';
|
||||
},
|
||||
|
||||
formatPhone(phone) {
|
||||
if (!phone) return '';
|
||||
|
||||
// Remove any non-digit characters
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
|
||||
// Format as (xxx) xxx-xxxx for 10 digit numbers
|
||||
if (cleaned.length === 10) {
|
||||
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
|
||||
}
|
||||
|
||||
return phone;
|
||||
},
|
||||
|
||||
formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
},
|
||||
|
||||
formatMessageTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
let date;
|
||||
if (typeof timestamp === 'string') {
|
||||
date = new Date(timestamp);
|
||||
} else {
|
||||
date = new Date(timestamp * 1000);
|
||||
}
|
||||
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
|
||||
getStatusIcon(status) {
|
||||
switch (status) {
|
||||
case 'pending': return '⏳';
|
||||
case 'sent': return '✓';
|
||||
case 'delivered': return '✓✓';
|
||||
case 'failed': return '❌';
|
||||
default: return '';
|
||||
}
|
||||
},
|
||||
|
||||
getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'pending': return 'text-yellow-400';
|
||||
case 'sent': return 'text-blue-300';
|
||||
case 'delivered': return 'text-blue-200';
|
||||
case 'failed': return 'text-red-400';
|
||||
default: return 'text-gray-400';
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,8 @@
|
||||
// SMS Campaign Manager - Dashboard JavaScript
|
||||
|
||||
// Global WebSocket instance (shared across all Alpine components)
|
||||
let globalSocket = null;
|
||||
|
||||
function campaignApp() {
|
||||
return {
|
||||
// Tab management
|
||||
@ -90,25 +94,38 @@ function campaignApp() {
|
||||
resettingDatabase: false,
|
||||
resetResult: null,
|
||||
|
||||
// Initialization
|
||||
// Logout state
|
||||
loggingOut: false,
|
||||
_intervals: [],
|
||||
|
||||
// Initialization
|
||||
async init() {
|
||||
// Check for URL hash to set initial tab
|
||||
const hash = window.location.hash.substring(1); // Remove the # symbol
|
||||
if (hash && ['campaigns', 'conversations', 'templates', 'lists', 'testing'].includes(hash)) {
|
||||
this.activeTab = hash;
|
||||
}
|
||||
|
||||
// Start monitoring connection status
|
||||
await this.checkConnectionStatus();
|
||||
await this.loadConnectionStatus();
|
||||
|
||||
|
||||
// Load initial data
|
||||
await this.loadAnalytics();
|
||||
await this.loadSavedLists();
|
||||
await this.loadSavedTemplates();
|
||||
await this.loadRecentCampaigns();
|
||||
await this.loadFollowups();
|
||||
|
||||
// Set up periodic updates
|
||||
setInterval(() => this.checkConnectionStatus(), 10000); // Check every 10 seconds
|
||||
setInterval(() => this.updateStatus(), 2000); // Campaign status updates
|
||||
setInterval(() => this.loadAnalytics(), 10000); // Analytics updates
|
||||
setInterval(() => this.loadRecentCampaigns(), 15000); // Recent campaigns updates every 15 seconds
|
||||
|
||||
|
||||
// Set up periodic updates (store IDs so we can clear on logout)
|
||||
this._intervals.push(setInterval(() => this.checkConnectionStatus(), 10000)); // Check every 10 seconds
|
||||
this._intervals.push(setInterval(() => this.updateStatus(), 2000)); // Campaign status updates
|
||||
this._intervals.push(setInterval(() => this.loadAnalytics(), 10000)); // Analytics updates
|
||||
this._intervals.push(setInterval(() => this.loadRecentCampaigns(), 15000)); // Recent campaigns updates every 15 seconds
|
||||
|
||||
// Initialize WebSocket connection at app level (persists across tabs)
|
||||
this.setupWebSocket();
|
||||
|
||||
// Listen for saved list loads from the ListManager UI
|
||||
document.addEventListener('saved-list-loaded', (e) => {
|
||||
try {
|
||||
@ -121,11 +138,87 @@ function campaignApp() {
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Setup WebSocket at app level (moved from conversationData)
|
||||
setupWebSocket() {
|
||||
// Don't create if already exists
|
||||
if (globalSocket) {
|
||||
console.log('✅ WebSocket already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof io === 'undefined') {
|
||||
console.warn('Socket.IO not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure Socket.IO with robust reconnection
|
||||
globalSocket = io({
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: Infinity,
|
||||
timeout: 20000,
|
||||
transports: ['websocket', 'polling'],
|
||||
upgrade: true,
|
||||
autoConnect: true,
|
||||
forceNew: false
|
||||
});
|
||||
|
||||
globalSocket.on('connect', () => {
|
||||
console.log('✅ WebSocket connected');
|
||||
});
|
||||
|
||||
globalSocket.on('disconnect', (reason) => {
|
||||
console.log('❌ WebSocket disconnected:', reason);
|
||||
if (reason === 'io server disconnect') {
|
||||
globalSocket.connect();
|
||||
}
|
||||
});
|
||||
|
||||
globalSocket.on('connect_error', (error) => {
|
||||
console.warn('⚠️ WebSocket connection error:', error.message);
|
||||
});
|
||||
|
||||
globalSocket.on('reconnect', (attemptNumber) => {
|
||||
console.log('🔄 WebSocket reconnected after', attemptNumber, 'attempts');
|
||||
});
|
||||
|
||||
globalSocket.on('reconnect_attempt', (attemptNumber) => {
|
||||
console.log('🔄 Reconnection attempt', attemptNumber);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error setting up WebSocket:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Connection management
|
||||
async checkConnectionStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/phone/status');
|
||||
const response = await fetch('/api/phone/status', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If unauthorized, redirect to login
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.warn('Unauthorized - session may have expired');
|
||||
// Still update status to show we tried
|
||||
this.phoneStatus = {
|
||||
termux_connected: false,
|
||||
adb_connected: false,
|
||||
connected: false,
|
||||
last_check: new Date().toISOString(),
|
||||
error: 'Authentication required'
|
||||
};
|
||||
return;
|
||||
}
|
||||
// Other HTTP errors
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.phoneStatus = {
|
||||
...data,
|
||||
@ -133,12 +226,24 @@ function campaignApp() {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Status check failed:', error);
|
||||
// On error, mark both as offline but still update last_check
|
||||
// so the UI doesn't stay stuck in "Checking..." state
|
||||
this.phoneStatus = {
|
||||
termux_connected: false,
|
||||
adb_connected: false,
|
||||
connected: false,
|
||||
last_check: new Date().toISOString(),
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async loadConnectionStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/connections/status');
|
||||
const response = await fetch('/api/connections/status', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
this.connectionStatus = {
|
||||
termux_api: data.connections?.termux_api || { available: false },
|
||||
@ -153,16 +258,18 @@ function campaignApp() {
|
||||
// Data loading functions
|
||||
async loadAnalytics() {
|
||||
try {
|
||||
const response = await fetch('/api/analytics');
|
||||
const response = await fetch('/api/analytics', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
this.analytics = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics:', error);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async loadSavedLists() {
|
||||
try {
|
||||
const response = await fetch('/api/lists');
|
||||
const response = await fetch('/api/lists', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
this.savedLists = data.success ? data.lists : [];
|
||||
} catch (error) {
|
||||
@ -170,10 +277,11 @@ function campaignApp() {
|
||||
this.savedLists = []; // Set empty array on error
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async loadRecentCampaigns() {
|
||||
try {
|
||||
const response = await fetch('/api/campaigns/recent');
|
||||
const response = await fetch('/api/campaign/recent', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const campaigns = await response.json();
|
||||
this.recentCampaigns = campaigns || [];
|
||||
console.log('Recent campaigns loaded:', this.recentCampaigns.length);
|
||||
@ -182,10 +290,11 @@ function campaignApp() {
|
||||
this.recentCampaigns = []; // Set empty array on error
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async loadFollowups() {
|
||||
try {
|
||||
const response = await fetch('/api/followups');
|
||||
const response = await fetch('/api/followups', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
this.followups = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to load followups:', error);
|
||||
@ -339,7 +448,8 @@ function campaignApp() {
|
||||
// Template Management Methods
|
||||
async loadSavedTemplates() {
|
||||
try {
|
||||
const response = await fetch('/api/templates');
|
||||
const response = await fetch('/api/templates', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
this.savedTemplates = data || [];
|
||||
} catch (error) {
|
||||
@ -742,9 +852,10 @@ function campaignApp() {
|
||||
async updateStatus() {
|
||||
if (this.campaignState.status !== 'idle') {
|
||||
try {
|
||||
const response = await fetch('/api/campaign/status');
|
||||
const response = await fetch('/api/campaign/status', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
this.campaignState = await response.json();
|
||||
|
||||
|
||||
if (this.campaignState.status === 'completed') {
|
||||
await this.loadAnalytics();
|
||||
}
|
||||
@ -753,12 +864,12 @@ function campaignApp() {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Testing functions
|
||||
async testTermuxConnection() {
|
||||
this.testingTermux = true;
|
||||
try {
|
||||
const response = await fetch('/api/test/termux', { method: 'POST' });
|
||||
const response = await fetch('/api/test/termux', { method: 'POST', credentials: 'same-origin' });
|
||||
this.termuxTestResult = await response.json();
|
||||
} catch (error) {
|
||||
this.termuxTestResult = { success: false, error: error.message };
|
||||
@ -766,11 +877,11 @@ function campaignApp() {
|
||||
this.testingTermux = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async testAdbConnection() {
|
||||
this.testingAdb = true;
|
||||
try {
|
||||
const response = await fetch('/api/test/adb', { method: 'POST' });
|
||||
const response = await fetch('/api/test/adb', { method: 'POST', credentials: 'same-origin' });
|
||||
this.adbTestResult = await response.json();
|
||||
} catch (error) {
|
||||
this.adbTestResult = { success: false, error: error.message };
|
||||
@ -852,12 +963,10 @@ function campaignApp() {
|
||||
}
|
||||
},
|
||||
|
||||
// Conversation management
|
||||
// Conversation management - placeholder for tab switching
|
||||
loadConversations() {
|
||||
// Load enhanced conversations when tab is clicked
|
||||
if (typeof window.conversationManager !== 'undefined') {
|
||||
window.conversationManager.init();
|
||||
}
|
||||
// Conversations are loaded via Alpine.js conversationData() component
|
||||
// No additional initialization needed here
|
||||
},
|
||||
|
||||
// Utility functions
|
||||
@ -878,6 +987,32 @@ function campaignApp() {
|
||||
reminder: "Hi {name}! Quick reminder that we're meeting today at {time}. Looking forward to seeing you there!"
|
||||
};
|
||||
this.messageTemplate = templates[type] || '';
|
||||
},
|
||||
|
||||
// Logout function
|
||||
logout() {
|
||||
// Prevent multiple logout calls
|
||||
if (this.loggingOut) return;
|
||||
this.loggingOut = true;
|
||||
|
||||
// Clear all intervals to prevent further API calls
|
||||
this._intervals.forEach(id => clearInterval(id));
|
||||
this._intervals = [];
|
||||
|
||||
// Disconnect WebSocket if it exists
|
||||
if (globalSocket) {
|
||||
globalSocket.disconnect();
|
||||
globalSocket = null;
|
||||
}
|
||||
|
||||
// Fire logout request (don't wait for response)
|
||||
fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
}).catch(() => {}); // Ignore errors
|
||||
|
||||
// Immediately redirect to login
|
||||
window.location.replace('/login');
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -885,9 +1020,6 @@ function campaignApp() {
|
||||
// Enhanced Conversations Data Function
|
||||
function conversationData() {
|
||||
return {
|
||||
// Initialize enhanced conversation manager
|
||||
manager: null,
|
||||
|
||||
// State properties
|
||||
conversations: [],
|
||||
selectedConversation: null,
|
||||
@ -936,15 +1068,54 @@ function conversationData() {
|
||||
// Initialize
|
||||
async init() {
|
||||
await this.loadConversations();
|
||||
this.setupWebSocket();
|
||||
|
||||
// Setup WebSocket handlers using global socket
|
||||
// Wait for global socket to be available (max 5 seconds)
|
||||
await this.waitForSocket();
|
||||
},
|
||||
|
||||
// Wait for global socket to be initialized
|
||||
async waitForSocket(maxAttempts = 10) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (globalSocket) {
|
||||
this.setupWebSocketHandlers(globalSocket);
|
||||
console.log('✅ Conversations connected to WebSocket');
|
||||
return;
|
||||
}
|
||||
// Wait 500ms before trying again
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
console.warn('⚠️ Global WebSocket not available after timeout');
|
||||
},
|
||||
|
||||
// Setup WebSocket event handlers on global socket
|
||||
setupWebSocketHandlers(socket) {
|
||||
// Remove any existing listeners to prevent duplicates
|
||||
socket.off('new_message');
|
||||
socket.off('message_status_update');
|
||||
socket.off('conversation_update');
|
||||
|
||||
// Add conversation-specific listeners
|
||||
socket.on('new_message', (data) => {
|
||||
this.handleNewMessage(data);
|
||||
});
|
||||
|
||||
socket.on('message_status_update', (data) => {
|
||||
this.updateMessageStatus(data.message_id, data.status);
|
||||
});
|
||||
|
||||
socket.on('conversation_update', (data) => {
|
||||
this.handleConversationUpdate(data);
|
||||
});
|
||||
},
|
||||
|
||||
// Load conversations from API
|
||||
async loadConversations() {
|
||||
try {
|
||||
const response = await fetch('/api/conversations/enhanced/');
|
||||
const response = await fetch('/api/conversations/enhanced/', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
this.conversations = data.conversations || [];
|
||||
console.log('Loaded conversations:', this.conversations.length);
|
||||
@ -1105,13 +1276,14 @@ function conversationData() {
|
||||
|
||||
async syncAllConversations() {
|
||||
if (this.syncing) return;
|
||||
|
||||
|
||||
try {
|
||||
this.syncing = true;
|
||||
|
||||
|
||||
// Trigger full sync
|
||||
await fetch('/api/responses/sync', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
// Reload conversations
|
||||
@ -1129,37 +1301,6 @@ function conversationData() {
|
||||
}
|
||||
},
|
||||
|
||||
// Setup WebSocket for real-time updates
|
||||
setupWebSocket() {
|
||||
if (typeof io === 'undefined') {
|
||||
console.warn('Socket.IO not available, real-time updates disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const socket = io();
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to conversation WebSocket');
|
||||
});
|
||||
|
||||
socket.on('new_message', (data) => {
|
||||
this.handleNewMessage(data);
|
||||
});
|
||||
|
||||
socket.on('message_status_update', (data) => {
|
||||
this.updateMessageStatus(data.message_id, data.status);
|
||||
});
|
||||
|
||||
socket.on('conversation_update', (data) => {
|
||||
this.handleConversationUpdate(data);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error setting up WebSocket:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Handle real-time message updates
|
||||
handleNewMessage(messageData) {
|
||||
// Add to messages if it's for the current conversation
|
||||
@ -1,105 +1,217 @@
|
||||
// Minimal ListManager to integrate with dashboard
|
||||
class ListManager {
|
||||
constructor() {
|
||||
this.currentList = null;
|
||||
this.lists = [];
|
||||
this.init();
|
||||
}
|
||||
// SMS Campaign Manager - Lists Page JavaScript
|
||||
|
||||
async init() {
|
||||
await this.loadLists();
|
||||
this.setupListeners();
|
||||
}
|
||||
/**
|
||||
* Lists Alpine.js app
|
||||
*/
|
||||
function listsApp() {
|
||||
return {
|
||||
// List management
|
||||
savedLists: [],
|
||||
listUploadName: '',
|
||||
listUploadPreview: [],
|
||||
viewingList: null,
|
||||
viewingListContacts: [],
|
||||
|
||||
async loadLists() {
|
||||
try {
|
||||
const r = await fetch('/api/lists');
|
||||
const data = await r.json();
|
||||
if (data.success) {
|
||||
this.lists = data.lists;
|
||||
this.renderSelector();
|
||||
this.renderTable();
|
||||
/**
|
||||
* Initialize lists app
|
||||
*/
|
||||
async init() {
|
||||
await this.loadSavedLists();
|
||||
console.log('✅ Lists app initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Load saved contact lists
|
||||
*/
|
||||
async loadSavedLists() {
|
||||
try {
|
||||
const response = await fetch('/api/lists', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
this.savedLists = data.success ? data.lists : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load lists:', error);
|
||||
this.savedLists = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('loadLists', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderSelector() {
|
||||
const sel = document.getElementById('saved-lists-selector');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">-- Select a saved list --</option>';
|
||||
this.lists.forEach(l => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = l.id;
|
||||
opt.textContent = `${l.name} (${l.total_contacts} contacts)`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Handle CSV file upload for list creation
|
||||
*/
|
||||
async handleListUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
renderTable() {
|
||||
const container = document.getElementById('lists-table-container');
|
||||
if (!container) return;
|
||||
if (!this.lists || this.lists.length === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500">No saved lists</p>';
|
||||
return;
|
||||
}
|
||||
const rows = this.lists.map(l => `
|
||||
<tr data-id="${l.id}" class="border-b">
|
||||
<td class="py-2">${l.name}</td>
|
||||
<td class="py-2">${l.total_contacts}</td>
|
||||
<td class="py-2">${l.created_at || ''}</td>
|
||||
<td class="py-2">${l.last_used_at || 'Never'}</td>
|
||||
<td class="py-2">${l.usage_count || 0}</td>
|
||||
<td class="py-2">
|
||||
<button onclick="listManager.useList(${l.id})" class="px-2 py-1 bg-green-500 text-white text-xs rounded">Use</button>
|
||||
<button onclick="listManager.editList(${l.id})" class="px-2 py-1 bg-gray-200 text-xs rounded">Edit</button>
|
||||
<button onclick="listManager.deleteList(${l.id})" class="px-2 py-1 bg-red-500 text-white text-xs rounded">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
container.innerHTML = `<table class="w-full text-left"><thead><tr><th>Name</th><th>Contacts</th><th>Created</th><th>Last Used</th><th>Usage</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/csv/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
setupListeners() {
|
||||
const sel = document.getElementById('saved-lists-selector');
|
||||
if (sel) sel.addEventListener('change', async (e) => {
|
||||
const id = e.target.value;
|
||||
if (!id) return;
|
||||
const r = await fetch(`/api/lists/${id}`);
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
const contacts = d.list.contacts || [];
|
||||
// store globally for legacy usage
|
||||
window.campaignContacts = contacts;
|
||||
// notify the app that a saved list was loaded
|
||||
document.dispatchEvent(new CustomEvent('saved-list-loaded', { detail: { contacts: contacts, list: d.list } }));
|
||||
alert(`Loaded ${contacts.length} contacts`);
|
||||
if (data.success) {
|
||||
this.listUploadPreview = data.recipients.slice(0, 10);
|
||||
|
||||
// Auto-save the list if no custom name is provided
|
||||
if (!this.listUploadName.trim()) {
|
||||
await this.loadSavedLists();
|
||||
alert(`Contact list saved automatically as "${data.list_name}"`);
|
||||
this.listUploadPreview = [];
|
||||
event.target.value = ''; // Clear file input
|
||||
}
|
||||
} else {
|
||||
alert('Error uploading file: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
alert('Upload failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async useList(id) {
|
||||
await fetch(`/api/lists/${id}/use`, {method: 'POST'});
|
||||
const sel = document.getElementById('saved-lists-selector');
|
||||
if (sel) { sel.value = id; sel.dispatchEvent(new Event('change')); }
|
||||
}
|
||||
/**
|
||||
* Save list from preview
|
||||
*/
|
||||
async saveListFromPreview() {
|
||||
if (this.listUploadPreview.length === 0) {
|
||||
alert('No contacts to save');
|
||||
return;
|
||||
}
|
||||
|
||||
async editList(id) {
|
||||
// open list detail in new tab for now
|
||||
window.open(`/api/lists/${id}`, '_blank');
|
||||
}
|
||||
try {
|
||||
const listName = this.listUploadName.trim() ||
|
||||
`Custom_List_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '_')}`;
|
||||
|
||||
async deleteList(id) {
|
||||
if (!confirm('Delete list?')) return;
|
||||
const r = await fetch(`/api/lists/${id}`, {method: 'DELETE'});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
await this.loadLists();
|
||||
} else {
|
||||
alert('Delete failed');
|
||||
const response = await fetch('/api/lists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: listName,
|
||||
contacts: this.listUploadPreview,
|
||||
filename: 'custom_upload'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert(`Contact list saved as "${listName}"`);
|
||||
this.listUploadPreview = [];
|
||||
this.listUploadName = '';
|
||||
await this.loadSavedLists();
|
||||
} else {
|
||||
alert('Error saving list: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving list:', error);
|
||||
alert('Error saving list: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* View list contacts in modal
|
||||
*/
|
||||
async viewListContacts(list) {
|
||||
try {
|
||||
const response = await fetch(`/api/lists/${list.id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.viewingList = data.list;
|
||||
this.viewingListContacts = data.list.contacts || [];
|
||||
} else {
|
||||
alert('Error loading list details: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading list details:', error);
|
||||
alert('Error loading list details: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Use list for campaign (navigate to campaigns page with list selected)
|
||||
*/
|
||||
async useListForCampaign(list) {
|
||||
// Store list ID in localStorage for campaigns page to pick up
|
||||
localStorage.setItem('selectedListId', list.id);
|
||||
|
||||
// Navigate to campaigns page
|
||||
window.location.href = '/campaigns';
|
||||
|
||||
alert(`List "${list.name}" will be loaded for your campaign!`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Download list as CSV
|
||||
*/
|
||||
async downloadList(list) {
|
||||
try {
|
||||
const response = await fetch(`/api/lists/${list.id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.list.contacts) {
|
||||
// Convert to CSV
|
||||
const contacts = data.list.contacts;
|
||||
const headers = ['name', 'phone', 'email'];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...contacts.map(contact =>
|
||||
headers.map(header =>
|
||||
(contact[header] || '').toString().replace(/"/g, '""')
|
||||
).map(field => `"${field}"`).join(',')
|
||||
)
|
||||
].join('\n');
|
||||
|
||||
// Download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${list.name}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
alert('Error downloading list: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error downloading list:', error);
|
||||
alert('Error downloading list: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete contact list
|
||||
*/
|
||||
async deleteContactList(listId, listName) {
|
||||
if (!confirm(`Are you sure you want to delete the list "${listName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lists/${listId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('List deleted!');
|
||||
await this.loadSavedLists();
|
||||
} else {
|
||||
alert('Error deleting list: ' + result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting list:', error);
|
||||
alert('Error deleting list: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date - uses baseApp's formatDate
|
||||
*/
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let listManager = new ListManager();
|
||||
|
||||
174
src/static/js/templates.js
Normal file
174
src/static/js/templates.js
Normal file
@ -0,0 +1,174 @@
|
||||
// SMS Campaign Manager - Templates Page JavaScript
|
||||
|
||||
/**
|
||||
* Templates Alpine.js app
|
||||
*/
|
||||
function templatesApp() {
|
||||
return {
|
||||
// Template management
|
||||
savedTemplates: [],
|
||||
editingTemplate: null,
|
||||
templateForm: {
|
||||
name: '',
|
||||
content: '',
|
||||
description: '',
|
||||
category: 'general',
|
||||
is_favorite: 0
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize templates app
|
||||
*/
|
||||
async init() {
|
||||
await this.loadSavedTemplates();
|
||||
console.log('✅ Templates app initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Load saved templates
|
||||
*/
|
||||
async loadSavedTemplates() {
|
||||
try {
|
||||
const response = await fetch('/api/templates', { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
this.savedTemplates = data || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
this.savedTemplates = [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save new template or update existing
|
||||
*/
|
||||
async saveNewTemplate() {
|
||||
if (!this.templateForm.name || !this.templateForm.content) {
|
||||
alert('Please fill in template name and content');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = this.editingTemplate
|
||||
? `/api/templates/${this.editingTemplate.id}`
|
||||
: '/api/templates';
|
||||
const method = this.editingTemplate ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.templateForm)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert(this.editingTemplate ? 'Template updated!' : 'Template created!');
|
||||
await this.loadSavedTemplates();
|
||||
this.resetTemplateForm();
|
||||
} else {
|
||||
alert('Error: ' + result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving template:', error);
|
||||
alert('Error saving template: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load template for editing
|
||||
*/
|
||||
loadTemplateForEditing(template) {
|
||||
this.editingTemplate = template;
|
||||
this.templateForm = {
|
||||
name: template.name,
|
||||
content: template.template || template.content,
|
||||
description: template.description || '',
|
||||
category: template.category || 'general',
|
||||
is_favorite: template.is_favorite || 0
|
||||
};
|
||||
|
||||
// Scroll to form
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel editing template
|
||||
*/
|
||||
cancelEditTemplate() {
|
||||
this.resetTemplateForm();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset template form
|
||||
*/
|
||||
resetTemplateForm() {
|
||||
this.editingTemplate = null;
|
||||
this.templateForm = {
|
||||
name: '',
|
||||
content: '',
|
||||
description: '',
|
||||
category: 'general',
|
||||
is_favorite: 0
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete template
|
||||
*/
|
||||
async deleteTemplate(templateId, templateName) {
|
||||
if (!confirm(`Are you sure you want to delete the template "${templateName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${templateId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('Template deleted!');
|
||||
await this.loadSavedTemplates();
|
||||
} else {
|
||||
alert('Error deleting template: ' + result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting template:', error);
|
||||
alert('Error deleting template: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle template favorite status
|
||||
*/
|
||||
async toggleTemplateFavorite(template) {
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${template.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
is_favorite: template.is_favorite ? 0 : 1
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await this.loadSavedTemplates();
|
||||
} else {
|
||||
alert('Error updating template: ' + result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating template:', error);
|
||||
alert('Error updating template: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date - uses baseApp's formatDate
|
||||
*/
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
};
|
||||
}
|
||||
141
src/static/js/testing.js
Normal file
141
src/static/js/testing.js
Normal file
@ -0,0 +1,141 @@
|
||||
// SMS Campaign Manager - Testing Page JavaScript
|
||||
|
||||
/**
|
||||
* Testing Alpine.js app
|
||||
*/
|
||||
function testingApp() {
|
||||
return {
|
||||
// Phone IP - will be set from template
|
||||
phoneIP: '',
|
||||
phoneStatus: {},
|
||||
|
||||
// Testing variables
|
||||
testPhone: '',
|
||||
testMessage: 'Test message from SMS Campaign Manager',
|
||||
termuxTestResult: null,
|
||||
adbTestResult: null,
|
||||
testSmsResult: null,
|
||||
testingTermux: false,
|
||||
testingAdb: false,
|
||||
sendingTest: false,
|
||||
|
||||
// Database reset
|
||||
showResetConfirmation: false,
|
||||
resetConfirmText: '',
|
||||
resettingDatabase: false,
|
||||
resetResult: null,
|
||||
|
||||
/**
|
||||
* Initialize testing app
|
||||
*/
|
||||
async init() {
|
||||
console.log('✅ Testing app initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Test Termux API connection
|
||||
*/
|
||||
async testTermuxConnection() {
|
||||
this.testingTermux = true;
|
||||
try {
|
||||
const response = await fetch('/api/test/termux', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
this.termuxTestResult = await response.json();
|
||||
} catch (error) {
|
||||
this.termuxTestResult = { success: false, error: error.message };
|
||||
} finally {
|
||||
this.testingTermux = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Test ADB connection
|
||||
*/
|
||||
async testAdbConnection() {
|
||||
this.testingAdb = true;
|
||||
try {
|
||||
const response = await fetch('/api/test/adb', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
this.adbTestResult = await response.json();
|
||||
} catch (error) {
|
||||
this.adbTestResult = { success: false, error: error.message };
|
||||
} finally {
|
||||
this.testingAdb = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send test SMS
|
||||
* @param {string} method - 'termux', 'adb', or 'auto'
|
||||
*/
|
||||
async sendTestSms(method) {
|
||||
this.sendingTest = true;
|
||||
try {
|
||||
const response = await fetch('/api/test/sms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: this.testPhone,
|
||||
message: this.testMessage,
|
||||
method: method
|
||||
})
|
||||
});
|
||||
this.testSmsResult = await response.json();
|
||||
} catch (error) {
|
||||
this.testSmsResult = { success: false, error: error.message };
|
||||
} finally {
|
||||
this.sendingTest = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset database (DANGEROUS!)
|
||||
*/
|
||||
async resetDatabase() {
|
||||
if (this.resetConfirmText !== 'RESET') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resettingDatabase = true;
|
||||
this.resetResult = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/database/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
this.resetResult = result;
|
||||
|
||||
if (result.success) {
|
||||
// Close modal and clear form
|
||||
this.showResetConfirmation = false;
|
||||
this.resetConfirmText = '';
|
||||
|
||||
// Alert user
|
||||
alert('Database reset successfully! All data has been cleared.');
|
||||
}
|
||||
} catch (error) {
|
||||
this.resetResult = {
|
||||
success: false,
|
||||
message: `Error: ${error.message}`
|
||||
};
|
||||
} finally {
|
||||
this.resettingDatabase = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format time - uses baseApp's formatTime
|
||||
*/
|
||||
formatTime(dateStr) {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleTimeString();
|
||||
}
|
||||
};
|
||||
}
|
||||
139
src/templates/base.html
Normal file
139
src/templates/base.html
Normal file
@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}SMS Campaign Manager{% endblock %}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css?v={{ cache_version }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div x-data="baseApp" x-init="init()" x-cloak class="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<!-- Header with Connection Status -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800">📱 SMS Campaign Manager</h1>
|
||||
<p class="text-gray-600 mt-1">Homelab Campaign Management Interface</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<!-- Termux API Status -->
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-gray-600 mr-2 w-20">Termux:</span>
|
||||
<!-- Checking state (before first check completes) -->
|
||||
<span x-show="!phoneStatus.last_check"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 animate-pulse">
|
||||
⏳ Checking...
|
||||
</span>
|
||||
<!-- Online state -->
|
||||
<span x-show="phoneStatus.last_check && phoneStatus.termux_connected"
|
||||
@click="checkConnectionStatus()"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 cursor-pointer hover:bg-green-200 transition-colors"
|
||||
:title="'Connected - Last checked: ' + formatTime(phoneStatus.last_check) + '\nClick to refresh'">
|
||||
🟢 Online
|
||||
</span>
|
||||
<!-- Offline state -->
|
||||
<span x-show="phoneStatus.last_check && !phoneStatus.termux_connected"
|
||||
@click="checkConnectionStatus()"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 cursor-pointer hover:bg-red-200 transition-colors"
|
||||
:title="'Disconnected - Last checked: ' + formatTime(phoneStatus.last_check) + '\nClick to retry'">
|
||||
🔴 Offline
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ADB Status -->
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-gray-600 mr-2 w-20">ADB:</span>
|
||||
<!-- Checking state -->
|
||||
<span x-show="!phoneStatus.last_check"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 animate-pulse">
|
||||
⏳ Checking...
|
||||
</span>
|
||||
<!-- Online state -->
|
||||
<span x-show="phoneStatus.last_check && phoneStatus.adb_connected"
|
||||
@click="checkConnectionStatus()"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 cursor-pointer hover:bg-green-200 transition-colors"
|
||||
:title="'Connected - Last checked: ' + formatTime(phoneStatus.last_check) + '\nClick to refresh'">
|
||||
🟢 Online
|
||||
</span>
|
||||
<!-- Offline state -->
|
||||
<span x-show="phoneStatus.last_check && !phoneStatus.adb_connected"
|
||||
@click="checkConnectionStatus()"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 cursor-pointer hover:bg-red-200 transition-colors"
|
||||
:title="'Disconnected - Last checked: ' + formatTime(phoneStatus.last_check) + '\nClick to retry'">
|
||||
🔴 Offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout Link (plain HTML, no JavaScript needed) -->
|
||||
<a href="/api/auth/logout"
|
||||
class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition-colors flex items-center gap-2 no-underline">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="bg-white rounded-t-lg shadow-sm border-b">
|
||||
<nav class="flex space-x-1 p-1">
|
||||
{% set current_page = current_page|default('campaigns') %}
|
||||
<a href="/campaigns"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors {% if current_page == 'campaigns' %}bg-blue-500 text-white{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200{% endif %}">
|
||||
📋 Campaigns
|
||||
</a>
|
||||
<a href="/conversations"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors {% if current_page == 'conversations' %}bg-blue-500 text-white{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200{% endif %}">
|
||||
💬 Conversations
|
||||
</a>
|
||||
<a href="/templates"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors {% if current_page == 'templates' %}bg-blue-500 text-white{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200{% endif %}">
|
||||
📝 Templates
|
||||
</a>
|
||||
<a href="/lists"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors {% if current_page == 'lists' %}bg-blue-500 text-white{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200{% endif %}">
|
||||
📋 Contact Lists
|
||||
</a>
|
||||
<a href="/import-contacts"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors {% if current_page == 'import' %}bg-blue-500 text-white{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200{% endif %}">
|
||||
📱 Import from Phone
|
||||
</a>
|
||||
<a href="/testing"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors {% if current_page == 'testing' %}bg-blue-500 text-white{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200{% endif %}">
|
||||
🧪 System Testing
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="bg-white rounded-b-lg shadow-sm min-h-[600px]">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common Scripts -->
|
||||
<script src="/static/js/common.js?v={{ cache_version }}"></script>
|
||||
|
||||
<!-- Page-specific Scripts -->
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
<!-- Initialize phone IP from template -->
|
||||
<script>
|
||||
// Make phone IP available globally
|
||||
window.PHONE_IP = '{{ phone_ip }}';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('baseApp', () => {
|
||||
const app = baseApp();
|
||||
app.phoneIP = '{{ phone_ip }}';
|
||||
return app;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
189
src/templates/campaigns.html
Normal file
189
src/templates/campaigns.html
Normal file
@ -0,0 +1,189 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Campaigns - SMS Campaign Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="campaignsApp" x-init="init()" class="p-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Create Campaign Section -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h2 class="text-xl font-semibold mb-4">Create Campaign</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Campaign Name</label>
|
||||
<input type="text" x-model="campaignName"
|
||||
placeholder="e.g., Weekend Volunteer Outreach"
|
||||
class="w-full px-3 py-2 border rounded-lg">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-2">
|
||||
Use Saved Template
|
||||
<button @click="console.log('Available templates:', savedTemplates); console.log('Selected template ID:', selectedTemplate); console.log('Current message:', messageTemplate)"
|
||||
class="px-2 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300">Debug</button>
|
||||
</label>
|
||||
<select x-model="selectedTemplate" @change="loadTemplate($event.target.value)" class="w-full px-3 py-2 border rounded-lg mb-2">
|
||||
<option value="">-- Select a saved template --</option>
|
||||
<template x-for="template in savedTemplates" :key="template.id">
|
||||
<option :value="template.id" x-text="`${template.name} (${template.category})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div x-show="selectedTemplate && messageTemplate" class="text-sm text-green-600 mb-2 flex justify-between items-center">
|
||||
<span>✓ Template loaded successfully</span>
|
||||
<button @click="clearTemplate()" class="text-xs text-gray-500 hover:text-gray-700 underline">
|
||||
Clear Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Message Template <span class="text-gray-500">(Use {name}, {phone}, {date}, {time} for variables)</span>
|
||||
</label>
|
||||
<textarea x-model="messageTemplate"
|
||||
@input="onMessageTemplateChange()"
|
||||
placeholder="Hi {name}! Hope all is well. I am wondering if you got my last email..."
|
||||
class="w-full px-3 py-2 border rounded-lg h-24"></textarea>
|
||||
<div x-show="messageTemplate && messageTemplate.includes('{name}')" class="mt-2 p-2 bg-gray-100 rounded text-sm">
|
||||
<span class="font-medium text-gray-600">Preview:</span>
|
||||
<span x-text="messageTemplate.replace('{name}', 'John')"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Recipients CSV</label>
|
||||
<input type="file" @change="handleFileUpload($event)"
|
||||
accept=".csv"
|
||||
class="w-full px-3 py-2 border rounded-lg">
|
||||
<div x-show="uploadedFile" class="mt-2 text-sm text-green-600">
|
||||
✓ <span x-text="uploadedFile"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Preview Section -->
|
||||
<div x-show="contactsPreview.length > 0" class="border rounded-lg p-4 bg-blue-50">
|
||||
<h3 class="font-semibold text-blue-800 mb-2">📋 Contacts Preview</h3>
|
||||
<div class="text-sm text-blue-600 mb-2">
|
||||
Total: <span x-text="totalContacts"></span> contacts loaded
|
||||
</div>
|
||||
<div class="max-h-40 overflow-y-auto space-y-1">
|
||||
<template x-for="(contact, index) in contactsPreview" :key="`preview-${index}`">
|
||||
<div class="text-sm bg-white p-2 rounded border">
|
||||
<span class="font-medium" x-text="contact.name || 'No Name'"></span> -
|
||||
<span class="text-gray-600" x-text="contact.phone"></span>
|
||||
<div x-show="contact.preview_message" class="text-xs text-gray-500 mt-1">
|
||||
Preview: <span x-text="(contact.preview_message || '').substring(0, 50) + '...'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="totalContacts > contactsPreview.length" class="text-xs text-blue-500 mt-2">
|
||||
... and <span x-text="totalContacts - contactsPreview.length"></span> more contacts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Use Saved List</label>
|
||||
<select x-model="selectedList" @change="loadSavedList($event.target.value)" class="w-full px-3 py-2 border rounded-lg">
|
||||
<option value="">-- Select a saved list --</option>
|
||||
<template x-for="(list, index) in savedLists" :key="`list-${index}-${list.id || ''}`">
|
||||
<option :value="list.id" x-text="`${list.name} (${list.total_contacts || 0} contacts)`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button @click="saveTemplate()"
|
||||
class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600">
|
||||
Save Template
|
||||
</button>
|
||||
<button @click="testSMS()"
|
||||
class="bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600">
|
||||
Test SMS
|
||||
</button>
|
||||
<button @click="startCampaign()"
|
||||
:disabled="!campaignReady"
|
||||
class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 disabled:opacity-50">
|
||||
Start Campaign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campaign Status Section -->
|
||||
<div class="space-y-4">
|
||||
<!-- Analytics -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-semibold mb-3">Campaign Analytics</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600" x-text="analytics.total_sent || 0"></div>
|
||||
<div class="text-sm text-gray-600">Total Sent</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600" x-text="analytics.responses || 0"></div>
|
||||
<div class="text-sm text-gray-600">Responses</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-yellow-600" x-text="analytics.follow_ups || 0"></div>
|
||||
<div class="text-sm text-gray-600">Follow-ups Needed</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-purple-600" x-text="analytics.opt_outs || 0"></div>
|
||||
<div class="text-sm text-gray-600">Opt-outs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Types -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-semibold mb-3">Response Types</h3>
|
||||
<div x-show="!responseTypes.length" class="text-gray-500 text-sm">
|
||||
No responses yet
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<template x-for="type in responseTypes" :key="type.type">
|
||||
<div class="flex justify-between items-center">
|
||||
<span x-text="type.type" class="text-sm"></span>
|
||||
<span x-text="type.count" class="font-medium"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Campaigns -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-semibold mb-3">Recent Campaigns</h3>
|
||||
<div x-show="!recentCampaigns.length" class="text-gray-500 text-sm">
|
||||
No recent campaigns
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<template x-for="(campaign, index) in recentCampaigns" :key="`campaign-${index}-${campaign.id || ''}`">
|
||||
<div class="border-b pb-2">
|
||||
<div class="font-medium" x-text="campaign.name"></div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<span x-text="campaign.sent_count || 0"></span> sent •
|
||||
<span x-text="formatDate(campaign.created_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/campaigns.js?v={{ cache_version }}"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('campaignsApp', () => {
|
||||
const app = campaignsApp();
|
||||
app.phoneIP = window.PHONE_IP;
|
||||
return app;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
263
src/templates/conversations.html
Normal file
263
src/templates/conversations.html
Normal file
@ -0,0 +1,263 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Conversations - SMS Campaign Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="conversationsApp" x-init="init()" class="p-6">
|
||||
<div class="flex h-[calc(100vh-200px)]">
|
||||
|
||||
<!-- Left Panel: Conversation List -->
|
||||
<div class="w-1/3 border-r border-gray-200 overflow-hidden flex flex-col">
|
||||
|
||||
<!-- Search and Controls Header -->
|
||||
<div class="p-4 border-b border-gray-200 bg-white">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="conversationSearch"
|
||||
@input="searchConversations()"
|
||||
placeholder="Search conversations..."
|
||||
class="w-full pl-10 pr-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
@click="syncAllConversations()"
|
||||
class="p-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
title="Sync All Conversations"
|
||||
:disabled="syncing"
|
||||
>
|
||||
<svg class="w-4 h-4" :class="{'animate-spin': syncing}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
@click="setFilter('all')"
|
||||
:class="conversationFilter === 'all' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 hover:bg-gray-200'"
|
||||
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
@click="setFilter('unread')"
|
||||
:class="conversationFilter === 'unread' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 hover:bg-gray-200'"
|
||||
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
>
|
||||
Unread
|
||||
<span x-show="unreadCount > 0" x-text="unreadCount"
|
||||
class="ml-1 bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="setFilter('starred')"
|
||||
:class="conversationFilter === 'starred' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 hover:bg-gray-200'"
|
||||
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
>
|
||||
Starred
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation List -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="divide-y divide-gray-100">
|
||||
<template x-for="conversation in filteredConversations" :key="conversation.phone">
|
||||
<div
|
||||
@click="selectConversation(conversation.phone)"
|
||||
:class="selectedConversation?.phone === conversation.phone ? 'bg-blue-50 border-r-2 border-blue-500' : 'hover:bg-gray-50'"
|
||||
class="p-4 cursor-pointer transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Avatar -->
|
||||
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center flex-shrink-0 text-white font-semibold">
|
||||
<span x-text="getInitials(conversation)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h4 class="font-semibold text-gray-900 truncate text-sm"
|
||||
x-text="conversation.contact_name || formatPhone(conversation.phone)">
|
||||
</h4>
|
||||
<span class="text-xs text-gray-500" x-text="formatTime(conversation.last_message_time)"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-gray-600 truncate"
|
||||
x-text="conversation.message_count + ' messages'"></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<!-- Star Icon -->
|
||||
<svg x-show="conversation.is_starred" class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Unread Badge -->
|
||||
<span x-show="conversation.unread_count > 0"
|
||||
x-text="conversation.unread_count"
|
||||
class="bg-red-500 text-white text-xs rounded-full px-2 py-0.5 min-w-[18px] text-center">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="filteredConversations.length === 0" class="p-8 text-center">
|
||||
<div class="text-gray-400 mb-2">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-500">No conversations found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Message View -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<!-- Chat Header -->
|
||||
<div x-show="selectedConversation" class="border-b border-gray-200 p-4 bg-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Avatar -->
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-semibold text-sm">
|
||||
<span x-text="selectedConversation ? getInitials(selectedConversation) : ''"></span>
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900"
|
||||
x-text="selectedConversation?.contact_name || formatPhone(selectedConversation?.phone)">
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500" x-text="formatPhone(selectedConversation?.phone)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="toggleStar(selectedConversation?.phone)"
|
||||
class="p-2 rounded-full hover:bg-gray-100 transition-colors"
|
||||
:class="selectedConversation?.is_starred ? 'text-yellow-500' : 'text-gray-400'"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="syncConversation(selectedConversation?.phone)"
|
||||
class="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors"
|
||||
:disabled="syncing"
|
||||
>
|
||||
<svg class="w-5 h-5" :class="{'animate-spin': syncing}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Area -->
|
||||
<div class="flex-1 overflow-y-auto p-4 bg-gray-50" x-ref="messagesContainer">
|
||||
<div x-show="!selectedConversation" class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Select a conversation</h3>
|
||||
<p class="text-gray-500">Choose a conversation from the left to start messaging</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message List -->
|
||||
<div x-show="selectedConversation" class="space-y-4">
|
||||
<template x-for="message in messages" :key="message.id">
|
||||
<div :class="message.direction === 'outbound' ? 'flex justify-end' : 'flex justify-start'">
|
||||
<div :class="message.direction === 'outbound'
|
||||
? 'bg-blue-500 text-white max-w-xs lg:max-w-md rounded-l-2xl rounded-br-2xl'
|
||||
: 'bg-white border max-w-xs lg:max-w-md rounded-r-2xl rounded-bl-2xl'"
|
||||
class="px-4 py-2 shadow-sm">
|
||||
<p class="text-sm" x-text="message.message"></p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span :class="message.direction === 'outbound' ? 'text-blue-100' : 'text-gray-500'"
|
||||
class="text-xs" x-text="formatMessageTime(message.sent_at || message.timestamp)">
|
||||
</span>
|
||||
<span x-show="message.direction === 'outbound'"
|
||||
:class="getStatusColor(message.status)"
|
||||
class="text-xs ml-2" x-text="getStatusIcon(message.status)">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Load More Messages Button -->
|
||||
<div x-show="hasMoreMessages && !loadingMessages" class="text-center py-2">
|
||||
<button
|
||||
@click="loadMoreMessages()"
|
||||
class="text-blue-500 hover:text-blue-600 text-sm font-medium"
|
||||
>
|
||||
Load older messages
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Messages -->
|
||||
<div x-show="loadingMessages" class="text-center py-2">
|
||||
<span class="text-gray-500 text-sm">Loading messages...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Input -->
|
||||
<div x-show="selectedConversation" class="border-t border-gray-200 p-4 bg-white">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
x-model="newMessage"
|
||||
@keydown.enter.prevent="sendMessage()"
|
||||
@keydown.shift.enter="newMessage += '\n'"
|
||||
placeholder="Type a message..."
|
||||
class="w-full resize-none border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
rows="1"
|
||||
x-ref="messageInput"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
@click="sendMessage()"
|
||||
:disabled="!newMessage.trim() || sendingMessage"
|
||||
class="p-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/conversations.js?v={{ cache_version }}"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('conversationsApp', () => conversationsApp());
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -7,7 +7,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css">
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css?v={{ cache_version }}">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div x-data="campaignApp" x-init="init()" x-cloak class="container mx-auto px-4 py-8 max-w-7xl">
|
||||
@ -18,28 +18,62 @@
|
||||
<h1 class="text-3xl font-bold text-gray-800">📱 SMS Campaign Manager</h1>
|
||||
<p class="text-gray-600 mt-1">Homelab Campaign Management Interface</p>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<!-- Termux API Status -->
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-gray-600 mr-2 w-20">Termux:</span>
|
||||
<span x-show="phoneStatus.termux_connected" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
🟢 Online
|
||||
</span>
|
||||
<span x-show="!phoneStatus.termux_connected" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
🔴 Offline
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ADB Status -->
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-gray-600 mr-2 w-20">ADB:</span>
|
||||
<span x-show="phoneStatus.adb_connected" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
🟢 Online
|
||||
</span>
|
||||
<span x-show="!phoneStatus.adb_connected" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
🔴 Offline
|
||||
</span>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<!-- Termux API Status -->
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-gray-600 mr-2 w-20">Termux:</span>
|
||||
<!-- Checking state (before first check completes) -->
|
||||
<span x-show="!phoneStatus.last_check"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 animate-pulse">
|
||||
⏳ Checking...
|
||||
</span>
|
||||
<!-- Online state -->
|
||||
<span x-show="phoneStatus.last_check && phoneStatus.termux_connected"
|
||||
@click="checkConnectionStatus()"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 cursor-pointer hover:bg-green-200 transition-colors"
|
||||
:title="'Connected - Last checked: ' + formatTime(phoneStatus.last_check) + '\nClick to refresh'">
|
||||
🟢 Online
|
||||
</span>
|
||||
<!-- Offline state -->
|
||||
<span x-show="phoneStatus.last_check && !phoneStatus.termux_connected"
|
||||
@click="checkConnectionStatus()"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 cursor-pointer hover:bg-red-200 transition-colors"
|
||||
:title="'Disconnected - Last checked: ' + formatTime(phoneStatus.last_check) + '\nClick to retry'">
|
||||
🔴 Offline
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ADB Status -->
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-gray-600 mr-2 w-20">ADB:</span>
|
||||
<!-- Checking state -->
|
||||
<span x-show="!phoneStatus.last_check"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 animate-pulse">
|
||||
⏳ Checking...
|
||||
</span>
|
||||
<!-- Online state -->
|
||||
<span x-show="phoneStatus.last_check && phoneStatus.adb_connected"
|
||||
@click="checkConnectionStatus()"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 cursor-pointer hover:bg-green-200 transition-colors"
|
||||
:title="'Connected - Last checked: ' + formatTime(phoneStatus.last_check) + '\nClick to refresh'">
|
||||
🟢 Online
|
||||
</span>
|
||||
<!-- Offline state -->
|
||||
<span x-show="phoneStatus.last_check && !phoneStatus.adb_connected"
|
||||
@click="checkConnectionStatus()"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 cursor-pointer hover:bg-red-200 transition-colors"
|
||||
:title="'Disconnected - Last checked: ' + formatTime(phoneStatus.last_check) + '\nClick to retry'">
|
||||
🔴 Offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout Link (plain HTML, no JavaScript needed) -->
|
||||
<a href="/api/auth/logout"
|
||||
class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition-colors flex items-center gap-2 no-underline">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -62,11 +96,15 @@
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
📝 Templates
|
||||
</button>
|
||||
<button @click="activeTab = 'lists'; loadSavedLists()"
|
||||
<button @click="activeTab = 'lists'; loadSavedLists()"
|
||||
:class="activeTab === 'lists' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
📋 Contact Lists
|
||||
</button>
|
||||
<button onclick="window.location.href='/import-contacts'"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200">
|
||||
📱 Import from Phone
|
||||
</button>
|
||||
<button @click="activeTab = 'testing'"
|
||||
:class="activeTab === 'testing' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
@ -913,11 +951,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load JavaScript files -->
|
||||
<script src="/static/js/dashboard.js?v=2025082505"></script>
|
||||
<script src="/static/js/lists.js?v=2025082505"></script>
|
||||
<script src="/static/js/conversations_enhanced.js?v=2025082505"></script>
|
||||
<script src="/static/js/rcs-gap-detector.js?v=2025082505"></script>
|
||||
<!-- Load JavaScript files (cache_version auto-updates on container restart) -->
|
||||
<script src="/static/js/dashboard.js?v={{ cache_version }}"></script>
|
||||
<script src="/static/js/lists.js?v={{ cache_version }}"></script>
|
||||
<script src="/static/js/conversations_enhanced.js?v={{ cache_version }}"></script>
|
||||
<script src="/static/js/rcs-gap-detector.js?v={{ cache_version }}"></script>
|
||||
<script>
|
||||
// Initialize phone IP from template
|
||||
document.addEventListener('alpine:init', () => {
|
||||
737
src/templates/import_contacts.html
Normal file
737
src/templates/import_contacts.html
Normal file
@ -0,0 +1,737 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Import Phone Contacts - SMS Campaign Manager{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css?v={{ cache_version }}">
|
||||
<style>
|
||||
.import-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-section h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-filter-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.contacts-table-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.contacts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.contacts-table thead {
|
||||
background: #f9fafb;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.contacts-table th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.contacts-table tbody tr {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.contacts-table tbody tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.contacts-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-new {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.status-existing {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-conflict {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid #f3f4f6;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bg-white rounded-b-lg shadow-sm p-6">
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6" id="statsRow">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-600 mb-1">Total on Phone</div>
|
||||
<div class="text-2xl font-bold text-gray-800" id="totalCount">--</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-600 mb-1">New Contacts</div>
|
||||
<div class="text-2xl font-bold text-blue-600" id="newCount">--</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-600 mb-1">Already in DB</div>
|
||||
<div class="text-2xl font-bold text-green-600" id="existingCount">--</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-600 mb-1">Conflicts</div>
|
||||
<div class="text-2xl font-bold text-red-600" id="conflictsCount">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
id="searchInput"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Search contacts by name or number..."
|
||||
>
|
||||
<div class="flex gap-2 mt-4 flex-wrap">
|
||||
<button class="px-4 py-2 rounded-lg font-medium transition-colors bg-blue-500 text-white" data-filter="all">All Contacts</button>
|
||||
<button class="px-4 py-2 rounded-lg font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200" data-filter="new">New Only</button>
|
||||
<button class="px-4 py-2 rounded-lg font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200" data-filter="existing">Existing Only</button>
|
||||
<button class="px-4 py-2 rounded-lg font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200" data-filter="conflicts">Conflicts Only</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts Table -->
|
||||
<div class="bg-gray-50 rounded-lg overflow-hidden border border-gray-200">
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-200">
|
||||
<h3 class="font-semibold text-gray-800">
|
||||
<input type="checkbox" id="selectAll" class="mr-2">
|
||||
Select All (<span id="selectedCount">0</span> selected)
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed" id="fetchContactsBtn">
|
||||
<span id="fetchBtnText">Fetch from Phone</span>
|
||||
<span id="fetchBtnSpinner" class="loading-spinner" style="display: none;"></span>
|
||||
</button>
|
||||
<button class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed" id="importSelectedBtn" disabled>Import Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto" style="max-height: 600px; overflow-y: auto;">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b-2 border-gray-200 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase" style="width: 40px;"></th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">Phone Number</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="contactsTableBody" class="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-8 text-center text-gray-500">
|
||||
Click "Fetch from Phone" to load contacts
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Import Modal -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50" id="importModal">
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h2 class="text-2xl font-bold mb-4">Import Contacts</h2>
|
||||
<form id="importForm">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Import to:</label>
|
||||
<select id="importListSelect" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Create New List</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 hidden" id="newListNameGroup">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">New List Name: <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="newListName" placeholder="e.g., Phone Contacts - January 2025" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" id="skipDuplicates" checked class="rounded">
|
||||
<span class="text-sm text-gray-700">Skip duplicate phone numbers</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" id="updateConflicts" class="rounded">
|
||||
<span class="text-sm text-gray-700">Update existing contacts with new names</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">Import</button>
|
||||
<button type="button" class="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors" onclick="closeImportModal()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let allContacts = [];
|
||||
let filteredContacts = [];
|
||||
let selectedContacts = new Set();
|
||||
let currentFilter = 'all';
|
||||
let duplicateAnalysis = null;
|
||||
|
||||
// Check phone status on load
|
||||
async function checkPhoneStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/phone/status', { credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
|
||||
const termuxStatus = document.getElementById('termuxStatus');
|
||||
const adbStatus = document.getElementById('adbStatus');
|
||||
|
||||
if (data.termux_connected) {
|
||||
termuxStatus.className = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800';
|
||||
termuxStatus.textContent = '🟢 Online';
|
||||
} else {
|
||||
termuxStatus.className = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800';
|
||||
termuxStatus.textContent = '🔴 Offline';
|
||||
}
|
||||
|
||||
if (data.adb_connected) {
|
||||
adbStatus.className = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800';
|
||||
adbStatus.textContent = '🟢 Online';
|
||||
} else {
|
||||
adbStatus.className = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800';
|
||||
adbStatus.textContent = '🔴 Offline';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check phone status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check status on page load
|
||||
checkPhoneStatus();
|
||||
|
||||
// Fetch contacts from phone
|
||||
document.getElementById('fetchContactsBtn').addEventListener('click', async function() {
|
||||
const btn = this;
|
||||
const btnText = document.getElementById('fetchBtnText');
|
||||
const btnSpinner = document.getElementById('fetchBtnSpinner');
|
||||
|
||||
btnText.style.display = 'none';
|
||||
btnSpinner.style.display = 'inline-block';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contacts/fetch-from-phone', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
allContacts = data.contacts || [];
|
||||
duplicateAnalysis = data.duplicate_analysis;
|
||||
|
||||
// Update stats
|
||||
document.getElementById('totalCount').textContent = data.total_count;
|
||||
document.getElementById('newCount').textContent = data.new_count;
|
||||
document.getElementById('existingCount').textContent = data.existing_count;
|
||||
document.getElementById('conflictsCount').textContent = data.conflicts_count;
|
||||
|
||||
// Enhance contacts with status
|
||||
allContacts = allContacts.map(contact => {
|
||||
const normalized = normalizePhone(contact.number || contact.phone);
|
||||
let status = 'new';
|
||||
|
||||
if (duplicateAnalysis.existing.some(c => normalizePhone(c.phone) === normalized)) {
|
||||
status = 'existing';
|
||||
} else if (duplicateAnalysis.conflicts.some(c => normalizePhone(c.phone) === normalized)) {
|
||||
status = 'conflict';
|
||||
}
|
||||
|
||||
return { ...contact, status, normalized };
|
||||
});
|
||||
|
||||
applyFilter();
|
||||
} else {
|
||||
alert('Error: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to fetch contacts: ' + error.message);
|
||||
} finally {
|
||||
btnText.style.display = 'inline';
|
||||
btnSpinner.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter buttons
|
||||
document.querySelectorAll('[data-filter]').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
// Remove active state from all buttons
|
||||
document.querySelectorAll('[data-filter]').forEach(b => {
|
||||
b.classList.remove('bg-blue-500', 'text-white');
|
||||
b.classList.add('bg-gray-100', 'text-gray-700', 'hover:bg-gray-200');
|
||||
});
|
||||
// Add active state to clicked button
|
||||
this.classList.remove('bg-gray-100', 'text-gray-700', 'hover:bg-gray-200');
|
||||
this.classList.add('bg-blue-500', 'text-white');
|
||||
currentFilter = this.dataset.filter;
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
|
||||
// Search input
|
||||
document.getElementById('searchInput').addEventListener('input', function(e) {
|
||||
applyFilter();
|
||||
});
|
||||
|
||||
// Select all
|
||||
document.getElementById('selectAll').addEventListener('change', function(e) {
|
||||
const checked = e.target.checked;
|
||||
filteredContacts.forEach(contact => {
|
||||
const key = normalizePhone(contact.number || contact.phone);
|
||||
if (checked) {
|
||||
selectedContacts.add(key);
|
||||
} else {
|
||||
selectedContacts.delete(key);
|
||||
}
|
||||
});
|
||||
renderTable();
|
||||
});
|
||||
|
||||
// Import selected button
|
||||
document.getElementById('importSelectedBtn').addEventListener('click', function() {
|
||||
if (selectedContacts.size === 0) {
|
||||
alert('Please select at least one contact');
|
||||
return;
|
||||
}
|
||||
showImportModal();
|
||||
});
|
||||
|
||||
// Import form
|
||||
document.getElementById('importForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
await performImport();
|
||||
});
|
||||
|
||||
// List select change
|
||||
document.getElementById('importListSelect').addEventListener('change', function(e) {
|
||||
const newListGroup = document.getElementById('newListNameGroup');
|
||||
if (e.target.value === '') {
|
||||
newListGroup.classList.remove('hidden');
|
||||
} else {
|
||||
newListGroup.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function normalizePhone(phone) {
|
||||
if (!phone) return '';
|
||||
return phone.replace(/[^\d+]/g, '');
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
filteredContacts = allContacts.filter(contact => {
|
||||
// Apply status filter
|
||||
if (currentFilter !== 'all' && contact.status !== currentFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm) {
|
||||
const name = (contact.name || '').toLowerCase();
|
||||
const phone = (contact.number || contact.phone || '').toLowerCase();
|
||||
if (!name.includes(searchTerm) && !phone.includes(searchTerm)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('contactsTableBody');
|
||||
const selectedCountEl = document.getElementById('selectedCount');
|
||||
const importBtn = document.getElementById('importSelectedBtn');
|
||||
|
||||
if (filteredContacts.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-8 text-center text-gray-500">
|
||||
No contacts found
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
importBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filteredContacts.map(contact => {
|
||||
const phone = contact.number || contact.phone || '';
|
||||
const normalized = normalizePhone(phone);
|
||||
const isSelected = selectedContacts.has(normalized);
|
||||
|
||||
let statusBadge = '';
|
||||
if (contact.status === 'new') {
|
||||
statusBadge = '<span class="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">New</span>';
|
||||
} else if (contact.status === 'existing') {
|
||||
statusBadge = '<span class="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">Existing</span>';
|
||||
} else if (contact.status === 'conflict') {
|
||||
statusBadge = '<span class="px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800">Conflict</span>';
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="contact-checkbox"
|
||||
data-phone="${normalized}"
|
||||
${isSelected ? 'checked' : ''}
|
||||
>
|
||||
</td>
|
||||
<td class="px-4 py-3">${contact.name || '<em class="text-gray-400">No name</em>'}</td>
|
||||
<td class="px-4 py-3">${phone}</td>
|
||||
<td class="px-4 py-3">${statusBadge}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add checkbox listeners
|
||||
tbody.querySelectorAll('.contact-checkbox').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function(e) {
|
||||
const phone = e.target.dataset.phone;
|
||||
if (e.target.checked) {
|
||||
selectedContacts.add(phone);
|
||||
} else {
|
||||
selectedContacts.delete(phone);
|
||||
}
|
||||
updateSelectionUI();
|
||||
});
|
||||
});
|
||||
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function updateSelectionUI() {
|
||||
const selectedCountEl = document.getElementById('selectedCount');
|
||||
const importBtn = document.getElementById('importSelectedBtn');
|
||||
const selectAllCheckbox = document.getElementById('selectAll');
|
||||
|
||||
selectedCountEl.textContent = selectedContacts.size;
|
||||
importBtn.disabled = selectedContacts.size === 0;
|
||||
|
||||
// Update select all checkbox state
|
||||
const allFiltered = filteredContacts.every(c =>
|
||||
selectedContacts.has(normalizePhone(c.number || c.phone))
|
||||
);
|
||||
selectAllCheckbox.checked = allFiltered && filteredContacts.length > 0;
|
||||
}
|
||||
|
||||
function showImportModal() {
|
||||
const modal = document.getElementById('importModal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
|
||||
// Load contact lists
|
||||
loadContactLists();
|
||||
|
||||
// Show new list name field by default since "Create New List" is selected
|
||||
const newListGroup = document.getElementById('newListNameGroup');
|
||||
const selectValue = document.getElementById('importListSelect').value;
|
||||
if (selectValue === '') {
|
||||
newListGroup.classList.remove('hidden');
|
||||
} else {
|
||||
newListGroup.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
const modal = document.getElementById('importModal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
|
||||
async function loadContactLists() {
|
||||
try {
|
||||
const response = await fetch('/api/database/contact-lists', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const select = document.getElementById('importListSelect');
|
||||
select.innerHTML = '<option value="">Create New List</option>';
|
||||
|
||||
if (data.success && data.lists) {
|
||||
data.lists.forEach(list => {
|
||||
const option = document.createElement('option');
|
||||
option.value = list.id;
|
||||
option.textContent = `${list.name} (${list.total_contacts} contacts)`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load contact lists:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function performImport() {
|
||||
const listId = document.getElementById('importListSelect').value;
|
||||
const listName = document.getElementById('newListName').value;
|
||||
const skipDuplicates = document.getElementById('skipDuplicates').checked;
|
||||
const updateConflicts = document.getElementById('updateConflicts').checked;
|
||||
|
||||
// Validate
|
||||
if (!listId && !listName) {
|
||||
alert('Please provide a name for the new list');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get selected contacts
|
||||
const selectedContactsData = allContacts.filter(c =>
|
||||
selectedContacts.has(normalizePhone(c.number || c.phone))
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contacts/import-from-phone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
contacts: selectedContactsData,
|
||||
list_id: listId || null,
|
||||
list_name: listName || null,
|
||||
skip_duplicates: skipDuplicates,
|
||||
update_conflicts: updateConflicts
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
closeImportModal();
|
||||
// Clear selection
|
||||
selectedContacts.clear();
|
||||
renderTable();
|
||||
} else {
|
||||
alert('Error: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Import failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
146
src/templates/lists.html
Normal file
146
src/templates/lists.html
Normal file
@ -0,0 +1,146 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Contact Lists - SMS Campaign Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="listsApp" x-init="init()" class="p-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6">📋 Contact Lists</h2>
|
||||
|
||||
<!-- Upload CSV Section -->
|
||||
<div class="mb-8 p-6 border rounded-lg bg-green-50">
|
||||
<h3 class="font-semibold text-gray-700 mb-4">Upload New Contact List</h3>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">CSV File</label>
|
||||
<input type="file" @change="handleListUpload($event)"
|
||||
accept=".csv"
|
||||
class="w-full px-3 py-2 border rounded-lg">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">List Name (optional)</label>
|
||||
<input type="text" x-model="listUploadName"
|
||||
placeholder="Leave blank for auto-generated name"
|
||||
class="w-full px-3 py-2 border rounded-lg">
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="listUploadPreview.length > 0" class="mt-4 p-4 bg-white rounded border">
|
||||
<h4 class="font-medium mb-2">Preview (<span x-text="listUploadPreview.length"></span> contacts)</h4>
|
||||
<div class="max-h-40 overflow-y-auto space-y-2">
|
||||
<template x-for="(contact, index) in listUploadPreview" :key="`preview-${index}`">
|
||||
<div class="text-sm bg-gray-50 p-2 rounded border flex justify-between">
|
||||
<div>
|
||||
<span class="font-medium" x-text="contact.name || 'No Name'"></span>
|
||||
<span class="text-gray-600 ml-2" x-text="contact.phone"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button @click="saveListFromPreview()"
|
||||
class="mt-3 bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
|
||||
Save List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lists Display -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-semibold text-gray-700">Saved Lists</h3>
|
||||
<button @click="loadSavedLists()" class="text-blue-600 hover:text-blue-800 text-sm">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="!savedLists || savedLists.length === 0" class="text-gray-500 text-center py-8 border rounded-lg">
|
||||
No contact lists saved yet. Upload a CSV file above to get started!
|
||||
</div>
|
||||
|
||||
<div x-show="savedLists && savedLists.length > 0" class="grid grid-cols-1 gap-4">
|
||||
<template x-for="list in savedLists" :key="list.id">
|
||||
<div class="border rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="font-medium text-gray-800" x-text="list.name"></h4>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
x-text="`${list.total_contacts} contacts`"></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600" x-text="`From: ${list.original_filename}`"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center text-xs text-gray-500 mb-3">
|
||||
<span>Used <span x-text="list.usage_count || 0"></span> times</span>
|
||||
<span>Created <span x-text="formatDate(list.created_at)"></span></span>
|
||||
<span x-show="list.last_used_at">Last used: <span x-text="formatDate(list.last_used_at)"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button @click="viewListContacts(list)"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm px-3 py-1 border border-blue-200 rounded hover:bg-blue-50">
|
||||
👁 View
|
||||
</button>
|
||||
<button @click="useListForCampaign(list)"
|
||||
class="text-green-600 hover:text-green-800 text-sm px-3 py-1 border border-green-200 rounded hover:bg-green-50">
|
||||
📤 Use for Campaign
|
||||
</button>
|
||||
<button @click="downloadList(list)"
|
||||
class="text-purple-600 hover:text-purple-800 text-sm px-3 py-1 border border-purple-200 rounded hover:bg-purple-50">
|
||||
💾 Download
|
||||
</button>
|
||||
<button @click="deleteContactList(list.id, list.name)"
|
||||
class="text-red-600 hover:text-red-800 text-sm px-3 py-1 border border-red-200 rounded hover:bg-red-50">
|
||||
🗑 Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- List Detail Modal -->
|
||||
<div x-show="viewingList" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 max-w-4xl max-h-[80vh] w-full mx-4 overflow-hidden flex flex-col">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<span x-text="viewingList ? viewingList.name : ''"></span>
|
||||
<span class="text-sm font-normal text-gray-600">
|
||||
(<span x-text="viewingList ? viewingList.total_contacts : 0"></span> contacts)
|
||||
</span>
|
||||
</h3>
|
||||
<button @click="viewingList = null" class="text-gray-500 hover:text-gray-700">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<template x-for="(contact, index) in viewingListContacts" :key="`contact-${index}`">
|
||||
<div class="border rounded p-3 bg-gray-50">
|
||||
<div class="font-medium" x-text="contact.name || 'No Name'"></div>
|
||||
<div class="text-gray-600" x-text="contact.phone"></div>
|
||||
<div x-show="contact.email" class="text-gray-500 text-sm" x-text="contact.email"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t flex justify-end gap-2">
|
||||
<button @click="viewingList = null"
|
||||
class="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/lists.js?v={{ cache_version }}"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('listsApp', () => listsApp());
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
170
src/templates/login.html
Normal file
170
src/templates/login.html
Normal file
@ -0,0 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - SMS Campaign Manager</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-500 to-purple-600 min-h-screen flex items-center justify-center">
|
||||
<div class="bg-white rounded-lg shadow-2xl p-8 w-full max-w-md">
|
||||
<!-- Logo/Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-5xl mb-2">📱</div>
|
||||
<h1 class="text-3xl font-bold text-gray-800">SMS Campaign Manager</h1>
|
||||
<p class="text-gray-600 mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div id="error-message" class="hidden mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span id="error-text" class="text-red-700 text-sm"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div id="success-message" class="hidden mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span id="success-text" class="text-green-700 text-sm"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form id="login-form" class="space-y-6">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||
placeholder="Enter your username"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||
placeholder="Enter your password"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" id="remember" class="rounded border-gray-300 text-blue-500 focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-600">Remember me</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="login-button"
|
||||
class="w-full bg-blue-500 text-white py-3 rounded-lg font-semibold hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition transform active:scale-95"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-6 text-center text-sm text-gray-600">
|
||||
<p>Secure authentication with session management</p>
|
||||
<p class="mt-2">🔒 Protected by API key and session tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const loginButton = document.getElementById('login-button');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const errorText = document.getElementById('error-text');
|
||||
const successMessage = document.getElementById('success-message');
|
||||
const successText = document.getElementById('success-text');
|
||||
|
||||
function showError(message) {
|
||||
errorText.textContent = message;
|
||||
errorMessage.classList.remove('hidden');
|
||||
successMessage.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
successText.textContent = message;
|
||||
successMessage.classList.remove('hidden');
|
||||
errorMessage.classList.add('hidden');
|
||||
}
|
||||
|
||||
function hideMessages() {
|
||||
errorMessage.classList.add('hidden');
|
||||
successMessage.classList.add('hidden');
|
||||
}
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideMessages();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
// Disable button
|
||||
loginButton.disabled = true;
|
||||
loginButton.textContent = 'Signing in...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccess('Login successful! Redirecting...');
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = data.redirect || '/';
|
||||
}, 500);
|
||||
} else {
|
||||
showError(data.error || 'Login failed');
|
||||
loginButton.disabled = false;
|
||||
loginButton.textContent = 'Sign In';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
showError('An error occurred. Please try again.');
|
||||
loginButton.disabled = false;
|
||||
loginButton.textContent = 'Sign In';
|
||||
}
|
||||
});
|
||||
|
||||
// Check if already logged in
|
||||
fetch('/api/auth/status')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.authenticated) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Status check error:', err));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
119
src/templates/templates.html
Normal file
119
src/templates/templates.html
Normal file
@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Templates - SMS Campaign Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="templatesApp" x-init="init()" class="p-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6">📝 Message Templates</h2>
|
||||
|
||||
<!-- Create/Edit Template Form -->
|
||||
<div class="mb-8 p-6 border rounded-lg bg-blue-50">
|
||||
<h3 class="font-semibold text-gray-700 mb-4">
|
||||
<span x-show="!editingTemplate">Create New Template</span>
|
||||
<span x-show="editingTemplate">Edit Template</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Template Name</label>
|
||||
<input type="text" x-model="templateForm.name"
|
||||
placeholder="e.g., Volunteer Check-In"
|
||||
class="w-full px-3 py-2 border rounded-lg">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select x-model="templateForm.category" class="w-full px-3 py-2 border rounded-lg">
|
||||
<option value="general">General</option>
|
||||
<option value="volunteer">Volunteer</option>
|
||||
<option value="reminder">Reminder</option>
|
||||
<option value="gratitude">Gratitude</option>
|
||||
<option value="followup">Follow-up</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<input type="text" x-model="templateForm.description"
|
||||
placeholder="Brief description of when to use this template"
|
||||
class="w-full px-3 py-2 border rounded-lg">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Message Template <span class="text-gray-500">(Use {name} for personalization)</span>
|
||||
</label>
|
||||
<textarea x-model="templateForm.content"
|
||||
placeholder="Hi {name}! Your message here..."
|
||||
class="w-full px-3 py-2 border rounded-lg h-24"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="saveNewTemplate()"
|
||||
:disabled="!templateForm.name || !templateForm.content"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50">
|
||||
<span x-show="!editingTemplate">Save Template</span>
|
||||
<span x-show="editingTemplate">Update Template</span>
|
||||
</button>
|
||||
<button x-show="editingTemplate" @click="cancelEditTemplate()"
|
||||
class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates List -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-700 mb-4">Saved Templates</h3>
|
||||
<div x-show="savedTemplates.length === 0" class="text-gray-500 text-center py-8 border rounded-lg">
|
||||
No templates saved yet. Create one above to get started!
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<template x-for="template in savedTemplates" :key="template.id">
|
||||
<div class="border rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="font-medium text-gray-800" x-text="template.name"></h4>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
x-text="template.category"></span>
|
||||
<span x-show="template.is_favorite" class="text-yellow-500">⭐</span>
|
||||
</div>
|
||||
<p x-show="template.description" class="text-sm text-gray-600 mb-2" x-text="template.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 bg-white p-3 rounded border mb-3 italic">
|
||||
"<span x-text="template.template || template.content"></span>"
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs text-gray-500 mb-3">
|
||||
<span>Used <span x-text="template.usage_count || 0"></span> times</span>
|
||||
<span>Created <span x-text="formatDate(template.created_at)"></span></span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="loadTemplateForEditing(template)"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm px-2 py-1 border border-blue-200 rounded hover:bg-blue-50">
|
||||
Edit
|
||||
</button>
|
||||
<button @click="toggleTemplateFavorite(template)"
|
||||
class="text-yellow-600 hover:text-yellow-800 text-sm px-2 py-1 border border-yellow-200 rounded hover:bg-yellow-50">
|
||||
<span x-show="template.is_favorite">Unfav</span>
|
||||
<span x-show="!template.is_favorite">Fav</span>
|
||||
</button>
|
||||
<button @click="deleteTemplate(template.id, template.name)"
|
||||
class="text-red-600 hover:text-red-800 text-sm px-2 py-1 border border-red-200 rounded hover:bg-red-50">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/templates.js?v={{ cache_version }}"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('templatesApp', () => templatesApp());
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
197
src/templates/testing.html
Normal file
197
src/templates/testing.html
Normal file
@ -0,0 +1,197 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}System Testing - SMS Campaign Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="testingApp" x-init="init()" class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">🧪 System Testing & Diagnostics</h2>
|
||||
|
||||
<!-- Connection Tests -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Termux API Test -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-medium mb-3">📡 Termux API Test</h3>
|
||||
<div class="text-sm text-gray-600 mb-3">
|
||||
Endpoint: <code class="bg-gray-100 px-1">http://{{ phone_ip }}:5001</code>
|
||||
</div>
|
||||
<button @click="testTermuxConnection()"
|
||||
:disabled="testingTermux"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50 transition-colors">
|
||||
<span x-show="!testingTermux">Test Termux API</span>
|
||||
<span x-show="testingTermux">Testing...</span>
|
||||
</button>
|
||||
<div x-show="termuxTestResult" class="mt-3 p-3 rounded text-sm"
|
||||
:class="termuxTestResult?.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'">
|
||||
<pre x-text="JSON.stringify(termuxTestResult, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ADB Connection Test -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-medium mb-3">🔌 ADB Connection Test</h3>
|
||||
<div class="text-sm text-gray-600 mb-3">
|
||||
Device: <code class="bg-gray-100 px-1">{{ phone_ip }}:5555</code>
|
||||
</div>
|
||||
<button @click="testAdbConnection()"
|
||||
:disabled="testingAdb"
|
||||
class="bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600 disabled:opacity-50 transition-colors">
|
||||
<span x-show="!testingAdb">Test ADB</span>
|
||||
<span x-show="testingAdb">Testing...</span>
|
||||
</button>
|
||||
<div x-show="adbTestResult" class="mt-3 p-3 rounded text-sm"
|
||||
:class="adbTestResult?.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'">
|
||||
<pre x-text="JSON.stringify(adbTestResult, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test SMS Send -->
|
||||
<div class="border rounded-lg p-4 mb-6">
|
||||
<h3 class="font-medium mb-3">📨 Test SMS Send</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input type="tel" x-model="testPhone"
|
||||
placeholder="Phone number (e.g., 7801234567)"
|
||||
class="border rounded px-3 py-2">
|
||||
<input type="text" x-model="testMessage"
|
||||
placeholder="Test message"
|
||||
class="border rounded px-3 py-2">
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button @click="sendTestSms('termux')"
|
||||
:disabled="!testPhone || sendingTest"
|
||||
class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 disabled:opacity-50 transition-colors">
|
||||
Send via Termux
|
||||
</button>
|
||||
<button @click="sendTestSms('adb')"
|
||||
:disabled="!testPhone || sendingTest"
|
||||
class="bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600 disabled:opacity-50 transition-colors">
|
||||
Send via ADB
|
||||
</button>
|
||||
<button @click="sendTestSms('auto')"
|
||||
:disabled="!testPhone || sendingTest"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50 transition-colors">
|
||||
Auto (Best Available)
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="testSmsResult" class="mt-3 p-3 rounded text-sm"
|
||||
:class="testSmsResult?.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'">
|
||||
<pre x-text="JSON.stringify(testSmsResult, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Management -->
|
||||
<div class="border rounded-lg p-4 mb-6 border-red-200 bg-red-50">
|
||||
<h3 class="font-medium mb-3 text-red-800">⚠️ Database Management</h3>
|
||||
<p class="text-sm text-red-700 mb-4">
|
||||
<strong>Warning:</strong> This will permanently delete all campaigns, contacts, messages, and conversation history.
|
||||
</p>
|
||||
<button @click="showResetConfirmation = true"
|
||||
:disabled="resettingDatabase"
|
||||
class="bg-red-600 text-white px-6 py-2 rounded hover:bg-red-700 disabled:opacity-50 transition-colors font-medium">
|
||||
<span x-show="!resettingDatabase">🗑️ Reset Database</span>
|
||||
<span x-show="resettingDatabase">Resetting...</span>
|
||||
</button>
|
||||
<div x-show="resetResult" class="mt-3 p-3 rounded text-sm"
|
||||
:class="resetResult?.success ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-100 text-red-800 border border-red-300'">
|
||||
<div class="font-medium mb-1" x-text="resetResult?.success ? '✅ Success' : '❌ Error'"></div>
|
||||
<div x-text="resetResult?.message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-medium mb-3">ℹ️ System Information</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium">Phone IP:</span>
|
||||
<span x-text="phoneIP"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Preferred Method:</span>
|
||||
<span x-text="phoneStatus.prefer_termux ? 'Termux API' : 'ADB'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Last Check:</span>
|
||||
<span x-text="formatTime(phoneStatus.last_check)"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Active Connection:</span>
|
||||
<span x-text="phoneStatus.termux_connected ? 'Termux' : (phoneStatus.adb_connected ? 'ADB' : 'None')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Database Confirmation Modal -->
|
||||
<div x-show="showResetConfirmation"
|
||||
x-cloak
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
@click.self="showResetConfirmation = false">
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">⚠️</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">Reset Database?</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
This action will permanently delete:
|
||||
</p>
|
||||
<ul class="text-sm text-gray-600 mt-2 space-y-1 list-disc list-inside">
|
||||
<li>All campaigns and their messages</li>
|
||||
<li>All contact lists</li>
|
||||
<li>All conversation history</li>
|
||||
<li>All message templates</li>
|
||||
</ul>
|
||||
<p class="text-sm font-semibold text-red-600 mt-3">
|
||||
This cannot be undone!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Type <span class="font-mono bg-gray-100 px-1 rounded">RESET</span> to confirm:
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="resetConfirmText"
|
||||
@keyup.enter="resetConfirmText === 'RESET' && resetDatabase()"
|
||||
placeholder="Type RESET"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="showResetConfirmation = false; resetConfirmText = ''"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="resetDatabase()"
|
||||
:disabled="resetConfirmText !== 'RESET' || resettingDatabase"
|
||||
class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium">
|
||||
<span x-show="!resettingDatabase">Reset Database</span>
|
||||
<span x-show="resettingDatabase">Resetting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/testing.js?v={{ cache_version }}"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('testingApp', () => {
|
||||
const app = testingApp();
|
||||
app.phoneIP = window.PHONE_IP;
|
||||
// Access phoneStatus from baseApp
|
||||
app.phoneStatus = Alpine.raw(Alpine.store('baseApp')?.phoneStatus) || {
|
||||
termux_connected: false,
|
||||
adb_connected: false,
|
||||
prefer_termux: true,
|
||||
last_check: null
|
||||
};
|
||||
return app;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -10,6 +10,60 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_phone_number(phone: str) -> str:
|
||||
"""
|
||||
Normalize phone number to a consistent format for comparison.
|
||||
|
||||
Removes all non-digit characters except leading '+'.
|
||||
Examples:
|
||||
"+1 780-860-3620" -> "+17808603620"
|
||||
"(780) 860-3620" -> "7808603620"
|
||||
"17809965183" -> "17809965183"
|
||||
"611" -> "611"
|
||||
|
||||
Args:
|
||||
phone: Raw phone number string
|
||||
|
||||
Returns:
|
||||
Normalized phone number string
|
||||
"""
|
||||
if not phone:
|
||||
return ""
|
||||
|
||||
# Keep leading + if present
|
||||
has_plus = phone.strip().startswith('+')
|
||||
|
||||
# Remove all non-digits
|
||||
digits_only = re.sub(r'[^\d]', '', phone)
|
||||
|
||||
# Return with + prefix if it was there originally
|
||||
if has_plus and digits_only:
|
||||
return f"+{digits_only}"
|
||||
|
||||
return digits_only
|
||||
|
||||
|
||||
def phones_match(phone1: str, phone2: str) -> bool:
|
||||
"""
|
||||
Check if two phone numbers are the same after normalization.
|
||||
|
||||
Args:
|
||||
phone1: First phone number
|
||||
phone2: Second phone number
|
||||
|
||||
Returns:
|
||||
True if normalized numbers match
|
||||
"""
|
||||
norm1 = normalize_phone_number(phone1)
|
||||
norm2 = normalize_phone_number(phone2)
|
||||
|
||||
if not norm1 or not norm2:
|
||||
return False
|
||||
|
||||
return norm1 == norm2
|
||||
|
||||
|
||||
class PhoneUtils:
|
||||
"""Utility functions for phone/ADB operations"""
|
||||
|
||||
|
||||
33
test_contacts.sh
Executable file
33
test_contacts.sh
Executable file
@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Test script for contact list endpoints
|
||||
|
||||
PHONE_IP="${PHONE_IP:-10.0.0.193}"
|
||||
BASE_URL="http://${PHONE_IP}:5001"
|
||||
|
||||
echo "=================================================="
|
||||
echo "Testing Termux Contact List API"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
|
||||
echo "1. Testing /api/contacts/test endpoint..."
|
||||
echo " This will show us the raw JSON structure"
|
||||
echo ""
|
||||
|
||||
curl -s "${BASE_URL}/api/contacts/test" | python3 -m json.tool
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo "2. Testing /api/contacts/list endpoint..."
|
||||
echo " This will fetch all contacts"
|
||||
echo ""
|
||||
|
||||
curl -s "${BASE_URL}/api/contacts/list" | python3 -m json.tool | head -50
|
||||
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo "Done! Check the output above to see:"
|
||||
echo " - What fields are available (name, number, etc.)"
|
||||
echo " - The JSON structure"
|
||||
echo " - Total contact count"
|
||||
echo "=================================================="
|
||||
@ -1,136 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic test to verify refactored modules can be imported and initialized
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
def test_imports():
|
||||
"""Test that all refactored modules can be imported"""
|
||||
print("🧪 Testing module imports...")
|
||||
|
||||
try:
|
||||
# Test core modules
|
||||
from core.config import config
|
||||
from core.logging_config import setup_logging
|
||||
from core.signal_handling import register_signal_handlers
|
||||
print("✅ Core modules imported successfully")
|
||||
|
||||
# Test database modules
|
||||
from database import DatabaseManager, DatabaseHelper
|
||||
print("✅ Database modules imported successfully")
|
||||
|
||||
# Test SMS services
|
||||
from services.sms import SMSConnectionManager, SMSSender, ConnectionType, SMSResult
|
||||
print("✅ SMS service modules imported successfully")
|
||||
|
||||
# Test campaign services
|
||||
from services.campaign import CampaignManager, CampaignExecutor, MessageUtils
|
||||
print("✅ Campaign service modules imported successfully")
|
||||
|
||||
# Test response sync service
|
||||
from services.response_sync import ResponseSyncService
|
||||
print("✅ Response sync service imported successfully")
|
||||
|
||||
# Test background services
|
||||
from services.background import PhoneMonitor
|
||||
print("✅ Background service modules imported successfully")
|
||||
|
||||
return True
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ Import error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
return False
|
||||
|
||||
def test_initialization():
|
||||
"""Test that key classes can be initialized"""
|
||||
print("\n🧪 Testing module initialization...")
|
||||
|
||||
try:
|
||||
from core.config import config
|
||||
from database import DatabaseManager, DatabaseHelper
|
||||
from services.sms import SMSConnectionManager
|
||||
|
||||
# Test database manager
|
||||
db_manager = DatabaseManager(config.DATABASE)
|
||||
print("✅ DatabaseManager initialized")
|
||||
|
||||
# Test database helper
|
||||
db_helper = DatabaseHelper(config.DATABASE)
|
||||
print("✅ DatabaseHelper initialized")
|
||||
|
||||
# Test SMS connection manager
|
||||
sms_manager = SMSConnectionManager(config.termux_config)
|
||||
print("✅ SMSConnectionManager initialized")
|
||||
|
||||
# Test campaign manager (requires db_helper and sms_manager)
|
||||
from services.campaign import CampaignManager
|
||||
campaign_manager = CampaignManager(db_helper, sms_manager)
|
||||
print("✅ CampaignManager initialized")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Initialization error: {e}")
|
||||
return False
|
||||
|
||||
def test_basic_functionality():
|
||||
"""Test basic functionality of key components"""
|
||||
print("\n🧪 Testing basic functionality...")
|
||||
|
||||
try:
|
||||
from core.config import config
|
||||
from services.campaign.message_utils import MessageUtils
|
||||
|
||||
# Test message substitution
|
||||
template = "Hi {name}! Your phone is {phone}. Today is {date}."
|
||||
result = MessageUtils.substitute_variables(template, name="John", phone="1234567890")
|
||||
|
||||
expected_parts = ["Hi John!", "Your phone is 1234567890", "Today is"]
|
||||
if all(part in result for part in expected_parts):
|
||||
print("✅ Message substitution working")
|
||||
else:
|
||||
print(f"❌ Message substitution failed: {result}")
|
||||
return False
|
||||
|
||||
# Test response classification
|
||||
positive_response = MessageUtils.classify_response("Yes, I'm interested!")
|
||||
if positive_response == 'positive':
|
||||
print("✅ Response classification working")
|
||||
else:
|
||||
print(f"❌ Response classification failed: {positive_response}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Functionality test error: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 Starting refactoring verification tests...\n")
|
||||
|
||||
# Run tests
|
||||
imports_ok = test_imports()
|
||||
init_ok = test_initialization()
|
||||
func_ok = test_basic_functionality()
|
||||
|
||||
# Summary
|
||||
print("\n📊 Test Results:")
|
||||
print(f" Imports: {'✅ PASS' if imports_ok else '❌ FAIL'}")
|
||||
print(f" Initialization: {'✅ PASS' if init_ok else '❌ FAIL'}")
|
||||
print(f" Functionality: {'✅ PASS' if func_ok else '❌ FAIL'}")
|
||||
|
||||
if imports_ok and init_ok and func_ok:
|
||||
print("\n🎉 All tests passed! Refactoring is working correctly.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n⚠️ Some tests failed. Check the errors above.")
|
||||
sys.exit(1)
|
||||
Loading…
x
Reference in New Issue
Block a user