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
|
# SageMath parsed files
|
||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
# Environments
|
# Environments - CRITICAL: NEVER COMMIT THESE FILES
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@ -113,6 +117,13 @@ ENV/
|
|||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
|
||||||
|
# Security - API keys and secrets
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.crt
|
||||||
|
secrets/
|
||||||
|
api_keys.txt
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
.spyproject
|
.spyproject
|
||||||
@ -174,3 +185,6 @@ src/routes/__pycache__/conversations_enhanced.cpython-311.pyc
|
|||||||
src/routes/__pycache__/conversations.cpython-311.pyc
|
src/routes/__pycache__/conversations.cpython-311.pyc
|
||||||
src/services/__pycache__/termux_sync_service.cpython-311.pyc
|
src/services/__pycache__/termux_sync_service.cpython-311.pyc
|
||||||
src/services/__pycache__/websocket_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 📱
|
# 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)
|
[](./docker/docker-compose.yml)
|
||||||
[](./src/requirements.txt)
|
[](./src/requirements.txt)
|
||||||
@ -14,7 +25,7 @@
|
|||||||
nano .env
|
nano .env
|
||||||
|
|
||||||
# 2. Deploy to Android using automated script
|
# 2. Deploy to Android using automated script
|
||||||
./deploy-android.sh
|
./scripts/deploy-android.sh
|
||||||
|
|
||||||
# 3. Start Ubuntu homelab
|
# 3. Start Ubuntu homelab
|
||||||
./run.sh start
|
./run.sh start
|
||||||
@ -79,7 +90,7 @@ open http://localhost:5000
|
|||||||
### 3. Deploy to Android Device
|
### 3. Deploy to Android Device
|
||||||
```bash
|
```bash
|
||||||
# Use the automated deployment script
|
# Use the automated deployment script
|
||||||
./deploy-android.sh
|
./scripts/deploy-android.sh
|
||||||
|
|
||||||
# The script will:
|
# The script will:
|
||||||
# - Test connectivity to your Android device
|
# - Test connectivity to your Android device
|
||||||
@ -242,20 +253,31 @@ Android Monitor Dashboard (Port 5000) ← Device monitoring
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## <EFBFBD> Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
### Setup & Configuration
|
For complete documentation, see the [docs/](docs/) directory or visit the documentation site.
|
||||||
- [`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
|
|
||||||
|
|
||||||
### Technical Documentation
|
### Quick Links
|
||||||
- [`files.md`](files.md) - Complete project file documentation
|
- [Quick Start Guide](docs/setup/quick-start.md) - Get started in minutes
|
||||||
- [`termux-integration-summary.md`](termux-integration-summary.md) - Integration architecture details
|
- [Deployment Guide](docs/deployment/deployment-guide.md) - Production deployment
|
||||||
- [`instruct.md`](instruct.md) - Development guidelines and preferences
|
- [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
|
### Building Documentation
|
||||||
- [`text history.md`](text%20history.md) - Message templates and campaign history
|
```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
|
### Testing SMS Integration
|
||||||
```bash
|
```bash
|
||||||
# Test ADB connection
|
# Test ADB connection
|
||||||
./auto.sh
|
./scripts/auto.sh
|
||||||
|
|
||||||
# Test Termux API (if configured)
|
|
||||||
./test-termux-integration.sh
|
|
||||||
|
|
||||||
# Manual SMS test via UI script
|
# 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:
|
The automated deployment script simplifies Android service deployment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy-android.sh
|
./scripts/deploy-android.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**What it does:**
|
**What it does:**
|
||||||
@ -719,15 +738,8 @@ ssh android-dev "termux-sms-list -l 1" # Should list recent SMS
|
|||||||
|
|
||||||
### Reinstall if services are corrupted
|
### Reinstall if services are corrupted
|
||||||
```bash
|
```bash
|
||||||
# Redeploy scripts to ~/bin/
|
# Use the deployment script to redeploy everything
|
||||||
scp -P 8022 android/*.sh android-dev@10.0.0.193:~/bin/
|
./scripts/deploy-android.sh
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Port Conflicts
|
### Service Port Conflicts
|
||||||
@ -743,13 +755,10 @@ ssh android-dev "lsof -ti:5000 | xargs kill -9"
|
|||||||
### Phone Not Connecting
|
### Phone Not Connecting
|
||||||
```bash
|
```bash
|
||||||
# Auto-reconnect your phone
|
# Auto-reconnect your phone
|
||||||
./auto.sh
|
./scripts/auto.sh
|
||||||
|
|
||||||
# Check ADB devices
|
# Check ADB devices
|
||||||
adb devices
|
adb devices
|
||||||
|
|
||||||
# Restart connection monitoring
|
|
||||||
./phone-auto-connect.sh restart
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Termux API Issues
|
### Termux API Issues
|
||||||
|
|||||||
@ -42,7 +42,8 @@ CONFIG = {
|
|||||||
'termux-sms-list',
|
'termux-sms-list',
|
||||||
'termux-battery-status',
|
'termux-battery-status',
|
||||||
'termux-location',
|
'termux-location',
|
||||||
'termux-notification'
|
'termux-notification',
|
||||||
|
'termux-contact-list'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,6 +213,22 @@ class SMSApiServer:
|
|||||||
# Global server instance
|
# Global server instance
|
||||||
sms_server = SMSApiServer()
|
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
|
# API Endpoints
|
||||||
# Web interface route
|
# Web interface route
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
@ -292,11 +309,24 @@ def index():
|
|||||||
<p>System information and device details</p>
|
<p>System information and device details</p>
|
||||||
</div>
|
</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">
|
<div class="test-links">
|
||||||
<h3>🧪 Quick Tests</h3>
|
<h3>🧪 Quick Tests</h3>
|
||||||
<a href="/health">📊 Health Check</a>
|
<a href="/health">📊 Health Check</a>
|
||||||
<a href="/api/device/battery">🔋 Battery</a>
|
<a href="/api/device/battery">🔋 Battery</a>
|
||||||
<a href="/api/device/info">ℹ️ Device Info</a>
|
<a href="/api/device/info">ℹ️ Device Info</a>
|
||||||
|
<a href="/api/contacts/test">📇 Test Contacts</a>
|
||||||
</div>
|
</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;">
|
<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'])
|
@app.route('/api/sms/send', methods=['POST'])
|
||||||
def send_sms():
|
def send_sms():
|
||||||
"""Send SMS message via Termux API"""
|
"""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:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data:
|
if not data:
|
||||||
@ -549,6 +587,14 @@ def get_contact_info(phone):
|
|||||||
@app.route('/api/sms/send-reply', methods=['POST'])
|
@app.route('/api/sms/send-reply', methods=['POST'])
|
||||||
def send_reply():
|
def send_reply():
|
||||||
"""Send a reply message with enhanced tracking"""
|
"""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:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
@ -617,6 +663,108 @@ def campaign_notification():
|
|||||||
logger.error(f"Notification error: {e}")
|
logger.error(f"Notification error: {e}")
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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__':
|
if __name__ == '__main__':
|
||||||
# Create logs directory
|
# Create logs directory
|
||||||
os.makedirs('/data/data/com.termux/files/home/logs', exist_ok=True)
|
os.makedirs('/data/data/com.termux/files/home/logs', exist_ok=True)
|
||||||
|
|||||||
@ -18,8 +18,20 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PHONE_IP: ${PHONE_IP:-10.0.0.193}
|
PHONE_IP: ${PHONE_IP:-10.0.0.193}
|
||||||
ADB_PORT: ${ADB_PORT:-5555}
|
ADB_PORT: ${ADB_PORT:-5555}
|
||||||
|
TERMUX_API_PORT: ${TERMUX_API_PORT:-5001}
|
||||||
FLASK_ENV: ${FLASK_ENV:-production}
|
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)
|
network_mode: host # Required for ADB network connection (host mode needed for ADB)
|
||||||
privileged: true # Required for USB access
|
privileged: true # Required for USB access
|
||||||
restart: unless-stopped
|
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:**
|
**Deploy to Android:**
|
||||||
```bash
|
```bash
|
||||||
./deploy-android.sh
|
./scripts/deploy-android.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
@ -202,7 +202,7 @@ sshd
|
|||||||
### "Termux API not responding"
|
### "Termux API not responding"
|
||||||
```bash
|
```bash
|
||||||
# Redeploy services
|
# Redeploy services
|
||||||
./deploy-android.sh
|
./scripts/deploy-android.sh
|
||||||
|
|
||||||
# Or manually restart
|
# Or manually restart
|
||||||
ssh -p 8022 100.107.173.66 "~/bin/start-all-services.sh"
|
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 -e "${BLUE}📊 What was updated:${NC}"
|
||||||
echo " ✅ Increased max message length from 160 to 1600 characters"
|
echo " ✅ Increased max message length from 160 to 1600 characters"
|
||||||
echo " ✅ Reduced rate limit delay from 2.0 to 1.0 seconds"
|
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 " ✅ Added detailed error logging and validation"
|
||||||
echo " ✅ Improved phone number cleaning"
|
echo " ✅ Improved phone number cleaning"
|
||||||
echo " ✅ Better debugging information"
|
echo " ✅ Better debugging information"
|
||||||
168
src/app.py
168
src/app.py
@ -7,8 +7,14 @@ Streamlined main application using modular components
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
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
|
# Add src to Python path for imports
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
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.config import config
|
||||||
from core.logging_config import setup_logging
|
from core.logging_config import setup_logging
|
||||||
from core.signal_handling import register_signal_handlers, shutdown_event
|
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
|
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.conversations_enhanced import conversations_enhanced_bp, set_services
|
||||||
from routes.lists import lists_bp
|
from routes.lists import lists_bp
|
||||||
|
|
||||||
|
from routes.auth_routes import auth_bp, init_auth_routes
|
||||||
|
|
||||||
# Services for enhanced conversations
|
# Services for enhanced conversations
|
||||||
from services.termux_sync_service import TermuxSyncService
|
from services.termux_sync_service import TermuxSyncService
|
||||||
from services.websocket_service import WebSocketService
|
from services.websocket_service import WebSocketService
|
||||||
@ -69,6 +80,94 @@ def create_app():
|
|||||||
'MAX_CONTENT_LENGTH': config.MAX_CONTENT_LENGTH
|
'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
|
# Create upload directory
|
||||||
os.makedirs(config.UPLOAD_FOLDER, exist_ok=True)
|
os.makedirs(config.UPLOAD_FOLDER, exist_ok=True)
|
||||||
|
|
||||||
@ -110,6 +209,10 @@ def create_app():
|
|||||||
# Set services for enhanced conversations
|
# Set services for enhanced conversations
|
||||||
set_services(termux_sync, websocket_service)
|
set_services(termux_sync, websocket_service)
|
||||||
|
|
||||||
|
# Initialize auth routes
|
||||||
|
init_auth_routes(user_manager)
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
|
||||||
# Initialize and register API routes
|
# Initialize and register API routes
|
||||||
init_campaign_routes(campaign_manager, campaign_executor, db_helper)
|
init_campaign_routes(campaign_manager, campaign_executor, db_helper)
|
||||||
init_template_routes(db_helper)
|
init_template_routes(db_helper)
|
||||||
@ -129,16 +232,71 @@ def create_app():
|
|||||||
app.register_blueprint(test_routes)
|
app.register_blueprint(test_routes)
|
||||||
app.register_blueprint(database_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
|
# Basic routes
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
|
@limiter.exempt
|
||||||
def health():
|
def health():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint - exempt from rate limiting"""
|
||||||
return {"status": "ok", "version": "2.0"}
|
return {"status": "ok", "version": "2.0"}
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def dashboard():
|
@require_login()
|
||||||
"""Main dashboard"""
|
def index():
|
||||||
return render_template('dashboard.html')
|
"""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
|
# Start background services
|
||||||
phone_monitor.start()
|
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'))
|
SMS_MAX_RETRY_DELAY: int = int(os.environ.get('SMS_MAX_RETRY_DELAY', '8'))
|
||||||
|
|
||||||
# Flask Settings
|
# 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'
|
UPLOAD_FOLDER: str = './uploads'
|
||||||
MAX_CONTENT_LENGTH: int = 16 * 1024 * 1024
|
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
|
@property
|
||||||
def termux_api_url(self) -> str:
|
def termux_api_url(self) -> str:
|
||||||
return f"http://{self.PHONE_IP}:{self.TERMUX_API_PORT}"
|
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 sqlite3
|
||||||
import json
|
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:
|
class ContactList:
|
||||||
@ -170,3 +178,244 @@ class ContactList:
|
|||||||
success = cur.rowcount > 0
|
success = cur.rowcount > 0
|
||||||
conn.close()
|
conn.close()
|
||||||
return success
|
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==3.0.0
|
||||||
|
Flask-Login==0.6.3
|
||||||
Werkzeug==3.0.1
|
Werkzeug==3.0.1
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
typing-extensions==4.8.0
|
typing-extensions==4.8.0
|
||||||
flask-socketio==5.3.5
|
flask-socketio==5.3.5
|
||||||
python-socketio==5.10.0
|
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 logging
|
||||||
import csv
|
import csv
|
||||||
from flask import Blueprint, request, jsonify, send_file
|
from flask import Blueprint, request, jsonify, send_file
|
||||||
|
from core.user_auth import require_login
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ def init_analytics_routes(db):
|
|||||||
db_helper = db
|
db_helper = db
|
||||||
|
|
||||||
@analytics_routes.route('/analytics')
|
@analytics_routes.route('/analytics')
|
||||||
|
@require_login()
|
||||||
def get_analytics():
|
def get_analytics():
|
||||||
"""Get campaign analytics"""
|
"""Get campaign analytics"""
|
||||||
try:
|
try:
|
||||||
@ -69,6 +71,7 @@ def get_analytics():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@analytics_routes.route('/followups')
|
@analytics_routes.route('/followups')
|
||||||
|
@require_login()
|
||||||
def get_followups():
|
def get_followups():
|
||||||
"""Get contacts needing follow-up"""
|
"""Get contacts needing follow-up"""
|
||||||
try:
|
try:
|
||||||
@ -87,6 +90,7 @@ def get_followups():
|
|||||||
return jsonify([]), 500
|
return jsonify([]), 500
|
||||||
|
|
||||||
@analytics_routes.route('/export/<int:campaign_id>')
|
@analytics_routes.route('/export/<int:campaign_id>')
|
||||||
|
@require_login()
|
||||||
def export_campaign(campaign_id):
|
def export_campaign(campaign_id):
|
||||||
"""Export campaign data as CSV"""
|
"""Export campaign data as CSV"""
|
||||||
try:
|
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 threading import Thread
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from database import DatabaseHelper
|
from database import DatabaseHelper
|
||||||
|
from routes.api.auth_decorator import require_auth
|
||||||
|
from core.user_auth import require_login
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -26,6 +28,7 @@ def init_campaign_routes(cm, ce, db):
|
|||||||
db_helper = db
|
db_helper = db
|
||||||
|
|
||||||
@campaign_routes.route('/create', methods=['POST'])
|
@campaign_routes.route('/create', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def create_campaign():
|
def create_campaign():
|
||||||
"""Create a new campaign with contact preview"""
|
"""Create a new campaign with contact preview"""
|
||||||
try:
|
try:
|
||||||
@ -72,6 +75,7 @@ def create_campaign():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@campaign_routes.route('/start', methods=['POST'])
|
@campaign_routes.route('/start', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def start_campaign():
|
def start_campaign():
|
||||||
"""Start SMS campaign"""
|
"""Start SMS campaign"""
|
||||||
try:
|
try:
|
||||||
@ -98,6 +102,7 @@ def start_campaign():
|
|||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@campaign_routes.route('/pause', methods=['POST'])
|
@campaign_routes.route('/pause', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def pause_campaign():
|
def pause_campaign():
|
||||||
"""Pause running campaign"""
|
"""Pause running campaign"""
|
||||||
try:
|
try:
|
||||||
@ -108,6 +113,7 @@ def pause_campaign():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@campaign_routes.route('/resume', methods=['POST'])
|
@campaign_routes.route('/resume', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def resume_campaign():
|
def resume_campaign():
|
||||||
"""Resume paused campaign"""
|
"""Resume paused campaign"""
|
||||||
try:
|
try:
|
||||||
@ -118,6 +124,7 @@ def resume_campaign():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@campaign_routes.route('/status')
|
@campaign_routes.route('/status')
|
||||||
|
@require_login()
|
||||||
def campaign_status():
|
def campaign_status():
|
||||||
"""Get current campaign status"""
|
"""Get current campaign status"""
|
||||||
try:
|
try:
|
||||||
@ -128,6 +135,7 @@ def campaign_status():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@campaign_routes.route('/list')
|
@campaign_routes.route('/list')
|
||||||
|
@require_login()
|
||||||
def list_campaigns():
|
def list_campaigns():
|
||||||
"""List all campaigns"""
|
"""List all campaigns"""
|
||||||
try:
|
try:
|
||||||
@ -145,6 +153,7 @@ def list_campaigns():
|
|||||||
return jsonify([]), 500
|
return jsonify([]), 500
|
||||||
|
|
||||||
@campaign_routes.route('/recent') # /api/campaigns/recent
|
@campaign_routes.route('/recent') # /api/campaigns/recent
|
||||||
|
@require_login()
|
||||||
def get_recent_campaigns():
|
def get_recent_campaigns():
|
||||||
"""Get recent campaigns"""
|
"""Get recent campaigns"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
import subprocess
|
import subprocess
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
from core.user_auth import require_login
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ def init_connection_routes(manager, sync_service, app_config):
|
|||||||
config = app_config
|
config = app_config
|
||||||
|
|
||||||
@connection_routes.route('/connections/status')
|
@connection_routes.route('/connections/status')
|
||||||
|
@require_login()
|
||||||
def connection_status():
|
def connection_status():
|
||||||
"""Get current SMS connection status"""
|
"""Get current SMS connection status"""
|
||||||
try:
|
try:
|
||||||
@ -34,6 +36,7 @@ def connection_status():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@connection_routes.route('/device/status')
|
@connection_routes.route('/device/status')
|
||||||
|
@require_login()
|
||||||
def device_status():
|
def device_status():
|
||||||
"""Get device status from available connection"""
|
"""Get device status from available connection"""
|
||||||
try:
|
try:
|
||||||
@ -43,6 +46,7 @@ def device_status():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@connection_routes.route('/phone/status')
|
@connection_routes.route('/phone/status')
|
||||||
|
@require_login()
|
||||||
def phone_status():
|
def phone_status():
|
||||||
"""Check phone connection status"""
|
"""Check phone connection status"""
|
||||||
try:
|
try:
|
||||||
@ -77,6 +81,7 @@ def phone_status():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@connection_routes.route('/phone/connect', methods=['POST'])
|
@connection_routes.route('/phone/connect', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def connect_phone():
|
def connect_phone():
|
||||||
"""Manually trigger phone connection"""
|
"""Manually trigger phone connection"""
|
||||||
try:
|
try:
|
||||||
@ -95,6 +100,7 @@ def connect_phone():
|
|||||||
return jsonify({"connected": False, "error": str(e)}), 500
|
return jsonify({"connected": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@connection_routes.route('/termux/status')
|
@connection_routes.route('/termux/status')
|
||||||
|
@require_login()
|
||||||
def termux_status():
|
def termux_status():
|
||||||
"""Check Termux API status"""
|
"""Check Termux API status"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -5,7 +5,8 @@ Administrative endpoints for database operations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -22,9 +23,11 @@ def init_database_routes(dbm, cfg):
|
|||||||
config = cfg
|
config = cfg
|
||||||
|
|
||||||
@database_routes.route('/reset', methods=['POST'])
|
@database_routes.route('/reset', methods=['POST'])
|
||||||
|
@require_login('admin')
|
||||||
def reset_database():
|
def reset_database():
|
||||||
"""
|
"""
|
||||||
Reset the entire database - WARNING: This deletes ALL data
|
Reset the entire database - WARNING: This deletes ALL data
|
||||||
|
REQUIRES: Admin API key
|
||||||
|
|
||||||
This endpoint:
|
This endpoint:
|
||||||
1. Closes existing database connections
|
1. Closes existing database connections
|
||||||
@ -80,9 +83,11 @@ def reset_database():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
@database_routes.route('/stats', methods=['GET'])
|
@database_routes.route('/stats', methods=['GET'])
|
||||||
|
@require_login()
|
||||||
def database_stats():
|
def database_stats():
|
||||||
"""
|
"""
|
||||||
Get database statistics
|
Get database statistics
|
||||||
|
REQUIRES: User or Admin API key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with database size, table counts, etc.
|
JSON response with database size, table counts, etc.
|
||||||
|
|||||||
@ -5,6 +5,8 @@ Handles SMS testing and sending operations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from flask import Blueprint, request, jsonify
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -21,6 +23,7 @@ def init_sms_routes(sender, manager):
|
|||||||
sms_manager = manager
|
sms_manager = manager
|
||||||
|
|
||||||
@sms_routes.route('/test/real', methods=['POST'])
|
@sms_routes.route('/test/real', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def test_sms_real():
|
def test_sms_real():
|
||||||
"""Test SMS by actually sending a real SMS"""
|
"""Test SMS by actually sending a real SMS"""
|
||||||
try:
|
try:
|
||||||
@ -56,6 +59,7 @@ def test_sms_real():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@sms_routes.route('/test', methods=['POST'])
|
@sms_routes.route('/test', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def test_sms_connection():
|
def test_sms_connection():
|
||||||
"""Test SMS connection without actually sending"""
|
"""Test SMS connection without actually sending"""
|
||||||
try:
|
try:
|
||||||
@ -77,6 +81,7 @@ def test_sms_connection():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@sms_routes.route('/send/enhanced', methods=['POST'])
|
@sms_routes.route('/send/enhanced', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def send_enhanced_sms():
|
def send_enhanced_sms():
|
||||||
"""Send SMS with enhanced dual connection support"""
|
"""Send SMS with enhanced dual connection support"""
|
||||||
try:
|
try:
|
||||||
@ -102,6 +107,7 @@ def send_enhanced_sms():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@sms_routes.route('/status')
|
@sms_routes.route('/status')
|
||||||
|
@require_login()
|
||||||
def get_sms_status():
|
def get_sms_status():
|
||||||
"""Get SMS connection status"""
|
"""Get SMS connection status"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -5,6 +5,7 @@ Handles message template CRUD operations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
from core.user_auth import require_login
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ def init_template_routes(db):
|
|||||||
db_helper = db
|
db_helper = db
|
||||||
|
|
||||||
@template_routes.route('')
|
@template_routes.route('')
|
||||||
|
@require_login()
|
||||||
def get_templates():
|
def get_templates():
|
||||||
"""Get message templates"""
|
"""Get message templates"""
|
||||||
try:
|
try:
|
||||||
@ -34,6 +36,7 @@ def get_templates():
|
|||||||
return jsonify([]), 500
|
return jsonify([]), 500
|
||||||
|
|
||||||
@template_routes.route('', methods=['POST'])
|
@template_routes.route('', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def save_template():
|
def save_template():
|
||||||
"""Save message template"""
|
"""Save message template"""
|
||||||
try:
|
try:
|
||||||
@ -50,6 +53,7 @@ def save_template():
|
|||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@template_routes.route('/<int:template_id>', methods=['GET'])
|
@template_routes.route('/<int:template_id>', methods=['GET'])
|
||||||
|
@require_login()
|
||||||
def get_template_by_id(template_id):
|
def get_template_by_id(template_id):
|
||||||
"""Get specific template by ID"""
|
"""Get specific template by ID"""
|
||||||
try:
|
try:
|
||||||
@ -70,6 +74,7 @@ def get_template_by_id(template_id):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@template_routes.route('/<int:template_id>', methods=['PUT'])
|
@template_routes.route('/<int:template_id>', methods=['PUT'])
|
||||||
|
@require_login()
|
||||||
def update_template_by_id(template_id):
|
def update_template_by_id(template_id):
|
||||||
"""Update existing template"""
|
"""Update existing template"""
|
||||||
try:
|
try:
|
||||||
@ -118,6 +123,7 @@ def update_template_by_id(template_id):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@template_routes.route('/<int:template_id>', methods=['DELETE'])
|
@template_routes.route('/<int:template_id>', methods=['DELETE'])
|
||||||
|
@require_login()
|
||||||
def delete_template_by_id(template_id):
|
def delete_template_by_id(template_id):
|
||||||
"""Delete template"""
|
"""Delete template"""
|
||||||
try:
|
try:
|
||||||
@ -140,6 +146,7 @@ def delete_template_by_id(template_id):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@template_routes.route('/<int:template_id>/use', methods=['POST'])
|
@template_routes.route('/<int:template_id>/use', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def use_template_by_id(template_id):
|
def use_template_by_id(template_id):
|
||||||
"""Mark template as used (increment usage counter)"""
|
"""Mark template as used (increment usage counter)"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Test API Routes - Flask Blueprint
|
Test API Routes - Flask Blueprint
|
||||||
System testing endpoints for debugging connections and SMS functionality
|
System testing endpoints for debugging connections and SMS functionality
|
||||||
|
Admin-only access required - these endpoints can send SMS
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
from core.user_auth import require_login
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -23,6 +25,7 @@ def init_test_routes(sm, cfg):
|
|||||||
config = cfg
|
config = cfg
|
||||||
|
|
||||||
@test_routes.route('/termux', methods=['POST'])
|
@test_routes.route('/termux', methods=['POST'])
|
||||||
|
@require_login('admin')
|
||||||
def test_termux_endpoint():
|
def test_termux_endpoint():
|
||||||
"""Test Termux API endpoint"""
|
"""Test Termux API endpoint"""
|
||||||
try:
|
try:
|
||||||
@ -50,6 +53,7 @@ def test_termux_endpoint():
|
|||||||
})
|
})
|
||||||
|
|
||||||
@test_routes.route('/adb', methods=['POST'])
|
@test_routes.route('/adb', methods=['POST'])
|
||||||
|
@require_login('admin')
|
||||||
def test_adb_endpoint():
|
def test_adb_endpoint():
|
||||||
"""Test ADB connection"""
|
"""Test ADB connection"""
|
||||||
try:
|
try:
|
||||||
@ -81,6 +85,7 @@ def test_adb_endpoint():
|
|||||||
})
|
})
|
||||||
|
|
||||||
@test_routes.route('/sms', methods=['POST'])
|
@test_routes.route('/sms', methods=['POST'])
|
||||||
|
@require_login('admin')
|
||||||
def test_sms_send():
|
def test_sms_send():
|
||||||
"""Test SMS send with specified method"""
|
"""Test SMS send with specified method"""
|
||||||
try:
|
try:
|
||||||
@ -111,6 +116,7 @@ def test_sms_send():
|
|||||||
})
|
})
|
||||||
|
|
||||||
@test_routes.route('/connections', methods=['GET'])
|
@test_routes.route('/connections', methods=['GET'])
|
||||||
|
@require_login('admin')
|
||||||
def test_connections():
|
def test_connections():
|
||||||
"""Test all connection types"""
|
"""Test all connection types"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import csv
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from werkzeug.utils import secure_filename
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -23,6 +25,7 @@ def init_upload_routes(config):
|
|||||||
app_config = config
|
app_config = config
|
||||||
|
|
||||||
@upload_routes.route('/csv/upload', methods=['POST'])
|
@upload_routes.route('/csv/upload', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def upload_csv():
|
def upload_csv():
|
||||||
"""Upload and parse CSV file"""
|
"""Upload and parse CSV file"""
|
||||||
try:
|
try:
|
||||||
@ -102,6 +105,7 @@ def upload_csv():
|
|||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@upload_routes.route('/campaign/upload', methods=['POST'])
|
@upload_routes.route('/campaign/upload', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def upload_campaign_csv():
|
def upload_campaign_csv():
|
||||||
"""Handle CSV file upload with preview for campaigns"""
|
"""Handle CSV file upload with preview for campaigns"""
|
||||||
try:
|
try:
|
||||||
@ -171,3 +175,95 @@ def upload_campaign_csv():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error uploading campaign CSV: {e}")
|
logger.error(f"Error uploading campaign CSV: {e}")
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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 flask import Blueprint, jsonify, request
|
||||||
from models.conversation import Conversation
|
from models.conversation import Conversation
|
||||||
|
from core.user_auth import require_login
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -18,6 +19,7 @@ conversation_model = Conversation()
|
|||||||
|
|
||||||
@conversations_bp.route('/')
|
@conversations_bp.route('/')
|
||||||
@conversations_bp.route('')
|
@conversations_bp.route('')
|
||||||
|
@require_login()
|
||||||
def list_conversations():
|
def list_conversations():
|
||||||
"""List all conversation threads with optional filters"""
|
"""List all conversation threads with optional filters"""
|
||||||
try:
|
try:
|
||||||
@ -46,6 +48,7 @@ def list_conversations():
|
|||||||
|
|
||||||
|
|
||||||
@conversations_bp.route('/<conversation_id>')
|
@conversations_bp.route('/<conversation_id>')
|
||||||
|
@require_login()
|
||||||
def get_conversation_detail(conversation_id):
|
def get_conversation_detail(conversation_id):
|
||||||
"""Get specific conversation with all messages"""
|
"""Get specific conversation with all messages"""
|
||||||
try:
|
try:
|
||||||
@ -65,6 +68,7 @@ def get_conversation_detail(conversation_id):
|
|||||||
|
|
||||||
|
|
||||||
@conversations_bp.route('/<conversation_id>/read', methods=['PUT'])
|
@conversations_bp.route('/<conversation_id>/read', methods=['PUT'])
|
||||||
|
@require_login()
|
||||||
def mark_conversation_read(conversation_id):
|
def mark_conversation_read(conversation_id):
|
||||||
"""Mark conversation as read"""
|
"""Mark conversation as read"""
|
||||||
try:
|
try:
|
||||||
@ -84,6 +88,7 @@ def mark_conversation_read(conversation_id):
|
|||||||
|
|
||||||
|
|
||||||
@conversations_bp.route('/<conversation_id>/notes', methods=['PUT'])
|
@conversations_bp.route('/<conversation_id>/notes', methods=['PUT'])
|
||||||
|
@require_login()
|
||||||
def update_conversation_notes(conversation_id):
|
def update_conversation_notes(conversation_id):
|
||||||
"""Update conversation notes"""
|
"""Update conversation notes"""
|
||||||
try:
|
try:
|
||||||
@ -106,6 +111,7 @@ def update_conversation_notes(conversation_id):
|
|||||||
|
|
||||||
|
|
||||||
@conversations_bp.route('/<conversation_id>/tags', methods=['POST'])
|
@conversations_bp.route('/<conversation_id>/tags', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def manage_conversation_tags(conversation_id):
|
def manage_conversation_tags(conversation_id):
|
||||||
"""Add, remove, or set tags for conversation"""
|
"""Add, remove, or set tags for conversation"""
|
||||||
try:
|
try:
|
||||||
@ -132,6 +138,7 @@ def manage_conversation_tags(conversation_id):
|
|||||||
|
|
||||||
|
|
||||||
@conversations_bp.route('/search')
|
@conversations_bp.route('/search')
|
||||||
|
@require_login()
|
||||||
def search_conversations():
|
def search_conversations():
|
||||||
"""Search conversations by phone, name, notes, or message content"""
|
"""Search conversations by phone, name, notes, or message content"""
|
||||||
try:
|
try:
|
||||||
@ -156,6 +163,7 @@ def search_conversations():
|
|||||||
|
|
||||||
|
|
||||||
@conversations_bp.route('/stats')
|
@conversations_bp.route('/stats')
|
||||||
|
@require_login()
|
||||||
def get_conversation_stats():
|
def get_conversation_stats():
|
||||||
"""Get overall conversation statistics"""
|
"""Get overall conversation statistics"""
|
||||||
try:
|
try:
|
||||||
@ -172,6 +180,7 @@ def get_conversation_stats():
|
|||||||
|
|
||||||
|
|
||||||
@conversations_bp.route('/migrate', methods=['POST'])
|
@conversations_bp.route('/migrate', methods=['POST'])
|
||||||
|
@require_login('admin')
|
||||||
def migrate_existing_messages():
|
def migrate_existing_messages():
|
||||||
"""One-time migration to create conversations from existing messages"""
|
"""One-time migration to create conversations from existing messages"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from flask import Blueprint, jsonify, request
|
|||||||
from models.conversation import Conversation
|
from models.conversation import Conversation
|
||||||
from services.termux_sync_service import TermuxSyncService
|
from services.termux_sync_service import TermuxSyncService
|
||||||
from services.websocket_service import WebSocketService
|
from services.websocket_service import WebSocketService
|
||||||
|
from core.user_auth import require_login
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@ -28,6 +29,7 @@ def set_services(sync_svc: TermuxSyncService, ws_svc: WebSocketService):
|
|||||||
|
|
||||||
@conversations_enhanced_bp.route('/')
|
@conversations_enhanced_bp.route('/')
|
||||||
@conversations_enhanced_bp.route('')
|
@conversations_enhanced_bp.route('')
|
||||||
|
@require_login()
|
||||||
def list_enhanced_conversations():
|
def list_enhanced_conversations():
|
||||||
"""List all conversations with enhanced features"""
|
"""List all conversations with enhanced features"""
|
||||||
try:
|
try:
|
||||||
@ -88,6 +90,7 @@ def list_enhanced_conversations():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@conversations_enhanced_bp.route('/<conversation_id>/messages')
|
@conversations_enhanced_bp.route('/<conversation_id>/messages')
|
||||||
|
@require_login()
|
||||||
def get_conversation_messages(conversation_id):
|
def get_conversation_messages(conversation_id):
|
||||||
"""Get paginated messages for a conversation"""
|
"""Get paginated messages for a conversation"""
|
||||||
try:
|
try:
|
||||||
@ -135,6 +138,7 @@ def get_conversation_messages(conversation_id):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@conversations_enhanced_bp.route('/<conversation_id>/send', methods=['POST'])
|
@conversations_enhanced_bp.route('/<conversation_id>/send', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def send_message(conversation_id):
|
def send_message(conversation_id):
|
||||||
"""Send a message in a conversation"""
|
"""Send a message in a conversation"""
|
||||||
try:
|
try:
|
||||||
@ -185,6 +189,7 @@ def send_message(conversation_id):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@conversations_enhanced_bp.route('/<conversation_id>/star', methods=['PUT'])
|
@conversations_enhanced_bp.route('/<conversation_id>/star', methods=['PUT'])
|
||||||
|
@require_login()
|
||||||
def toggle_star(conversation_id):
|
def toggle_star(conversation_id):
|
||||||
"""Toggle starred status of conversation"""
|
"""Toggle starred status of conversation"""
|
||||||
try:
|
try:
|
||||||
@ -223,6 +228,7 @@ def toggle_star(conversation_id):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@conversations_enhanced_bp.route('/<conversation_id>/sync', methods=['POST'])
|
@conversations_enhanced_bp.route('/<conversation_id>/sync', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def sync_conversation(conversation_id):
|
def sync_conversation(conversation_id):
|
||||||
"""Manually trigger full history sync for a conversation"""
|
"""Manually trigger full history sync for a conversation"""
|
||||||
try:
|
try:
|
||||||
@ -255,6 +261,7 @@ def sync_conversation(conversation_id):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@conversations_enhanced_bp.route('/sync-all', methods=['POST'])
|
@conversations_enhanced_bp.route('/sync-all', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def sync_all_conversations():
|
def sync_all_conversations():
|
||||||
"""Sync all campaign conversations with phone"""
|
"""Sync all campaign conversations with phone"""
|
||||||
try:
|
try:
|
||||||
@ -281,6 +288,7 @@ def sync_all_conversations():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@conversations_enhanced_bp.route('/<conversation_id>/mark-read', methods=['PUT'])
|
@conversations_enhanced_bp.route('/<conversation_id>/mark-read', methods=['PUT'])
|
||||||
|
@require_login()
|
||||||
def mark_conversation_read(conversation_id):
|
def mark_conversation_read(conversation_id):
|
||||||
"""Mark all messages in conversation as read"""
|
"""Mark all messages in conversation as read"""
|
||||||
try:
|
try:
|
||||||
@ -315,6 +323,7 @@ def mark_conversation_read(conversation_id):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@conversations_enhanced_bp.route('/stats')
|
@conversations_enhanced_bp.route('/stats')
|
||||||
|
@require_login()
|
||||||
def get_conversation_stats():
|
def get_conversation_stats():
|
||||||
"""Get conversation statistics for dashboard"""
|
"""Get conversation statistics for dashboard"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
from models.contact_list import ContactList
|
from models.contact_list import ContactList
|
||||||
|
from core.user_auth import require_login
|
||||||
|
|
||||||
lists_bp = Blueprint('lists', __name__)
|
lists_bp = Blueprint('lists', __name__)
|
||||||
model = ContactList()
|
model = ContactList()
|
||||||
@ -9,6 +10,7 @@ model.ensure_schema()
|
|||||||
|
|
||||||
|
|
||||||
@lists_bp.route('/api/lists', methods=['GET'])
|
@lists_bp.route('/api/lists', methods=['GET'])
|
||||||
|
@require_login()
|
||||||
def get_lists():
|
def get_lists():
|
||||||
try:
|
try:
|
||||||
lists = model.get_all_lists()
|
lists = model.get_all_lists()
|
||||||
@ -18,6 +20,7 @@ def get_lists():
|
|||||||
|
|
||||||
|
|
||||||
@lists_bp.route('/api/lists', methods=['POST'])
|
@lists_bp.route('/api/lists', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def create_list():
|
def create_list():
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@ -38,6 +41,7 @@ def create_list():
|
|||||||
|
|
||||||
|
|
||||||
@lists_bp.route('/api/lists/<int:list_id>', methods=['GET'])
|
@lists_bp.route('/api/lists/<int:list_id>', methods=['GET'])
|
||||||
|
@require_login()
|
||||||
def get_list(list_id):
|
def get_list(list_id):
|
||||||
try:
|
try:
|
||||||
data = model.get_list(list_id)
|
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'])
|
@lists_bp.route('/api/lists/<int:list_id>/contacts/<path:phone>', methods=['PUT'])
|
||||||
|
@require_login()
|
||||||
def update_contact(list_id, phone):
|
def update_contact(list_id, phone):
|
||||||
try:
|
try:
|
||||||
updates = request.get_json() or {}
|
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'])
|
@lists_bp.route('/api/lists/<int:list_id>', methods=['DELETE'])
|
||||||
|
@require_login()
|
||||||
def delete_list(list_id):
|
def delete_list(list_id):
|
||||||
try:
|
try:
|
||||||
success = model.soft_delete(list_id)
|
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'])
|
@lists_bp.route('/api/lists/<int:list_id>/use', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
def use_list(list_id):
|
def use_list(list_id):
|
||||||
try:
|
try:
|
||||||
model.mark_used(list_id)
|
model.mark_used(list_id)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import subprocess
|
|||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import os
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
@ -38,6 +39,11 @@ class SMSConnectionManager:
|
|||||||
self.termux_api_port = config.get('TERMUX_API_PORT', '5001')
|
self.termux_api_port = config.get('TERMUX_API_PORT', '5001')
|
||||||
self.termux_api_url = f"http://{self.phone_ip}:{self.termux_api_port}"
|
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
|
# Connection preferences and status
|
||||||
self.primary_connection = ConnectionType.TERMUX_API # Prefer native API
|
self.primary_connection = ConnectionType.TERMUX_API # Prefer native API
|
||||||
self.fallback_connection = ConnectionType.ADB
|
self.fallback_connection = ConnectionType.ADB
|
||||||
@ -70,8 +76,10 @@ class SMSConnectionManager:
|
|||||||
# Check Termux API
|
# Check Termux API
|
||||||
prev_termux_status = self.connection_status.get(ConnectionType.TERMUX_API, None)
|
prev_termux_status = self.connection_status.get(ConnectionType.TERMUX_API, None)
|
||||||
try:
|
try:
|
||||||
|
# Health endpoint doesn't require auth, but we should add header for consistency
|
||||||
response = requests.get(
|
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
|
timeout=5
|
||||||
)
|
)
|
||||||
current_status = (
|
current_status = (
|
||||||
@ -185,6 +193,7 @@ class SMSConnectionManager:
|
|||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self.termux_api_url}/api/sms/send",
|
f"{self.termux_api_url}/api/sms/send",
|
||||||
json=payload,
|
json=payload,
|
||||||
|
headers={'X-API-Key': self.termux_api_key} if self.termux_api_key else {},
|
||||||
timeout=timeout
|
timeout=timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -417,7 +426,11 @@ class SMSConnectionManager:
|
|||||||
"""Get device status from available connection"""
|
"""Get device status from available connection"""
|
||||||
if self.connection_status.get(ConnectionType.TERMUX_API, False):
|
if self.connection_status.get(ConnectionType.TERMUX_API, False):
|
||||||
try:
|
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:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
return {'success': True, 'battery': data}
|
return {'success': True, 'battery': data}
|
||||||
|
|||||||
@ -21,7 +21,14 @@ class WebSocketService:
|
|||||||
cors_allowed_origins="*",
|
cors_allowed_origins="*",
|
||||||
async_mode='threading',
|
async_mode='threading',
|
||||||
logger=False,
|
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.sync_service = sync_service
|
||||||
self.connected_clients = {}
|
self.connected_clients = {}
|
||||||
|
|||||||
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
|
// SMS Campaign Manager - Dashboard JavaScript
|
||||||
|
|
||||||
|
// Global WebSocket instance (shared across all Alpine components)
|
||||||
|
let globalSocket = null;
|
||||||
|
|
||||||
function campaignApp() {
|
function campaignApp() {
|
||||||
return {
|
return {
|
||||||
// Tab management
|
// Tab management
|
||||||
@ -90,8 +94,18 @@ function campaignApp() {
|
|||||||
resettingDatabase: false,
|
resettingDatabase: false,
|
||||||
resetResult: null,
|
resetResult: null,
|
||||||
|
|
||||||
// Initialization
|
// Logout state
|
||||||
|
loggingOut: false,
|
||||||
|
_intervals: [],
|
||||||
|
|
||||||
|
// Initialization
|
||||||
async init() {
|
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
|
// Start monitoring connection status
|
||||||
await this.checkConnectionStatus();
|
await this.checkConnectionStatus();
|
||||||
await this.loadConnectionStatus();
|
await this.loadConnectionStatus();
|
||||||
@ -103,11 +117,14 @@ function campaignApp() {
|
|||||||
await this.loadRecentCampaigns();
|
await this.loadRecentCampaigns();
|
||||||
await this.loadFollowups();
|
await this.loadFollowups();
|
||||||
|
|
||||||
// Set up periodic updates
|
// Set up periodic updates (store IDs so we can clear on logout)
|
||||||
setInterval(() => this.checkConnectionStatus(), 10000); // Check every 10 seconds
|
this._intervals.push(setInterval(() => this.checkConnectionStatus(), 10000)); // Check every 10 seconds
|
||||||
setInterval(() => this.updateStatus(), 2000); // Campaign status updates
|
this._intervals.push(setInterval(() => this.updateStatus(), 2000)); // Campaign status updates
|
||||||
setInterval(() => this.loadAnalytics(), 10000); // Analytics updates
|
this._intervals.push(setInterval(() => this.loadAnalytics(), 10000)); // Analytics updates
|
||||||
setInterval(() => this.loadRecentCampaigns(), 15000); // Recent campaigns updates every 15 seconds
|
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
|
// Listen for saved list loads from the ListManager UI
|
||||||
document.addEventListener('saved-list-loaded', (e) => {
|
document.addEventListener('saved-list-loaded', (e) => {
|
||||||
@ -122,10 +139,86 @@ 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
|
// Connection management
|
||||||
async checkConnectionStatus() {
|
async checkConnectionStatus() {
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
this.phoneStatus = {
|
this.phoneStatus = {
|
||||||
...data,
|
...data,
|
||||||
@ -133,12 +226,24 @@ function campaignApp() {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Status check failed:', 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() {
|
async loadConnectionStatus() {
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
this.connectionStatus = {
|
this.connectionStatus = {
|
||||||
termux_api: data.connections?.termux_api || { available: false },
|
termux_api: data.connections?.termux_api || { available: false },
|
||||||
@ -153,7 +258,8 @@ function campaignApp() {
|
|||||||
// Data loading functions
|
// Data loading functions
|
||||||
async loadAnalytics() {
|
async loadAnalytics() {
|
||||||
try {
|
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();
|
this.analytics = await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load analytics:', error);
|
console.error('Failed to load analytics:', error);
|
||||||
@ -162,7 +268,8 @@ function campaignApp() {
|
|||||||
|
|
||||||
async loadSavedLists() {
|
async loadSavedLists() {
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
this.savedLists = data.success ? data.lists : [];
|
this.savedLists = data.success ? data.lists : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -173,7 +280,8 @@ function campaignApp() {
|
|||||||
|
|
||||||
async loadRecentCampaigns() {
|
async loadRecentCampaigns() {
|
||||||
try {
|
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();
|
const campaigns = await response.json();
|
||||||
this.recentCampaigns = campaigns || [];
|
this.recentCampaigns = campaigns || [];
|
||||||
console.log('Recent campaigns loaded:', this.recentCampaigns.length);
|
console.log('Recent campaigns loaded:', this.recentCampaigns.length);
|
||||||
@ -185,7 +293,8 @@ function campaignApp() {
|
|||||||
|
|
||||||
async loadFollowups() {
|
async loadFollowups() {
|
||||||
try {
|
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();
|
this.followups = await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load followups:', error);
|
console.error('Failed to load followups:', error);
|
||||||
@ -339,7 +448,8 @@ function campaignApp() {
|
|||||||
// Template Management Methods
|
// Template Management Methods
|
||||||
async loadSavedTemplates() {
|
async loadSavedTemplates() {
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
this.savedTemplates = data || [];
|
this.savedTemplates = data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -742,7 +852,8 @@ function campaignApp() {
|
|||||||
async updateStatus() {
|
async updateStatus() {
|
||||||
if (this.campaignState.status !== 'idle') {
|
if (this.campaignState.status !== 'idle') {
|
||||||
try {
|
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();
|
this.campaignState = await response.json();
|
||||||
|
|
||||||
if (this.campaignState.status === 'completed') {
|
if (this.campaignState.status === 'completed') {
|
||||||
@ -758,7 +869,7 @@ function campaignApp() {
|
|||||||
async testTermuxConnection() {
|
async testTermuxConnection() {
|
||||||
this.testingTermux = true;
|
this.testingTermux = true;
|
||||||
try {
|
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();
|
this.termuxTestResult = await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.termuxTestResult = { success: false, error: error.message };
|
this.termuxTestResult = { success: false, error: error.message };
|
||||||
@ -770,7 +881,7 @@ function campaignApp() {
|
|||||||
async testAdbConnection() {
|
async testAdbConnection() {
|
||||||
this.testingAdb = true;
|
this.testingAdb = true;
|
||||||
try {
|
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();
|
this.adbTestResult = await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.adbTestResult = { success: false, error: error.message };
|
this.adbTestResult = { success: false, error: error.message };
|
||||||
@ -852,12 +963,10 @@ function campaignApp() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management - placeholder for tab switching
|
||||||
loadConversations() {
|
loadConversations() {
|
||||||
// Load enhanced conversations when tab is clicked
|
// Conversations are loaded via Alpine.js conversationData() component
|
||||||
if (typeof window.conversationManager !== 'undefined') {
|
// No additional initialization needed here
|
||||||
window.conversationManager.init();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Utility functions
|
// 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!"
|
reminder: "Hi {name}! Quick reminder that we're meeting today at {time}. Looking forward to seeing you there!"
|
||||||
};
|
};
|
||||||
this.messageTemplate = templates[type] || '';
|
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
|
// Enhanced Conversations Data Function
|
||||||
function conversationData() {
|
function conversationData() {
|
||||||
return {
|
return {
|
||||||
// Initialize enhanced conversation manager
|
|
||||||
manager: null,
|
|
||||||
|
|
||||||
// State properties
|
// State properties
|
||||||
conversations: [],
|
conversations: [],
|
||||||
selectedConversation: null,
|
selectedConversation: null,
|
||||||
@ -936,13 +1068,52 @@ function conversationData() {
|
|||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
await this.loadConversations();
|
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
|
// Load conversations from API
|
||||||
async loadConversations() {
|
async loadConversations() {
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
@ -1111,7 +1282,8 @@ function conversationData() {
|
|||||||
|
|
||||||
// Trigger full sync
|
// Trigger full sync
|
||||||
await fetch('/api/responses/sync', {
|
await fetch('/api/responses/sync', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload conversations
|
// 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
|
// Handle real-time message updates
|
||||||
handleNewMessage(messageData) {
|
handleNewMessage(messageData) {
|
||||||
// Add to messages if it's for the current conversation
|
// Add to messages if it's for the current conversation
|
||||||
@ -1,105 +1,217 @@
|
|||||||
// Minimal ListManager to integrate with dashboard
|
// SMS Campaign Manager - Lists Page JavaScript
|
||||||
class ListManager {
|
|
||||||
constructor() {
|
|
||||||
this.currentList = null;
|
|
||||||
this.lists = [];
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
/**
|
||||||
await this.loadLists();
|
* Lists Alpine.js app
|
||||||
this.setupListeners();
|
*/
|
||||||
}
|
function listsApp() {
|
||||||
|
return {
|
||||||
|
// List management
|
||||||
|
savedLists: [],
|
||||||
|
listUploadName: '',
|
||||||
|
listUploadPreview: [],
|
||||||
|
viewingList: null,
|
||||||
|
viewingListContacts: [],
|
||||||
|
|
||||||
async loadLists() {
|
/**
|
||||||
try {
|
* Initialize lists app
|
||||||
const r = await fetch('/api/lists');
|
*/
|
||||||
const data = await r.json();
|
async init() {
|
||||||
if (data.success) {
|
await this.loadSavedLists();
|
||||||
this.lists = data.lists;
|
console.log('✅ Lists app initialized');
|
||||||
this.renderSelector();
|
},
|
||||||
this.renderTable();
|
|
||||||
|
/**
|
||||||
|
* 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');
|
* Handle CSV file upload for list creation
|
||||||
if (!sel) return;
|
*/
|
||||||
sel.innerHTML = '<option value="">-- Select a saved list --</option>';
|
async handleListUpload(event) {
|
||||||
this.lists.forEach(l => {
|
const file = event.target.files[0];
|
||||||
const opt = document.createElement('option');
|
if (file) {
|
||||||
opt.value = l.id;
|
const formData = new FormData();
|
||||||
opt.textContent = `${l.name} (${l.total_contacts} contacts)`;
|
formData.append('file', file);
|
||||||
sel.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTable() {
|
try {
|
||||||
const container = document.getElementById('lists-table-container');
|
const response = await fetch('/api/csv/upload', {
|
||||||
if (!container) return;
|
method: 'POST',
|
||||||
if (!this.lists || this.lists.length === 0) {
|
body: formData
|
||||||
container.innerHTML = '<p class="text-sm text-gray-500">No saved lists</p>';
|
});
|
||||||
return;
|
const data = await response.json();
|
||||||
}
|
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupListeners() {
|
if (data.success) {
|
||||||
const sel = document.getElementById('saved-lists-selector');
|
this.listUploadPreview = data.recipients.slice(0, 10);
|
||||||
if (sel) sel.addEventListener('change', async (e) => {
|
|
||||||
const id = e.target.value;
|
// Auto-save the list if no custom name is provided
|
||||||
if (!id) return;
|
if (!this.listUploadName.trim()) {
|
||||||
const r = await fetch(`/api/lists/${id}`);
|
await this.loadSavedLists();
|
||||||
const d = await r.json();
|
alert(`Contact list saved automatically as "${data.list_name}"`);
|
||||||
if (d.success) {
|
this.listUploadPreview = [];
|
||||||
const contacts = d.list.contacts || [];
|
event.target.value = ''; // Clear file input
|
||||||
// store globally for legacy usage
|
}
|
||||||
window.campaignContacts = contacts;
|
} else {
|
||||||
// notify the app that a saved list was loaded
|
alert('Error uploading file: ' + data.error);
|
||||||
document.dispatchEvent(new CustomEvent('saved-list-loaded', { detail: { contacts: contacts, list: d.list } }));
|
}
|
||||||
alert(`Loaded ${contacts.length} contacts`);
|
} catch (error) {
|
||||||
|
console.error('Upload failed:', error);
|
||||||
|
alert('Upload failed: ' + error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
|
||||||
|
|
||||||
async useList(id) {
|
/**
|
||||||
await fetch(`/api/lists/${id}/use`, {method: 'POST'});
|
* Save list from preview
|
||||||
const sel = document.getElementById('saved-lists-selector');
|
*/
|
||||||
if (sel) { sel.value = id; sel.dispatchEvent(new Event('change')); }
|
async saveListFromPreview() {
|
||||||
}
|
if (this.listUploadPreview.length === 0) {
|
||||||
|
alert('No contacts to save');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
async editList(id) {
|
try {
|
||||||
// open list detail in new tab for now
|
const listName = this.listUploadName.trim() ||
|
||||||
window.open(`/api/lists/${id}`, '_blank');
|
`Custom_List_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '_')}`;
|
||||||
}
|
|
||||||
|
|
||||||
async deleteList(id) {
|
const response = await fetch('/api/lists', {
|
||||||
if (!confirm('Delete list?')) return;
|
method: 'POST',
|
||||||
const r = await fetch(`/api/lists/${id}`, {method: 'DELETE'});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const d = await r.json();
|
body: JSON.stringify({
|
||||||
if (d.success) {
|
name: listName,
|
||||||
await this.loadLists();
|
contacts: this.listUploadPreview,
|
||||||
} else {
|
filename: 'custom_upload'
|
||||||
alert('Delete failed');
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
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.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></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>
|
</head>
|
||||||
<body class="bg-gray-50">
|
<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">
|
<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>
|
<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>
|
<p class="text-gray-600 mt-1">Homelab Campaign Management Interface</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col space-y-2">
|
<div class="flex items-center space-x-4">
|
||||||
<!-- Termux API Status -->
|
<div class="flex flex-col space-y-2">
|
||||||
<div class="flex items-center">
|
<!-- Termux API Status -->
|
||||||
<span class="text-sm font-medium text-gray-600 mr-2 w-20">Termux:</span>
|
<div class="flex items-center">
|
||||||
<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">
|
<span class="text-sm font-medium text-gray-600 mr-2 w-20">Termux:</span>
|
||||||
🟢 Online
|
<!-- Checking state (before first check completes) -->
|
||||||
</span>
|
<span x-show="!phoneStatus.last_check"
|
||||||
<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">
|
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">
|
||||||
🔴 Offline
|
⏳ Checking...
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ADB Status -->
|
<!-- Logout Link (plain HTML, no JavaScript needed) -->
|
||||||
<div class="flex items-center">
|
<a href="/api/auth/logout"
|
||||||
<span class="text-sm font-medium text-gray-600 mr-2 w-20">ADB:</span>
|
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">
|
||||||
<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">
|
Logout
|
||||||
🟢 Online
|
</a>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -67,6 +101,10 @@
|
|||||||
class="px-4 py-2 rounded-lg font-medium transition-colors">
|
class="px-4 py-2 rounded-lg font-medium transition-colors">
|
||||||
📋 Contact Lists
|
📋 Contact Lists
|
||||||
</button>
|
</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'"
|
<button @click="activeTab = 'testing'"
|
||||||
:class="activeTab === 'testing' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
: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">
|
class="px-4 py-2 rounded-lg font-medium transition-colors">
|
||||||
@ -913,11 +951,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Load JavaScript files -->
|
<!-- Load JavaScript files (cache_version auto-updates on container restart) -->
|
||||||
<script src="/static/js/dashboard.js?v=2025082505"></script>
|
<script src="/static/js/dashboard.js?v={{ cache_version }}"></script>
|
||||||
<script src="/static/js/lists.js?v=2025082505"></script>
|
<script src="/static/js/lists.js?v={{ cache_version }}"></script>
|
||||||
<script src="/static/js/conversations_enhanced.js?v=2025082505"></script>
|
<script src="/static/js/conversations_enhanced.js?v={{ cache_version }}"></script>
|
||||||
<script src="/static/js/rcs-gap-detector.js?v=2025082505"></script>
|
<script src="/static/js/rcs-gap-detector.js?v={{ cache_version }}"></script>
|
||||||
<script>
|
<script>
|
||||||
// Initialize phone IP from template
|
// Initialize phone IP from template
|
||||||
document.addEventListener('alpine:init', () => {
|
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__)
|
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:
|
class PhoneUtils:
|
||||||
"""Utility functions for phone/ADB operations"""
|
"""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