Compare commits
14 Commits
729f64f411
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d2faf80395 | |||
| 2eb413f303 | |||
| 9e4c21996b | |||
| faaddba185 | |||
| 1f0420c6b3 | |||
| ccf8b49f95 | |||
| 53f5c513d4 | |||
| 1ae080df37 | |||
| 4252c32d44 | |||
|
|
543763adf6 | ||
|
|
8dbcec974e | ||
|
|
7e28c3f365 | ||
|
|
264a81652a | ||
| e9a8f5e622 |
4
.dockerignore
Normal file → Executable file
4
.dockerignore
Normal file → Executable file
@@ -23,13 +23,15 @@ wheels/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.env
|
||||
.env.*
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
# Allow production .env file
|
||||
!.env
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
|
||||
0
.env.example
Normal file → Executable file
0
.env.example
Normal file → Executable file
41
.env.production
Executable file
41
.env.production
Executable file
@@ -0,0 +1,41 @@
|
||||
# QR Code Manager - Production Environment Configuration
|
||||
# Copy this file to .env and customize for your deployment
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your-super-secret-key-change-this-in-production-please
|
||||
|
||||
# Admin Credentials (CHANGE THESE BEFORE DEPLOYMENT!)
|
||||
ADMIN_USERNAME=admin # set your admin username
|
||||
ADMIN_PASSWORD=admin-password-here # Set a strong password
|
||||
|
||||
# Application Domain (for URL shortener)
|
||||
APP_DOMAIN=localhost:5000 # Change to your production domain, e.g., qr.[domain].com
|
||||
|
||||
# Database (Future use)
|
||||
# DATABASE_URL=sqlite:///qr_manager.db
|
||||
|
||||
# SSL/TLS Configuration (Uncomment for HTTPS)
|
||||
# SSL_KEYFILE=/path/to/your/private.key
|
||||
# SSL_CERTFILE=/path/to/your/certificate.crt
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=/app/logs/qr_manager.log
|
||||
|
||||
# Security Settings
|
||||
SESSION_COOKIE_SECURE=false
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
SESSION_COOKIE_SAMESITE=Lax
|
||||
|
||||
# Performance Settings
|
||||
UPLOAD_MAX_SIZE=10485760 # 10MB in bytes
|
||||
CACHE_TIMEOUT=3600 # 1 hour in seconds
|
||||
|
||||
# URL Shortener Settings
|
||||
SHORT_URL_LENGTH=6
|
||||
CUSTOM_DOMAIN_ENABLED=true # Enable custom domain for URL shortener and set APP_DOMAIN
|
||||
|
||||
# Health Check Settings
|
||||
HEALTH_CHECK_ENABLED=true
|
||||
HEALTH_CHECK_INTERVAL=30
|
||||
19
.env.sample
Executable file
19
.env.sample
Executable file
@@ -0,0 +1,19 @@
|
||||
# QR Code Manager Environment Configuration
|
||||
|
||||
# Application Domain - Used for URL shortener functionality
|
||||
# Examples:
|
||||
# For development: APP_DOMAIN=localhost:5000
|
||||
# For production: APP_DOMAIN=qr.moto-adv.com
|
||||
# For production with HTTPS: APP_DOMAIN=https://qr.moto-adv.com
|
||||
APP_DOMAIN=localhost:5000
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# Database Configuration (if using a database in the future)
|
||||
# DATABASE_URL=sqlite:///qr_manager.db
|
||||
|
||||
# Admin Credentials (optional override)
|
||||
# ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD_HASH=$2b$12$... # bcrypt hash of password
|
||||
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
313
BACKUP_GUIDE.md
Normal file
313
BACKUP_GUIDE.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# QR Code Manager - Backup System
|
||||
|
||||
The QR Code Manager includes a comprehensive backup system to protect your data and ensure business continuity. This document explains how to use the backup features effectively.
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
The backup system provides three types of backups:
|
||||
|
||||
- **Data Backup** 📄: QR codes, link pages, and short URLs (recommended for regular backups)
|
||||
- **Config Backup** ⚙️: Configuration files and environment settings
|
||||
- **Full Backup** 📦: Complete application backup including all files
|
||||
|
||||
## 🛠️ Backup Methods
|
||||
|
||||
### 1. Web Interface (Recommended)
|
||||
|
||||
Access the backup management interface through the main dashboard:
|
||||
|
||||
1. Login to your QR Code Manager
|
||||
2. Click the "🛡️ Backup" button in the top-right corner
|
||||
3. Use the web interface to create, download, and manage backups
|
||||
|
||||
**Features:**
|
||||
- Visual backup status dashboard
|
||||
- One-click backup creation
|
||||
- Direct backup downloads
|
||||
- Backup history and file sizes
|
||||
|
||||
### 2. Python Script
|
||||
|
||||
Use the dedicated backup script for command-line operations:
|
||||
|
||||
```bash
|
||||
# Data backup (default)
|
||||
python3 backup.py
|
||||
|
||||
# Specific backup types
|
||||
python3 backup.py --data-only
|
||||
python3 backup.py --config
|
||||
python3 backup.py --full
|
||||
|
||||
# List available backups
|
||||
python3 backup.py --list
|
||||
|
||||
# Restore from backup
|
||||
python3 backup.py --restore backup_file.tar.gz
|
||||
|
||||
# Automated backup (for cron)
|
||||
python3 backup.py --auto
|
||||
|
||||
# Cleanup old backups
|
||||
python3 backup.py --cleanup 10
|
||||
```
|
||||
|
||||
### 3. Docker Script
|
||||
|
||||
For Docker deployments, use the Docker-specific backup script:
|
||||
|
||||
```bash
|
||||
# Make script executable
|
||||
chmod +x docker_backup.sh
|
||||
|
||||
# Data backup
|
||||
./docker_backup.sh backup-data
|
||||
|
||||
# Full backup
|
||||
./docker_backup.sh backup-full
|
||||
|
||||
# List backups
|
||||
./docker_backup.sh list
|
||||
|
||||
# Restore from backup
|
||||
./docker_backup.sh restore backup_file.tar.gz
|
||||
|
||||
# Cleanup old backups
|
||||
./docker_backup.sh cleanup 5
|
||||
|
||||
# Set up automated backups
|
||||
./docker_backup.sh schedule
|
||||
```
|
||||
|
||||
## ⏰ Automated Backups
|
||||
|
||||
### Using Cron (Linux/macOS)
|
||||
|
||||
1. Set up automated backups:
|
||||
```bash
|
||||
./docker_backup.sh schedule
|
||||
```
|
||||
|
||||
2. Or manually add to crontab (`crontab -e`):
|
||||
```bash
|
||||
# Daily data backup at 2 AM
|
||||
0 2 * * * /path/to/qr-code_manager/docker_backup.sh backup-data >> /path/to/backup.log 2>&1
|
||||
|
||||
# Weekly full backup on Sundays at 3 AM
|
||||
0 3 * * 0 /path/to/qr-code_manager/docker_backup.sh backup-full >> /path/to/backup.log 2>&1
|
||||
```
|
||||
|
||||
### Using Python Auto Mode
|
||||
|
||||
The Python script includes an intelligent auto mode:
|
||||
|
||||
```bash
|
||||
# Add to cron for smart automated backups
|
||||
0 2 * * * cd /path/to/qr-code_manager && python3 backup.py --auto >> backup.log 2>&1
|
||||
```
|
||||
|
||||
**Auto mode behavior:**
|
||||
- Daily: Data backup
|
||||
- Monday: Data + Config backup
|
||||
- 1st of month: Data + Config + Full backup
|
||||
- Automatic cleanup of old backups
|
||||
|
||||
## 📁 Backup Storage
|
||||
|
||||
### Default Location
|
||||
All backups are stored in the `backups/` directory within your QR Code Manager installation.
|
||||
|
||||
### Backup Files
|
||||
- **Data backups**: `qr_data_backup_YYYYMMDD_HHMMSS.tar.gz`
|
||||
- **Config backups**: `qr_config_backup_YYYYMMDD_HHMMSS.tar.gz`
|
||||
- **Full backups**: `qr_full_backup_YYYYMMDD_HHMMSS.tar.gz`
|
||||
|
||||
### Metadata Files
|
||||
Each backup includes a JSON metadata file with:
|
||||
- Backup type and timestamp
|
||||
- Application version
|
||||
- File list and sizes
|
||||
- Backup method used
|
||||
|
||||
## 🔄 Restore Process
|
||||
|
||||
### Web Interface Restore
|
||||
Currently, the web interface provides backup download. For restoration:
|
||||
|
||||
1. Download the desired backup file
|
||||
2. Use command-line restore methods below
|
||||
|
||||
### Command Line Restore
|
||||
|
||||
```bash
|
||||
# Python script restore
|
||||
python3 backup.py --restore backup_file.tar.gz
|
||||
|
||||
# Docker script restore
|
||||
./docker_backup.sh restore backup_file.tar.gz
|
||||
```
|
||||
|
||||
### Manual Restore
|
||||
|
||||
1. Stop the application:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
2. Backup current data (safety):
|
||||
```bash
|
||||
mv data data.backup.$(date +%Y%m%d_%H%M%S)
|
||||
```
|
||||
|
||||
3. Extract backup:
|
||||
```bash
|
||||
tar xzf backup_file.tar.gz
|
||||
```
|
||||
|
||||
4. Restart application:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 🔐 Security Considerations
|
||||
|
||||
### Backup Content
|
||||
- **Data backups** contain QR codes, links, and URLs (may include sensitive information)
|
||||
- **Config backups** contain environment variables and settings (may include passwords)
|
||||
- **Full backups** contain entire application including source code
|
||||
|
||||
### Best Practices
|
||||
1. **Encrypt backups** for off-site storage
|
||||
2. **Secure backup storage** with appropriate access controls
|
||||
3. **Regular backup testing** to ensure restoration works
|
||||
4. **Monitor backup logs** for failures
|
||||
5. **Rotate backup files** to manage storage space
|
||||
|
||||
### Encryption Example
|
||||
```bash
|
||||
# Encrypt backup for storage
|
||||
gpg --symmetric --cipher-algo AES256 backup_file.tar.gz
|
||||
|
||||
# Decrypt when needed
|
||||
gpg backup_file.tar.gz.gpg
|
||||
```
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Backup Status
|
||||
Monitor backup health through:
|
||||
|
||||
1. **Web Interface**: Real-time status dashboard
|
||||
2. **Log Files**: `backup.log` for automated backups
|
||||
3. **API Endpoints**: `/api/backup/status` for integration
|
||||
|
||||
### Key Metrics
|
||||
- Total number of backups
|
||||
- Last backup timestamp
|
||||
- Data directory size
|
||||
- Total backup storage used
|
||||
- Backup success/failure rates
|
||||
|
||||
## 🚨 Disaster Recovery
|
||||
|
||||
### Complete System Failure
|
||||
|
||||
1. **Prepare new environment**:
|
||||
```bash
|
||||
git clone <your-repo>
|
||||
cd qr-code_manager
|
||||
```
|
||||
|
||||
2. **Restore from full backup**:
|
||||
```bash
|
||||
./docker_backup.sh restore qr_full_backup_latest.tar.gz
|
||||
```
|
||||
|
||||
3. **Verify configuration**:
|
||||
- Check environment variables
|
||||
- Update domain settings if needed
|
||||
- Test database connections
|
||||
|
||||
4. **Start services**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Data Corruption
|
||||
|
||||
1. **Stop services**:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
2. **Restore data only**:
|
||||
```bash
|
||||
./docker_backup.sh restore qr_data_backup_latest.tar.gz
|
||||
```
|
||||
|
||||
3. **Restart services**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Permission Errors:**
|
||||
```bash
|
||||
chmod +x backup.py docker_backup.sh
|
||||
```
|
||||
|
||||
**Docker Not Found:**
|
||||
```bash
|
||||
# Check Docker installation
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
**Backup Script Fails:**
|
||||
```bash
|
||||
# Check Python dependencies
|
||||
python3 -c "import tarfile, json, pathlib"
|
||||
|
||||
# Check disk space
|
||||
df -h
|
||||
```
|
||||
|
||||
**Large Backup Files:**
|
||||
Consider data cleanup before backup:
|
||||
```bash
|
||||
python3 clean_data.py
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. Check the application logs: `docker compose logs`
|
||||
2. Review backup logs: `tail -f backup.log`
|
||||
3. Test backup creation manually
|
||||
4. Verify file permissions and disk space
|
||||
|
||||
## 📈 Best Practices
|
||||
|
||||
### Backup Strategy
|
||||
- **Daily**: Data backups (automated)
|
||||
- **Weekly**: Config backups
|
||||
- **Monthly**: Full backups
|
||||
- **Before updates**: Create full backup
|
||||
|
||||
### Storage Management
|
||||
- Keep last 15 daily backups
|
||||
- Keep last 8 weekly backups
|
||||
- Keep last 12 monthly backups
|
||||
- Archive yearly backups off-site
|
||||
|
||||
### Testing
|
||||
- Monthly restore tests
|
||||
- Document restore procedures
|
||||
- Train team on backup/restore process
|
||||
- Test disaster recovery scenarios
|
||||
|
||||
---
|
||||
|
||||
**💡 Remember**: The best backup is one that has been tested! Regularly verify your backups can be restored successfully.
|
||||
353
BACKUP_SYSTEM.md
Normal file
353
BACKUP_SYSTEM.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# QR Code Manager - Backup System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The QR Code Manager includes a comprehensive backup system that allows users to create, manage, and restore application data. The backup system supports both manual and automated backup creation, with full lifecycle management including deletion and cleanup capabilities.
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Backup Creation
|
||||
- **Manual Backup**: Create backups on-demand through the web interface
|
||||
- **Automated Backup**: Schedule regular backups using cron jobs
|
||||
- **Comprehensive Data**: Backs up all application data including QR codes, short URLs, and link pages
|
||||
- **Timestamped Files**: Each backup includes creation timestamp for easy identification
|
||||
|
||||
### ✅ Backup Management
|
||||
- **List All Backups**: View all available backups with creation dates and file sizes
|
||||
- **Download Backups**: Download backup files directly from the web interface
|
||||
- **Delete Individual Backups**: Remove specific backup files
|
||||
- **Bulk Cleanup**: Automatically remove old backups (older than 7 days)
|
||||
- **Real-time Status**: Live updates on backup operations
|
||||
|
||||
### ✅ Data Integrity
|
||||
- **ZIP Compression**: All backups are compressed for efficient storage
|
||||
- **File Validation**: Automatic validation of backup file integrity
|
||||
- **Error Handling**: Comprehensive error reporting and recovery
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **`backup.py`** - Core backup script
|
||||
- Creates timestamped ZIP archives
|
||||
- Handles data collection and compression
|
||||
- Provides command-line interface for automation
|
||||
|
||||
2. **`docker_backup.sh`** - Docker-aware backup wrapper
|
||||
- Executes backups within Docker containers
|
||||
- Handles volume mounting and permissions
|
||||
|
||||
3. **API Endpoints** (`app/routes/api.py`)
|
||||
- `/api/backup/create` - Create new backup
|
||||
- `/api/backup/list` - List all backups
|
||||
- `/api/backup/status` - Check backup operation status
|
||||
- `/api/backup/delete/<filename>` - Delete specific backup
|
||||
- `/api/backup/cleanup` - Remove old backups
|
||||
|
||||
4. **Web Interface** (`app/templates/backup.html`)
|
||||
- User-friendly backup management dashboard
|
||||
- Real-time operation status updates
|
||||
- Confirmation dialogs for destructive operations
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Docker Environment (Recommended)
|
||||
|
||||
The backup system is pre-configured for Docker deployment:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
qr-manager:
|
||||
build: .
|
||||
ports:
|
||||
- "8066:5000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups # Backup persistence
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Ensure Python dependencies are installed:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Create backup directory:
|
||||
```bash
|
||||
mkdir -p backups
|
||||
```
|
||||
|
||||
3. Set appropriate permissions:
|
||||
```bash
|
||||
chmod +x backup.py docker_backup.sh
|
||||
```
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### Creating Backups
|
||||
|
||||
#### Via Web Interface
|
||||
1. Navigate to the backup management page
|
||||
2. Click "Create Backup"
|
||||
3. Monitor progress in real-time
|
||||
4. Download completed backup when ready
|
||||
|
||||
#### Via Command Line
|
||||
```bash
|
||||
# Direct execution
|
||||
python backup.py
|
||||
|
||||
# Docker execution
|
||||
./docker_backup.sh
|
||||
```
|
||||
|
||||
#### Via API
|
||||
```bash
|
||||
curl -X POST http://localhost:8066/api/backup/create
|
||||
```
|
||||
|
||||
### Managing Backups
|
||||
|
||||
#### List All Backups
|
||||
- **Web**: Visit backup management page
|
||||
- **API**: `GET /api/backup/list`
|
||||
|
||||
#### Download Backup
|
||||
- **Web**: Click download button next to backup
|
||||
- **Direct**: Access files in `./backups/` directory
|
||||
|
||||
#### Delete Individual Backup
|
||||
- **Web**: Click red "Delete" button with confirmation
|
||||
- **API**: `DELETE /api/backup/delete/<filename>`
|
||||
|
||||
#### Cleanup Old Backups
|
||||
- **Web**: Click "Cleanup Old Backups" button
|
||||
- **API**: `POST /api/backup/cleanup`
|
||||
|
||||
### Restoring from Backup
|
||||
|
||||
1. **Stop the application**:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
2. **Extract backup**:
|
||||
```bash
|
||||
cd data/
|
||||
unzip ../backups/backup_YYYYMMDD_HHMMSS.zip
|
||||
```
|
||||
|
||||
3. **Restart application**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Backup Contents
|
||||
|
||||
Each backup includes:
|
||||
|
||||
```
|
||||
backup_YYYYMMDD_HHMMSS.zip
|
||||
├── qr_codes.json # QR code data and metadata
|
||||
├── short_urls.json # URL shortening data
|
||||
├── link_pages.json # Link page configurations
|
||||
└── static/
|
||||
└── qr_codes/ # Generated QR code images
|
||||
├── qr_*.png
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Automation
|
||||
|
||||
### Cron Job Setup
|
||||
|
||||
Add to crontab for automated backups:
|
||||
|
||||
```bash
|
||||
# Daily backup at 2 AM
|
||||
0 2 * * * cd /opt/qr-code_manager && ./docker_backup.sh
|
||||
|
||||
# Weekly cleanup (remove backups older than 7 days)
|
||||
0 3 * * 0 cd /opt/qr-code_manager && curl -X POST http://localhost:8066/api/backup/cleanup
|
||||
```
|
||||
|
||||
### Docker Compose Automation
|
||||
|
||||
```yaml
|
||||
# Optional: Backup service with automated scheduling
|
||||
services:
|
||||
backup-scheduler:
|
||||
image: alpine:latest
|
||||
command: >
|
||||
sh -c "
|
||||
apk add --no-cache curl &&
|
||||
while true; do
|
||||
sleep 86400;
|
||||
curl -X POST http://qr-manager:5000/api/backup/create;
|
||||
done
|
||||
"
|
||||
depends_on:
|
||||
- qr-manager
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Access Control
|
||||
- Backup operations require user authentication
|
||||
- API endpoints validate user permissions
|
||||
- Backup files are stored with restricted permissions
|
||||
|
||||
### Data Protection
|
||||
- Backups contain sensitive application data
|
||||
- Store backup files in secure locations
|
||||
- Consider encryption for long-term storage
|
||||
- Implement retention policies for compliance
|
||||
|
||||
### Network Security
|
||||
- API endpoints use HTTPS in production
|
||||
- Validate all input parameters
|
||||
- Implement rate limiting for backup operations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "Backup creation failed"
|
||||
- **Cause**: Insufficient disk space or permissions
|
||||
- **Solution**: Check available space and file permissions
|
||||
- **Command**: `df -h && ls -la backups/`
|
||||
|
||||
#### "Cannot delete backup file"
|
||||
- **Cause**: File in use or permission denied
|
||||
- **Solution**: Ensure file is not being accessed
|
||||
- **Command**: `lsof | grep backup`
|
||||
|
||||
#### "Docker backup script not found"
|
||||
- **Cause**: Script not executable or missing
|
||||
- **Solution**: Check file exists and permissions
|
||||
- **Command**: `chmod +x docker_backup.sh`
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | Description | Action |
|
||||
|------|-------------|---------|
|
||||
| 200 | Success | Operation completed |
|
||||
| 400 | Bad Request | Check request parameters |
|
||||
| 404 | Not Found | Backup file doesn't exist |
|
||||
| 500 | Server Error | Check logs and permissions |
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable debug logging:
|
||||
```bash
|
||||
export FLASK_ENV=development
|
||||
export FLASK_DEBUG=1
|
||||
```
|
||||
|
||||
Check container logs:
|
||||
```bash
|
||||
docker compose logs qr-manager
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Backup Size Optimization
|
||||
- Implement incremental backups for large datasets
|
||||
- Compress QR code images efficiently
|
||||
- Exclude temporary files from backups
|
||||
|
||||
### Speed Improvements
|
||||
- Use parallel compression for large backups
|
||||
- Implement background processing for web requests
|
||||
- Cache backup status for faster UI updates
|
||||
|
||||
### Storage Management
|
||||
- Automatic cleanup of old backups
|
||||
- Configurable retention policies
|
||||
- External storage integration (S3, etc.)
|
||||
|
||||
## API Reference
|
||||
|
||||
### Create Backup
|
||||
```http
|
||||
POST /api/backup/create
|
||||
Content-Type: application/json
|
||||
|
||||
Response:
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Backup creation started",
|
||||
"backup_id": "backup_20250801_143022"
|
||||
}
|
||||
```
|
||||
|
||||
### List Backups
|
||||
```http
|
||||
GET /api/backup/list
|
||||
|
||||
Response:
|
||||
{
|
||||
"backups": [
|
||||
{
|
||||
"filename": "backup_20250801_143022.zip",
|
||||
"size": "2.5 MB",
|
||||
"created": "2025-08-01 14:30:22"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Backup
|
||||
```http
|
||||
DELETE /api/backup/delete/backup_20250801_143022.zip
|
||||
|
||||
Response:
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Backup deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup Old Backups
|
||||
```http
|
||||
POST /api/backup/cleanup
|
||||
|
||||
Response:
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Cleanup completed",
|
||||
"deleted_count": 3
|
||||
}
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 2.0.0 (August 2025)
|
||||
- ✅ Added comprehensive backup management system
|
||||
- ✅ Implemented web-based backup interface
|
||||
- ✅ Added individual backup deletion
|
||||
- ✅ Added bulk cleanup functionality
|
||||
- ✅ Enhanced Docker integration
|
||||
- ✅ Added real-time status updates
|
||||
- ✅ Improved error handling and validation
|
||||
|
||||
### Version 1.0.0 (Initial Release)
|
||||
- ✅ Basic backup creation functionality
|
||||
- ✅ Command-line backup tools
|
||||
- ✅ Docker support
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions regarding the backup system:
|
||||
|
||||
1. Check this documentation
|
||||
2. Review application logs
|
||||
3. Verify file permissions and disk space
|
||||
4. Test with minimal data set
|
||||
5. Create GitHub issue with detailed error information
|
||||
|
||||
---
|
||||
|
||||
**Note**: Always test backup and restore procedures in a development environment before implementing in production.
|
||||
113
DOMAIN_SETUP.md
Normal file
113
DOMAIN_SETUP.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# QR Code Manager - Domain Setup Guide
|
||||
|
||||
## Option 1: Using Nginx Reverse Proxy (Recommended)
|
||||
|
||||
### 1. Install Nginx
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install nginx
|
||||
```
|
||||
|
||||
### 2. Create Nginx Configuration
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/qr.moto-adv.com
|
||||
```
|
||||
|
||||
Add this configuration:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name qr.moto-adv.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8066;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Handle WebSocket connections if needed
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Enable the Site
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/qr.moto-adv.com /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 4. DNS Configuration
|
||||
Make sure your domain `qr.moto-adv.com` points to your server's IP address:
|
||||
- Add an A record: `qr.moto-adv.com -> YOUR_SERVER_IP`
|
||||
|
||||
## Option 2: Direct Docker Port Mapping
|
||||
|
||||
### Modify docker-compose.yml
|
||||
```yaml
|
||||
services:
|
||||
qr-manager:
|
||||
# ... other config
|
||||
ports:
|
||||
- "80:5000" # Map port 80 directly to container
|
||||
```
|
||||
|
||||
Then access via `http://qr.moto-adv.com` (without port number)
|
||||
|
||||
## Option 3: SSL/HTTPS Setup (Production)
|
||||
|
||||
### Using Certbot for Let's Encrypt SSL
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d qr.moto-adv.com
|
||||
```
|
||||
|
||||
## Testing Your Setup
|
||||
|
||||
1. **Test locally first:**
|
||||
```bash
|
||||
curl http://localhost:8066
|
||||
# Should redirect to /login
|
||||
```
|
||||
|
||||
2. **Test domain (after DNS is configured):**
|
||||
```bash
|
||||
curl -H "Host: qr.moto-adv.com" http://localhost:8066
|
||||
```
|
||||
|
||||
3. **Access the dashboard:**
|
||||
- Open: http://qr.moto-adv.com (or http://localhost:8066)
|
||||
- Username: ske087
|
||||
- Password: Matei@123
|
||||
|
||||
## Current Working Credentials
|
||||
|
||||
✅ **Admin Login:**
|
||||
- **Username:** `ske087`
|
||||
- **Password:** `Matei@123`
|
||||
- **URL:** http://localhost:8066/login
|
||||
|
||||
## Health Check
|
||||
Your app includes a health endpoint: http://localhost:8066/health
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Check container status:**
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs qr-manager
|
||||
```
|
||||
|
||||
2. **Check port binding:**
|
||||
```bash
|
||||
netstat -tulpn | grep 8066
|
||||
```
|
||||
|
||||
3. **Test internal connectivity:**
|
||||
```bash
|
||||
docker exec -it qr-code-manager curl http://localhost:5000/health
|
||||
```
|
||||
9
Dockerfile
Normal file → Executable file
9
Dockerfile
Normal file → Executable file
@@ -23,10 +23,13 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY main.py .
|
||||
COPY backup.py .
|
||||
COPY gunicorn.conf.py .
|
||||
COPY .env .
|
||||
COPY app/ ./app/
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p app/static/qr_codes app/static/logos flask_session
|
||||
RUN mkdir -p app/static/qr_codes app/static/logos flask_session data
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=main.py
|
||||
@@ -45,5 +48,5 @@ EXPOSE 5000
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import requests; requests.get('http://localhost:5000/health')" || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "main.py"]
|
||||
# Run the application with Gunicorn for production
|
||||
CMD ["gunicorn", "-c", "gunicorn.conf.py", "main:app"]
|
||||
|
||||
89
IMPLEMENTATION_SUMMARY.md
Normal file
89
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# QR Code Manager - Backup System Implementation Summary
|
||||
|
||||
## 🎯 Project Completion Status: ✅ COMPLETE
|
||||
|
||||
### 📋 Original Requirements
|
||||
✅ **Fixed backup creation errors** - "on the docker image in the web interface if i hit a anii backup creation i have an error message"
|
||||
✅ **Added delete functionality** - "lets add alsow the ability to delete backups that were older not only to download them from the manage backup page"
|
||||
|
||||
### 🚀 Delivered Features
|
||||
|
||||
#### 1. **Complete Backup Lifecycle Management**
|
||||
- ✅ Create backups (web interface + API + CLI)
|
||||
- ✅ List all backups with timestamps and file sizes
|
||||
- ✅ Download backups directly from web interface
|
||||
- ✅ Delete individual backups with confirmation dialogs
|
||||
- ✅ Bulk cleanup of old backups (>7 days)
|
||||
- ✅ Real-time status updates during operations
|
||||
|
||||
#### 2. **Docker Integration Fixes**
|
||||
- ✅ Fixed missing `backup.py` in Docker container
|
||||
- ✅ Added Docker-aware path detection
|
||||
- ✅ Enhanced volume mounting for backup persistence
|
||||
- ✅ Container health verification
|
||||
|
||||
#### 3. **Web Interface Enhancements**
|
||||
- ✅ Dedicated backup management page
|
||||
- ✅ Modern UI with Bootstrap styling
|
||||
- ✅ Real-time progress indicators
|
||||
- ✅ User confirmation for destructive operations
|
||||
- ✅ Error handling and user feedback
|
||||
|
||||
#### 4. **API Endpoints**
|
||||
- ✅ `POST /api/backup/create` - Create new backup
|
||||
- ✅ `GET /api/backup/list` - List all backups
|
||||
- ✅ `GET /api/backup/status` - Check operation status
|
||||
- ✅ `DELETE /api/backup/delete/<filename>` - Delete specific backup
|
||||
- ✅ `POST /api/backup/cleanup` - Remove old backups
|
||||
|
||||
#### 5. **Documentation & Repository**
|
||||
- ✅ Comprehensive `BACKUP_SYSTEM.md` documentation
|
||||
- ✅ Updated `README.md` with backup features
|
||||
- ✅ All files committed and pushed to repository
|
||||
- ✅ Detailed commit history with changelog
|
||||
|
||||
### 📊 Technical Implementation
|
||||
|
||||
#### Files Added/Modified:
|
||||
```
|
||||
✅ backup.py - Core backup script
|
||||
✅ docker_backup.sh - Docker wrapper script
|
||||
✅ app/templates/backup.html - Web management interface
|
||||
✅ app/routes/api.py - Enhanced with backup endpoints
|
||||
✅ app/routes/main.py - Added backup route
|
||||
✅ app/templates/index.html - Added backup navigation
|
||||
✅ Dockerfile - Fixed backup script inclusion
|
||||
✅ docker-compose.yml - Added backup volume mount
|
||||
✅ BACKUP_SYSTEM.md - Complete documentation
|
||||
✅ README.md - Updated with backup info
|
||||
```
|
||||
|
||||
#### Key Technical Solutions:
|
||||
- **Docker Path Detection**: Automatic detection of Docker vs development environment
|
||||
- **Volume Persistence**: Backup files persist between container restarts
|
||||
- **Error Handling**: Comprehensive error reporting and recovery
|
||||
- **Security**: User authentication and operation confirmation
|
||||
- **Performance**: Background processing and real-time updates
|
||||
|
||||
### 🎉 Result Summary
|
||||
|
||||
**Before**: Backup creation failed with HTTP 500 errors, no delete functionality
|
||||
**After**: Complete backup management system with creation, deletion, cleanup, and comprehensive documentation
|
||||
|
||||
### 🚀 Live System Status
|
||||
- ✅ Container running and healthy on port 8066
|
||||
- ✅ All backup endpoints functional
|
||||
- ✅ Web interface accessible and working
|
||||
- ✅ Volume mounting configured for persistence
|
||||
- ✅ All files committed to repository
|
||||
|
||||
### 📖 Next Steps for User
|
||||
1. Access backup management at: http://localhost:8066 → Login → Backup Management
|
||||
2. Test backup creation, deletion, and cleanup features
|
||||
3. Set up automated backups using provided cron examples
|
||||
4. Review `BACKUP_SYSTEM.md` for advanced configuration options
|
||||
|
||||
---
|
||||
**Implementation Date**: August 1, 2025
|
||||
**Status**: ✅ Production Ready
|
||||
**Repository**: All changes committed and pushed
|
||||
406
README.md
Normal file → Executable file
406
README.md
Normal file → Executable file
@@ -6,9 +6,16 @@ A modern Flask web application for generating and managing QR codes with authent
|
||||
|
||||
- **Multiple QR Code Types**: Text, URL, WiFi, Email, SMS, vCard
|
||||
- **Dynamic Link Pages**: Create collections of links accessible via QR codes
|
||||
- **URL Shortener**: Generate shortened URLs with custom domains and QR codes
|
||||
- **Admin Authentication**: Secure login with bcrypt password hashing
|
||||
- **Customizable Styling**: Different QR code styles (square, rounded, circle)
|
||||
- **Logo Integration**: Add custom logos to QR codes
|
||||
- **Click Tracking**: Monitor short URL usage and statistics
|
||||
- **🆕 Comprehensive Backup System**: Full backup lifecycle management
|
||||
- Create backups on-demand or automated
|
||||
- Download, delete, and cleanup old backups
|
||||
- Web-based backup management interface
|
||||
- Docker-aware backup operations
|
||||
- **Docker Deployment**: Production-ready containerization
|
||||
- **Responsive Design**: Modern web interface that works on all devices
|
||||
|
||||
@@ -43,6 +50,7 @@ qr-code_manager/
|
||||
│ ├── auth.py # Authentication utilities
|
||||
│ ├── qr_generator.py # QR code generation
|
||||
│ ├── link_manager.py # Dynamic link management
|
||||
│ ├── url_shortener.py # URL shortening utilities
|
||||
│ └── data_manager.py # Data storage utilities
|
||||
```
|
||||
|
||||
@@ -89,7 +97,180 @@ qr-code_manager/
|
||||
python main.py
|
||||
```
|
||||
|
||||
## 🔐 Authentication
|
||||
## <EFBFBD> Production vs Development Modes
|
||||
|
||||
The QR Code Manager supports two distinct runtime modes with different behaviors and optimizations.
|
||||
|
||||
### 🛠️ Development Mode (Default)
|
||||
|
||||
**When it runs:**
|
||||
- When `FLASK_ENV` is not set to "production"
|
||||
- When running `python main.py` locally
|
||||
- Default mode for local development
|
||||
|
||||
**Characteristics:**
|
||||
- Uses Flask's built-in development server
|
||||
- Debug mode enabled with auto-reload
|
||||
- Detailed error messages and stack traces
|
||||
- Console shows default login credentials
|
||||
- Not suitable for production use
|
||||
|
||||
**How to run:**
|
||||
```bash
|
||||
# Method 1: Direct execution
|
||||
python main.py
|
||||
|
||||
# Method 2: With explicit development environment
|
||||
FLASK_ENV=development python main.py
|
||||
|
||||
# Method 3: Using environment file
|
||||
echo "FLASK_ENV=development" > .env
|
||||
python main.py
|
||||
```
|
||||
|
||||
**Console output in development:**
|
||||
```
|
||||
🛠️ Starting QR Code Manager in DEVELOPMENT mode
|
||||
🔐 Admin user: admin
|
||||
🔑 Default password: admin123
|
||||
🌐 Domain configured: localhost:5000
|
||||
🔗 URL shortener available at: /s/
|
||||
* Serving Flask app 'app'
|
||||
* Debug mode: on
|
||||
* Running on all addresses (0.0.0.0)
|
||||
* Running on http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
### 🚀 Production Mode
|
||||
|
||||
**When it runs:**
|
||||
- When `FLASK_ENV=production` is set
|
||||
- When deployed with Docker (automatic)
|
||||
- For live/production deployments
|
||||
|
||||
**Characteristics:**
|
||||
- Uses Gunicorn WSGI server (4 workers)
|
||||
- Debug mode disabled
|
||||
- Optimized for performance and security
|
||||
- No default credentials shown
|
||||
- Production-grade error handling
|
||||
|
||||
**How to run:**
|
||||
|
||||
#### Option 1: Docker (Recommended)
|
||||
```bash
|
||||
# Copy and edit production environment
|
||||
cp .env.production .env
|
||||
# Edit .env with your production settings
|
||||
|
||||
# Deploy with Docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Option 2: Manual Gunicorn
|
||||
```bash
|
||||
# Set production environment
|
||||
export FLASK_ENV=production
|
||||
export SECRET_KEY=your-super-secret-key
|
||||
export ADMIN_USERNAME=your-admin-username
|
||||
export ADMIN_PASSWORD=your-secure-password
|
||||
export APP_DOMAIN=your-domain.com
|
||||
|
||||
# Run with Gunicorn
|
||||
gunicorn -c gunicorn.conf.py main:app
|
||||
```
|
||||
|
||||
#### Option 3: Environment File + Gunicorn
|
||||
```bash
|
||||
# Create production environment file
|
||||
cp .env.production .env
|
||||
|
||||
# Edit .env with your settings:
|
||||
# FLASK_ENV=production
|
||||
# SECRET_KEY=your-super-secret-key
|
||||
# ADMIN_USERNAME=your-admin-username
|
||||
# ADMIN_PASSWORD=your-secure-password
|
||||
# APP_DOMAIN=your-domain.com
|
||||
|
||||
# Run with Gunicorn
|
||||
gunicorn -c gunicorn.conf.py main:app
|
||||
```
|
||||
|
||||
**Console output in production:**
|
||||
```
|
||||
Admin user initialized: your-admin-username
|
||||
Default password: your-secure-password
|
||||
[2025-07-16 17:27:27 +0000] [1] [INFO] Starting gunicorn 21.2.0
|
||||
[2025-07-16 17:27:27 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000 (1)
|
||||
[2025-07-16 17:27:27 +0000] [1] [INFO] Using worker: sync
|
||||
[2025-07-16 17:27:27 +0000] [7] [INFO] Booting worker with pid: 7
|
||||
[2025-07-16 17:27:27 +0000] [8] [INFO] Booting worker with pid: 8
|
||||
[2025-07-16 17:27:27 +0000] [9] [INFO] Booting worker with pid: 9
|
||||
[2025-07-16 17:27:27 +0000] [10] [INFO] Booting worker with pid: 10
|
||||
```
|
||||
|
||||
### 🔧 Environment Configuration
|
||||
|
||||
Create a `.env` file in the project root with your configuration:
|
||||
|
||||
**For Development:**
|
||||
```bash
|
||||
# Development settings
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=dev-secret-key
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
APP_DOMAIN=localhost:5000
|
||||
```
|
||||
|
||||
**For Production:**
|
||||
```bash
|
||||
# Production settings (copy from .env.production and customize)
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your-super-secret-key-change-this
|
||||
ADMIN_USERNAME=your-admin-username
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
APP_DOMAIN=your-domain.com
|
||||
|
||||
# Security settings
|
||||
SESSION_COOKIE_SECURE=true # Set to true for HTTPS
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
SESSION_COOKIE_SAMESITE=Lax
|
||||
|
||||
# Performance settings
|
||||
UPLOAD_MAX_SIZE=10485760 # 10MB
|
||||
CACHE_TIMEOUT=3600 # 1 hour
|
||||
```
|
||||
|
||||
### 🛡️ Security Considerations
|
||||
|
||||
**Development Mode:**
|
||||
- ⚠️ Never use in production
|
||||
- Default credentials are visible
|
||||
- Debug information exposed
|
||||
- Single-threaded server
|
||||
|
||||
**Production Mode:**
|
||||
- ✅ Use Gunicorn WSGI server
|
||||
- ✅ Change default credentials
|
||||
- ✅ Use strong SECRET_KEY
|
||||
- ✅ Enable HTTPS when possible
|
||||
- ✅ Set secure cookie flags
|
||||
- ✅ Multiple worker processes
|
||||
|
||||
### 📊 Performance Comparison
|
||||
|
||||
| Feature | Development | Production |
|
||||
|---------|-------------|------------|
|
||||
| Server | Flask dev server | Gunicorn (4 workers) |
|
||||
| Performance | Basic | Optimized |
|
||||
| Concurrency | Single-threaded | Multi-worker |
|
||||
| Auto-reload | Yes | No |
|
||||
| Debug info | Full | Minimal |
|
||||
| Error handling | Verbose | Secure |
|
||||
| Session security | Basic | Enhanced |
|
||||
|
||||
## <20>🔐 Authentication
|
||||
|
||||
- **Default Credentials**: admin / admin123
|
||||
- **Environment Variables**:
|
||||
@@ -114,6 +295,7 @@ The application is fully containerized with Docker:
|
||||
SECRET_KEY=your-super-secret-key-here
|
||||
ADMIN_USERNAME=your-admin-username
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
APP_DOMAIN=[your-domain] # Your custom domain for URL shortener
|
||||
```
|
||||
|
||||
2. **Deploy:**
|
||||
@@ -121,6 +303,100 @@ The application is fully containerized with Docker:
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### URL Shortener Configuration
|
||||
|
||||
The URL shortener feature uses the `APP_DOMAIN` environment variable to generate short URLs:
|
||||
|
||||
- **Development**: `APP_DOMAIN=localhost:5000`
|
||||
- **Production**: `APP_DOMAIN=[your-domain]` or `APP_DOMAIN=https://[your-domain]`
|
||||
|
||||
Short URLs will be available at: `https://[your-domain]/s/[short-code]`
|
||||
|
||||
## 🌐 Reverse Proxy Configuration (Nginx)
|
||||
|
||||
When deploying behind a reverse proxy like Nginx Proxy Manager or standard Nginx, configure the proxy to route **HTTPS traffic to HTTP backend**:
|
||||
|
||||
### ⚠️ Important Proxy Configuration
|
||||
|
||||
- **Frontend (Public)**: HTTPS (Port 443)
|
||||
- **Backend (Docker)**: HTTP (Port 8066)
|
||||
- **SSL Termination**: At the proxy level
|
||||
|
||||
### Nginx Proxy Manager Setup
|
||||
|
||||
1. **Create Proxy Host**:
|
||||
- Domain: `[your-domain]` (your domain)
|
||||
- Forward Hostname/IP: `your-server-ip`
|
||||
- Forward Port: `8066`
|
||||
- **Protocol**: HTTP (not HTTPS)
|
||||
|
||||
2. **SSL Configuration**:
|
||||
- Enable SSL certificate (Let's Encrypt)
|
||||
- Force SSL: Yes
|
||||
- HTTP/2 Support: Yes
|
||||
|
||||
3. **Advanced Settings** (if needed):
|
||||
```nginx
|
||||
# Custom Nginx configuration
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
### Standard Nginx Configuration
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name [your-domain];
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /path/to/ssl/cert.pem;
|
||||
ssl_certificate_key /path/to/ssl/private.key;
|
||||
|
||||
# Proxy to Docker container
|
||||
location / {
|
||||
proxy_pass http://localhost:8066; # Note: HTTP, not HTTPS
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name [your-domain];
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Common Proxy Issues & Solutions
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| 502 Bad Gateway | HTTPS→HTTPS routing | Change backend to HTTP |
|
||||
| SSL errors | Missing certificates | Configure SSL at proxy level |
|
||||
| 404 on subpaths | Missing headers | Add proxy headers |
|
||||
| Session issues | Domain mismatch | Set correct `APP_DOMAIN` |
|
||||
|
||||
### Docker Configuration for Proxy
|
||||
|
||||
Ensure your `docker-compose.yml` exposes the correct port:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
qr-manager:
|
||||
ports:
|
||||
- "8066:5000" # Maps container port 5000 to host port 8066
|
||||
environment:
|
||||
- APP_DOMAIN=[your-domain] # Your public domain
|
||||
```
|
||||
|
||||
## 📱 Usage
|
||||
|
||||
### Generating QR Codes
|
||||
@@ -139,6 +415,18 @@ The application is fully containerized with Docker:
|
||||
3. **Share the QR code** that points to your link page
|
||||
4. **Update links anytime** without changing the QR code
|
||||
|
||||
### URL Shortener
|
||||
|
||||
1. **Create shortened URLs** with optional custom codes
|
||||
2. **Generate QR codes** for shortened URLs
|
||||
3. **Track clicks** and monitor usage statistics
|
||||
4. **Redirect seamlessly** from short URLs to original destinations
|
||||
5. **Integrate with link pages** by enabling shortener for individual links
|
||||
|
||||
**Examples:**
|
||||
- Original: `https://very-long-domain.com/extremely/long/path/to/resource`
|
||||
- Short: `https://[your-domain]/s/abc123`
|
||||
|
||||
## 🛡️ Security Features
|
||||
|
||||
- **Password Hashing**: Uses bcrypt for secure password storage
|
||||
@@ -168,15 +456,28 @@ The application follows a modular Flask structure:
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
### QR Code Management
|
||||
- `POST /api/generate` - Generate QR code
|
||||
- `GET /api/qr_codes` - List all QR codes
|
||||
- `GET /api/qr_codes/{id}` - Get specific QR code
|
||||
- `DELETE /api/qr_codes/{id}` - Delete QR code
|
||||
|
||||
### Dynamic Link Pages
|
||||
- `POST /api/create_link_page` - Create dynamic link page
|
||||
- `POST /api/link_pages/{id}/links` - Add link to page
|
||||
- `PUT /api/link_pages/{id}/links/{link_id}` - Update link
|
||||
- `DELETE /api/link_pages/{id}/links/{link_id}` - Delete link
|
||||
|
||||
### URL Shortener
|
||||
- `POST /api/shorten` - Create standalone short URL
|
||||
- `POST /api/generate_shortened_qr` - Generate QR code with short URL
|
||||
- `GET /api/short_urls` - List all short URLs
|
||||
- `GET /api/short_urls/{code}/stats` - Get short URL statistics
|
||||
- `GET /s/{code}` - Redirect short URL to original
|
||||
- `POST /api/link_pages/{id}/links` - Add link to page
|
||||
- `PUT /api/link_pages/{id}/links/{link_id}` - Update link
|
||||
- `DELETE /api/link_pages/{id}/links/{link_id}` - Delete link
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
@@ -197,6 +498,60 @@ curl http://localhost:5000/health
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## 💾 Backup & Restore System
|
||||
|
||||
The QR Code Manager includes a comprehensive backup system for data protection and migration.
|
||||
|
||||
### Quick Start
|
||||
|
||||
**Create Backup:**
|
||||
- Via Web: Navigate to backup management page → "Create Backup"
|
||||
- Via API: `curl -X POST http://localhost:8066/api/backup/create`
|
||||
- Via CLI: `python backup.py` or `./docker_backup.sh`
|
||||
|
||||
**Manage Backups:**
|
||||
- **List**: View all backups with timestamps and sizes
|
||||
- **Download**: Get backup files directly from web interface
|
||||
- **Delete**: Remove individual backups with confirmation
|
||||
- **Cleanup**: Bulk remove backups older than 7 days
|
||||
|
||||
### What Gets Backed Up
|
||||
|
||||
Each backup includes:
|
||||
- ✅ All QR code data and metadata
|
||||
- ✅ Short URL database
|
||||
- ✅ Dynamic link page configurations
|
||||
- ✅ Generated QR code images
|
||||
- ✅ Application settings
|
||||
|
||||
### Restore Process
|
||||
|
||||
```bash
|
||||
# 1. Stop application
|
||||
docker compose down
|
||||
|
||||
# 2. Extract backup to data directory
|
||||
cd data/
|
||||
unzip ../backups/backup_20250801_143022.zip
|
||||
|
||||
# 3. Restart application
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Automated Backups
|
||||
|
||||
**Daily Backup (2 AM):**
|
||||
```bash
|
||||
0 2 * * * cd /opt/qr-code_manager && ./docker_backup.sh
|
||||
```
|
||||
|
||||
**Weekly Cleanup:**
|
||||
```bash
|
||||
0 3 * * 0 curl -X POST http://localhost:8066/api/backup/cleanup
|
||||
```
|
||||
|
||||
> 📖 **Detailed Documentation**: See [BACKUP_SYSTEM.md](BACKUP_SYSTEM.md) for complete backup system documentation.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
@@ -205,6 +560,55 @@ This project is licensed under the MIT License - see the LICENSE file for detail
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## 🧹 Data Cleanup for Deployment
|
||||
|
||||
When preparing for a fresh deployment or when you need to clear all data, use the provided cleanup scripts:
|
||||
|
||||
### Option 1: Python Script (Recommended)
|
||||
|
||||
```bash
|
||||
python clean_data.py
|
||||
```
|
||||
|
||||
### Option 2: Shell Script (Quick)
|
||||
|
||||
```bash
|
||||
./clean_data.sh
|
||||
```
|
||||
|
||||
### What Gets Cleaned
|
||||
|
||||
Both scripts will remove:
|
||||
|
||||
- **All QR codes and their data** - Clears the QR codes database and deletes all generated PNG images
|
||||
- **All dynamic link pages** - Removes all link collections and their settings
|
||||
- **All short URLs** - Clears the URL shortener database
|
||||
- **All Flask sessions** - Removes user session files
|
||||
- **All log files** - Deletes any application log files
|
||||
- **Python cache files** - Removes `__pycache__` directories and `.pyc` files
|
||||
|
||||
### Safety Features
|
||||
|
||||
- **Confirmation prompt** - Both scripts require typing 'YES' to confirm the action
|
||||
- **Directory preservation** - Required directories are recreated after cleanup
|
||||
- **Error handling** - Scripts handle missing files/directories gracefully
|
||||
|
||||
### Post-Cleanup Steps
|
||||
|
||||
After running the cleanup script:
|
||||
|
||||
1. Start the application: `python main.py`
|
||||
2. Login with default credentials: `admin` / `admin123`
|
||||
3. **Important**: Change the default admin password immediately
|
||||
4. Begin creating your QR codes and link pages
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Fresh deployment** - Clean slate for production deployment
|
||||
- **Development reset** - Clear test data during development
|
||||
- **Data migration** - Prepare for moving to a new system
|
||||
- **Security cleanup** - Remove all data when decommissioning
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For support, please open an issue on GitHub or contact the development team.
|
||||
|
||||
6
app/__init__.py
Normal file → Executable file
6
app/__init__.py
Normal file → Executable file
@@ -4,11 +4,15 @@ A modern Flask web application for generating and managing QR codes with authent
|
||||
"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from flask_session import Session
|
||||
from app.utils.auth import init_admin
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def create_app():
|
||||
"""Create and configure the Flask application"""
|
||||
app = Flask(__name__)
|
||||
@@ -18,6 +22,8 @@ def create_app():
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
app.config['SESSION_PERMANENT'] = False
|
||||
app.config['SESSION_USE_SIGNER'] = True
|
||||
app.config['SESSION_FILE_DIR'] = '/tmp/flask_session' # Use /tmp for sessions
|
||||
app.config['SESSION_FILE_THRESHOLD'] = 500
|
||||
|
||||
# Initialize CORS
|
||||
CORS(app)
|
||||
|
||||
0
app/routes/__init__.py
Normal file → Executable file
0
app/routes/__init__.py
Normal file → Executable file
509
app/routes/api.py
Normal file → Executable file
509
app/routes/api.py
Normal file → Executable file
@@ -7,7 +7,7 @@ import io
|
||||
import base64
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from flask import Blueprint, request, jsonify, send_file, redirect, Response, current_app
|
||||
from app.utils.auth import login_required
|
||||
from app.utils.qr_generator import QRCodeGenerator
|
||||
from app.utils.link_manager import LinkPageManager
|
||||
@@ -20,9 +20,11 @@ qr_generator = QRCodeGenerator()
|
||||
link_manager = LinkPageManager()
|
||||
data_manager = QRDataManager()
|
||||
|
||||
# Configuration for file uploads
|
||||
UPLOAD_FOLDER = 'app/static/qr_codes'
|
||||
LOGOS_FOLDER = 'app/static/logos'
|
||||
# Configuration for file uploads - use paths relative to app root
|
||||
UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '..', 'static', 'qr_codes')
|
||||
LOGOS_FOLDER = os.path.join(os.path.dirname(__file__), '..', 'static', 'logos')
|
||||
UPLOAD_FOLDER = os.path.abspath(UPLOAD_FOLDER)
|
||||
LOGOS_FOLDER = os.path.abspath(LOGOS_FOLDER)
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
os.makedirs(LOGOS_FOLDER, exist_ok=True)
|
||||
|
||||
@@ -107,7 +109,7 @@ END:VCARD"""
|
||||
@bp.route('/download/<qr_id>')
|
||||
@login_required
|
||||
def download_qr(qr_id):
|
||||
"""Download QR code"""
|
||||
"""Download QR code in PNG format"""
|
||||
try:
|
||||
img_path = os.path.join(UPLOAD_FOLDER, f'{qr_id}.png')
|
||||
if os.path.exists(img_path):
|
||||
@@ -117,6 +119,32 @@ def download_qr(qr_id):
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/download/<qr_id>/svg')
|
||||
@login_required
|
||||
def download_qr_svg(qr_id):
|
||||
"""Download QR code in SVG format"""
|
||||
try:
|
||||
# Get QR code data from database
|
||||
qr_data = data_manager.get_qr_code(qr_id)
|
||||
if not qr_data:
|
||||
return jsonify({'error': 'QR code not found'}), 404
|
||||
|
||||
# Regenerate QR code as SVG
|
||||
settings = qr_data.get('settings', {})
|
||||
content = qr_data.get('content', '')
|
||||
|
||||
# Generate SVG QR code
|
||||
svg_string = qr_generator.generate_qr_code_svg_string(content, settings)
|
||||
|
||||
# Create a response with SVG content
|
||||
response = Response(svg_string, mimetype='image/svg+xml')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=qr_code_{qr_id}.svg'
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/qr_codes')
|
||||
@login_required
|
||||
def list_qr_codes():
|
||||
@@ -188,15 +216,34 @@ def upload_logo():
|
||||
def create_link_page():
|
||||
"""Create a new dynamic link page and QR code"""
|
||||
try:
|
||||
print("DEBUG: Starting create_link_page")
|
||||
data = request.json
|
||||
title = data.get('title', 'My Links')
|
||||
description = data.get('description', 'Collection of useful links')
|
||||
print(f"DEBUG: Creating link page with title='{title}', description='{description}'")
|
||||
|
||||
# Create the link page
|
||||
page_id = link_manager.create_link_page(title, description)
|
||||
print(f"DEBUG: Created link page with ID: {page_id}")
|
||||
|
||||
# Create QR code pointing to the link page
|
||||
page_url = f"{request.url_root}links/{page_id}"
|
||||
# Create the original page URL
|
||||
original_page_url = f"{request.url_root}links/{page_id}"
|
||||
print(f"DEBUG: Original page URL: {original_page_url}")
|
||||
|
||||
# Automatically create a short URL for the link page
|
||||
print(f"DEBUG: Creating short URL for: {original_page_url}")
|
||||
short_result = link_manager.create_standalone_short_url(
|
||||
original_page_url,
|
||||
title=f"Link Page: {title}",
|
||||
custom_code=None
|
||||
)
|
||||
print(f"DEBUG: Short URL result: {short_result}")
|
||||
short_page_url = short_result['short_url']
|
||||
|
||||
# Store the short URL info with the page
|
||||
print(f"DEBUG: Setting page short URL: {short_page_url}, code: {short_result['short_code']}")
|
||||
link_manager.set_page_short_url(page_id, short_page_url, short_result['short_code'])
|
||||
print(f"DEBUG: Page short URL set successfully")
|
||||
|
||||
settings = {
|
||||
'size': data.get('size', 10),
|
||||
@@ -206,8 +253,8 @@ def create_link_page():
|
||||
'style': data.get('style', 'square')
|
||||
}
|
||||
|
||||
# Generate QR code
|
||||
qr_img = qr_generator.generate_qr_code(page_url, settings)
|
||||
# Generate QR code pointing to the SHORT URL (not the original long URL)
|
||||
qr_img = qr_generator.generate_qr_code(short_page_url, settings)
|
||||
|
||||
# Convert to base64
|
||||
img_buffer = io.BytesIO()
|
||||
@@ -215,8 +262,8 @@ def create_link_page():
|
||||
img_buffer.seek(0)
|
||||
img_base64 = base64.b64encode(img_buffer.getvalue()).decode()
|
||||
|
||||
# Save QR code record
|
||||
qr_id = data_manager.save_qr_record('link_page', page_url, settings, img_base64, page_id)
|
||||
# Save QR code record with the short URL
|
||||
qr_id = data_manager.save_qr_record('link_page', short_page_url, settings, img_base64, page_id)
|
||||
|
||||
# Save image file
|
||||
img_path = os.path.join(UPLOAD_FOLDER, f'{qr_id}.png')
|
||||
@@ -226,7 +273,9 @@ def create_link_page():
|
||||
'success': True,
|
||||
'qr_id': qr_id,
|
||||
'page_id': page_id,
|
||||
'page_url': page_url,
|
||||
'page_url': short_page_url, # Return the short URL as the main page URL
|
||||
'original_url': original_page_url, # Keep original for reference
|
||||
'short_code': short_result['short_code'],
|
||||
'edit_url': f"{request.url_root}edit/{page_id}",
|
||||
'image_data': f'data:image/png;base64,{img_base64}',
|
||||
'download_url': f'/api/download/{qr_id}'
|
||||
@@ -240,22 +289,36 @@ def create_link_page():
|
||||
def add_link_to_page(page_id):
|
||||
"""Add a link to a page"""
|
||||
try:
|
||||
print(f"DEBUG: Adding link to page {page_id}")
|
||||
data = request.json
|
||||
print(f"DEBUG: Request data: {data}")
|
||||
|
||||
title = data.get('title', '')
|
||||
url = data.get('url', '')
|
||||
description = data.get('description', '')
|
||||
enable_shortener = data.get('enable_shortener', False)
|
||||
custom_short_code = data.get('custom_short_code', None)
|
||||
|
||||
if not title or not url:
|
||||
print("DEBUG: Missing title or URL")
|
||||
return jsonify({'error': 'Title and URL are required'}), 400
|
||||
|
||||
success = link_manager.add_link(page_id, title, url, description)
|
||||
print(f"DEBUG: Calling link_manager.add_link with shortener={enable_shortener}")
|
||||
success = link_manager.add_link(
|
||||
page_id, title, url, description,
|
||||
enable_shortener=enable_shortener,
|
||||
custom_short_code=custom_short_code
|
||||
)
|
||||
|
||||
if success:
|
||||
print("DEBUG: Link added successfully")
|
||||
return jsonify({'success': True})
|
||||
else:
|
||||
print(f"DEBUG: Failed to add link - page {page_id} not found")
|
||||
return jsonify({'error': 'Page not found'}), 404
|
||||
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Exception in add_link_to_page: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/link_pages/<page_id>/links/<link_id>', methods=['PUT'])
|
||||
@@ -267,8 +330,14 @@ def update_link_in_page(page_id, link_id):
|
||||
title = data.get('title')
|
||||
url = data.get('url')
|
||||
description = data.get('description')
|
||||
enable_shortener = data.get('enable_shortener')
|
||||
custom_short_code = data.get('custom_short_code')
|
||||
|
||||
success = link_manager.update_link(page_id, link_id, title, url, description)
|
||||
success = link_manager.update_link(
|
||||
page_id, link_id, title, url, description,
|
||||
enable_shortener=enable_shortener,
|
||||
custom_short_code=custom_short_code
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({'success': True})
|
||||
@@ -302,3 +371,415 @@ def get_link_page(page_id):
|
||||
return jsonify(page_data)
|
||||
else:
|
||||
return jsonify({'error': 'Page not found'}), 404
|
||||
|
||||
# URL Shortener API Routes
|
||||
|
||||
@bp.route('/shorten', methods=['POST'])
|
||||
@login_required
|
||||
def create_short_url():
|
||||
"""Create a shortened URL"""
|
||||
try:
|
||||
data = request.json
|
||||
url = data.get('url', '')
|
||||
title = data.get('title', '')
|
||||
custom_code = data.get('custom_code', None)
|
||||
|
||||
if not url:
|
||||
return jsonify({'error': 'URL is required'}), 400
|
||||
|
||||
result = link_manager.create_standalone_short_url(url, title, custom_code)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'short_url': result['short_url'],
|
||||
'short_code': result['short_code'],
|
||||
'original_url': result['original_url']
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/short_urls')
|
||||
@login_required
|
||||
def list_short_urls():
|
||||
"""List all shortened URLs"""
|
||||
try:
|
||||
urls = link_manager.list_all_short_urls()
|
||||
return jsonify({'success': True, 'urls': urls})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/short_urls/<short_code>/stats')
|
||||
@login_required
|
||||
def get_short_url_stats(short_code):
|
||||
"""Get statistics for a short URL"""
|
||||
try:
|
||||
stats = link_manager.get_short_url_stats(short_code)
|
||||
if stats:
|
||||
return jsonify({'success': True, 'stats': stats})
|
||||
else:
|
||||
return jsonify({'error': 'Short URL not found'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/generate_shortened_qr', methods=['POST'])
|
||||
@login_required
|
||||
def generate_shortened_qr():
|
||||
"""Generate QR code for a shortened URL"""
|
||||
try:
|
||||
data = request.json
|
||||
shortener_data = data.get('shortener', {})
|
||||
url = shortener_data.get('url', '')
|
||||
title = shortener_data.get('title', '')
|
||||
custom_code = shortener_data.get('custom_code', '').strip() or None
|
||||
|
||||
if not url:
|
||||
return jsonify({'error': 'URL is required'}), 400
|
||||
|
||||
# Create shortened URL
|
||||
result = link_manager.create_standalone_short_url(url, title, custom_code)
|
||||
short_url = result['short_url']
|
||||
|
||||
# Generate QR code for the short URL
|
||||
settings = {
|
||||
'size': data.get('size', 10),
|
||||
'border': data.get('border', 4),
|
||||
'foreground_color': data.get('foreground_color', '#000000'),
|
||||
'background_color': data.get('background_color', '#FFFFFF'),
|
||||
'style': data.get('style', 'square')
|
||||
}
|
||||
|
||||
qr_img = qr_generator.generate_qr_code(short_url, settings)
|
||||
|
||||
# Convert to base64
|
||||
img_buffer = io.BytesIO()
|
||||
qr_img.save(img_buffer, format='PNG')
|
||||
img_buffer.seek(0)
|
||||
img_base64 = base64.b64encode(img_buffer.getvalue()).decode()
|
||||
|
||||
# Save QR code record
|
||||
qr_id = data_manager.save_qr_record('url_shortener', short_url, settings, img_base64)
|
||||
|
||||
# Save image file
|
||||
img_path = os.path.join(UPLOAD_FOLDER, f'{qr_id}.png')
|
||||
qr_img.save(img_path)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'qr_id': qr_id,
|
||||
'short_url': short_url,
|
||||
'short_code': result['short_code'],
|
||||
'original_url': result['original_url'],
|
||||
'image_data': f'data:image/png;base64,{img_base64}',
|
||||
'download_url': f'/api/download/{qr_id}'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# Backup Management API Endpoints
|
||||
@bp.route('/backup/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_backup():
|
||||
"""Create backup via API"""
|
||||
try:
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
data = request.json or {}
|
||||
backup_type = data.get('type', 'data') # data, config, full
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
app_root = '/app'
|
||||
backup_script = '/app/backup.py'
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_script = 'backup.py'
|
||||
|
||||
if backup_type == 'data':
|
||||
# Create data backup using Python script
|
||||
result = subprocess.run(
|
||||
['python3', backup_script, '--data-only'],
|
||||
cwd=app_root,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
elif backup_type == 'config':
|
||||
# Create config backup
|
||||
result = subprocess.run(
|
||||
['python3', backup_script, '--config'],
|
||||
cwd=app_root,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
elif backup_type == 'full':
|
||||
# Create full backup
|
||||
result = subprocess.run(
|
||||
['python3', backup_script, '--full'],
|
||||
cwd=app_root,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
else:
|
||||
return jsonify({'error': 'Invalid backup type'}), 400
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Backup created successfully',
|
||||
'output': result.stdout
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Backup creation failed',
|
||||
'output': result.stderr
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/list', methods=['GET'])
|
||||
@login_required
|
||||
def list_backups():
|
||||
"""List available backups"""
|
||||
try:
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
app_root = '/app'
|
||||
backup_script = '/app/backup.py'
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_script = 'backup.py'
|
||||
|
||||
result = subprocess.run(
|
||||
['python3', backup_script, '--list'],
|
||||
cwd=app_root,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Parse backup list from output
|
||||
lines = result.stdout.strip().split('\n')
|
||||
backups = []
|
||||
|
||||
for line in lines:
|
||||
if '|' in line and not line.startswith('─') and 'Available backups' not in line:
|
||||
parts = [part.strip() for part in line.split('|')]
|
||||
if len(parts) >= 4:
|
||||
backups.append({
|
||||
'type': parts[0],
|
||||
'filename': parts[1],
|
||||
'size': parts[2],
|
||||
'date': parts[3]
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'backups': backups
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Failed to list backups',
|
||||
'output': result.stderr
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/download/<filename>', methods=['GET'])
|
||||
@login_required
|
||||
def download_backup(filename):
|
||||
"""Download backup file"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
backup_dir = Path('/app/backups')
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_dir = app_root / 'backups'
|
||||
|
||||
backup_file = backup_dir / filename
|
||||
|
||||
if not backup_file.exists():
|
||||
return jsonify({'error': 'Backup file not found'}), 404
|
||||
|
||||
return send_file(
|
||||
str(backup_file),
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
mimetype='application/gzip'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/status', methods=['GET'])
|
||||
@login_required
|
||||
def backup_status():
|
||||
"""Get backup system status"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
backup_dir = Path('/app/backups')
|
||||
data_dir = Path('/app/data')
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_dir = app_root / 'backups'
|
||||
data_dir = app_root / 'data'
|
||||
|
||||
# Count backups
|
||||
backup_files = list(backup_dir.glob('*.tar.gz')) if backup_dir.exists() else []
|
||||
|
||||
# Get data directory size
|
||||
data_size = 0
|
||||
if data_dir.exists():
|
||||
for file_path in data_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
data_size += file_path.stat().st_size
|
||||
|
||||
# Get backup directory size
|
||||
backup_size = 0
|
||||
for backup_file in backup_files:
|
||||
backup_size += backup_file.stat().st_size
|
||||
|
||||
# Check last backup time
|
||||
last_backup = None
|
||||
if backup_files:
|
||||
latest_backup = max(backup_files, key=lambda x: x.stat().st_mtime)
|
||||
last_backup = datetime.fromtimestamp(latest_backup.stat().st_mtime).isoformat()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'status': {
|
||||
'backup_count': len(backup_files),
|
||||
'data_size': data_size,
|
||||
'backup_size': backup_size,
|
||||
'last_backup': last_backup,
|
||||
'backup_directory': str(backup_dir),
|
||||
'data_directory': str(data_dir)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/delete/<filename>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_backup(filename):
|
||||
"""Delete a backup file"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
backup_dir = Path('/app/backups')
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_dir = app_root / 'backups'
|
||||
|
||||
backup_file = backup_dir / filename
|
||||
|
||||
# Security check: ensure filename is just a filename, not a path
|
||||
if '/' in filename or '\\' in filename or '..' in filename:
|
||||
return jsonify({'error': 'Invalid filename'}), 400
|
||||
|
||||
# Check if file exists
|
||||
if not backup_file.exists():
|
||||
return jsonify({'error': 'Backup file not found'}), 404
|
||||
|
||||
# Delete the file
|
||||
backup_file.unlink()
|
||||
|
||||
# Also delete associated metadata file if it exists
|
||||
metadata_file = backup_dir / f"{filename.replace('.tar.gz', '.json')}"
|
||||
if metadata_file.exists():
|
||||
metadata_file.unlink()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Backup {filename} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/backup/cleanup', methods=['POST'])
|
||||
@login_required
|
||||
def cleanup_old_backups():
|
||||
"""Delete old backup files, keeping only the N most recent"""
|
||||
try:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
data = request.json or {}
|
||||
keep_count = data.get('keep', 5) # Default: keep 5 most recent
|
||||
|
||||
# Use absolute path for Docker container or relative path for development
|
||||
if os.path.exists('/app/backup.py'):
|
||||
# Running in Docker container
|
||||
backup_dir = Path('/app/backups')
|
||||
else:
|
||||
# Running in development
|
||||
app_root = Path(__file__).parent.parent.parent
|
||||
backup_dir = app_root / 'backups'
|
||||
|
||||
if not backup_dir.exists():
|
||||
return jsonify({'error': 'Backup directory not found'}), 404
|
||||
|
||||
# Get all backup files sorted by modification time (newest first)
|
||||
backup_files = list(backup_dir.glob('*.tar.gz'))
|
||||
backup_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
|
||||
# Delete old backups
|
||||
deleted_files = []
|
||||
files_to_delete = backup_files[keep_count:]
|
||||
|
||||
for backup_file in files_to_delete:
|
||||
# Delete the backup file
|
||||
backup_file.unlink()
|
||||
deleted_files.append(backup_file.name)
|
||||
|
||||
# Also delete associated metadata file if it exists
|
||||
metadata_file = backup_dir / f"{backup_file.stem}.json"
|
||||
if metadata_file.exists():
|
||||
metadata_file.unlink()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Cleanup completed. Deleted {len(deleted_files)} old backups.',
|
||||
'deleted_files': deleted_files,
|
||||
'kept_count': min(len(backup_files), keep_count)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
0
app/routes/auth.py
Normal file → Executable file
0
app/routes/auth.py
Normal file → Executable file
34
app/routes/main.py
Normal file → Executable file
34
app/routes/main.py
Normal file → Executable file
@@ -2,9 +2,10 @@
|
||||
Main routes for QR Code Manager
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
from flask import Blueprint, render_template, redirect, abort
|
||||
from app.utils.auth import login_required
|
||||
from app.utils.link_manager import LinkPageManager
|
||||
from app.utils.url_shortener import URLShortener
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
@@ -19,14 +20,24 @@ def index():
|
||||
|
||||
@bp.route('/links/<page_id>')
|
||||
def view_link_page(page_id):
|
||||
"""Display the public link page"""
|
||||
"""Display the public link page (for QR codes)"""
|
||||
if not link_manager.page_exists(page_id):
|
||||
return "Page not found", 404
|
||||
|
||||
link_manager.increment_view_count(page_id)
|
||||
page_data = link_manager.get_page(page_id)
|
||||
|
||||
return render_template('link_page.html', page=page_data)
|
||||
return render_template('public_page.html', page=page_data)
|
||||
|
||||
@bp.route('/stats/<page_id>')
|
||||
@login_required
|
||||
def view_page_statistics(page_id):
|
||||
"""Display the statistics page for admins"""
|
||||
if not link_manager.page_exists(page_id):
|
||||
return "Page not found", 404
|
||||
|
||||
page_data = link_manager.get_page(page_id)
|
||||
return render_template('statistics_page.html', page=page_data)
|
||||
|
||||
@bp.route('/edit/<page_id>')
|
||||
@login_required
|
||||
@@ -38,9 +49,26 @@ def edit_link_page(page_id):
|
||||
page_data = link_manager.get_page(page_id)
|
||||
return render_template('edit_links.html', page=page_data)
|
||||
|
||||
@bp.route('/backup')
|
||||
@login_required
|
||||
def backup_management():
|
||||
"""Display the backup management page"""
|
||||
return render_template('backup.html')
|
||||
|
||||
@bp.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for Docker"""
|
||||
from datetime import datetime
|
||||
from flask import jsonify
|
||||
return jsonify({'status': 'healthy', 'timestamp': datetime.now().isoformat()})
|
||||
|
||||
@bp.route('/s/<short_code>')
|
||||
def redirect_short_url(short_code):
|
||||
"""Redirect short URL to original URL"""
|
||||
# Force reload of data to ensure we have the latest short URLs
|
||||
link_manager.url_shortener = URLShortener()
|
||||
original_url = link_manager.resolve_short_url(short_code)
|
||||
if original_url:
|
||||
return redirect(original_url)
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
685
app/templates/backup.html
Normal file
685
app/templates/backup.html
Normal file
@@ -0,0 +1,685 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Backup Management - QR Code Manager</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 8px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
margin-bottom: 30px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-number {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%);
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background: linear-gradient(135deg, #17a2b8 0%, #6f42c1 100%);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
}
|
||||
|
||||
.backup-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.backup-list-header {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.backup-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.backup-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.backup-type {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.backup-filename {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.backup-size {
|
||||
font-size: 0.9em;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.backup-date {
|
||||
font-size: 0.9em;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.backup-actions-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-list-header,
|
||||
.backup-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.backup-actions-item {
|
||||
justify-content: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="/" class="back-link">🏠 Back to Dashboard</a>
|
||||
<h1>🛡️ Backup Management</h1>
|
||||
<p>Create, manage, and restore backups of your QR Code Manager data</p>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- Status Section -->
|
||||
<div class="section">
|
||||
<h2>📊 Backup Status</h2>
|
||||
<div class="status-grid" id="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-number" id="backup-count">-</div>
|
||||
<div class="status-label">Total Backups</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-number" id="data-size">-</div>
|
||||
<div class="status-label">Data Size</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-number" id="backup-size">-</div>
|
||||
<div class="status-label">Backup Size</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-number" id="last-backup">-</div>
|
||||
<div class="status-label">Last Backup</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Backup Section -->
|
||||
<div class="section">
|
||||
<h2>💾 Create Backup</h2>
|
||||
<div class="backup-actions">
|
||||
<button class="btn btn-success" onclick="createBackup('data')">
|
||||
📄 Data Backup
|
||||
</button>
|
||||
<button class="btn btn-warning" onclick="createBackup('config')">
|
||||
⚙️ Config Backup
|
||||
</button>
|
||||
<button class="btn btn-info" onclick="createBackup('full')">
|
||||
📦 Full Backup
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="loadBackups()">
|
||||
🔄 Refresh List
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="cleanupOldBackups()">
|
||||
🗑️ Cleanup Old
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="backup-alerts"></div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>💡 Backup Types:</strong><br>
|
||||
<strong>Data:</strong> QR codes, link pages, and short URLs (recommended for regular backups)<br>
|
||||
<strong>Config:</strong> Configuration files and environment settings<br>
|
||||
<strong>Full:</strong> Complete application backup including all files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup List Section -->
|
||||
<div class="section">
|
||||
<h2>📋 Available Backups</h2>
|
||||
<div class="backup-list">
|
||||
<div class="backup-list-header">
|
||||
<div>Type</div>
|
||||
<div>Filename</div>
|
||||
<div>Size</div>
|
||||
<div>Date</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
<div id="backup-list-content">
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
Loading backups...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load backup status and list on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadBackupStatus();
|
||||
loadBackups();
|
||||
});
|
||||
|
||||
async function loadBackupStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/backup/status');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const status = result.status;
|
||||
|
||||
document.getElementById('backup-count').textContent = status.backup_count;
|
||||
document.getElementById('data-size').textContent = formatBytes(status.data_size);
|
||||
document.getElementById('backup-size').textContent = formatBytes(status.backup_size);
|
||||
|
||||
if (status.last_backup) {
|
||||
const lastBackup = new Date(status.last_backup);
|
||||
document.getElementById('last-backup').textContent = formatTimeAgo(lastBackup);
|
||||
} else {
|
||||
document.getElementById('last-backup').textContent = 'Never';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load backup status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
const listContent = document.getElementById('backup-list-content');
|
||||
listContent.innerHTML = '<div class="loading"><div class="loading-spinner"></div>Loading backups...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/backup/list');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.backups.length > 0) {
|
||||
listContent.innerHTML = result.backups.map(backup => `
|
||||
<div class="backup-item">
|
||||
<div class="backup-type">${backup.type}</div>
|
||||
<div class="backup-filename">${backup.filename}</div>
|
||||
<div class="backup-size">${backup.size}</div>
|
||||
<div class="backup-date">${backup.date}</div>
|
||||
<div class="backup-actions-item">
|
||||
<button class="btn btn-small btn-info" onclick="downloadBackup('${backup.filename}')">
|
||||
📥 Download
|
||||
</button>
|
||||
<button class="btn btn-small btn-danger" onclick="deleteBackup('${backup.filename}')">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
listContent.innerHTML = '<div style="padding: 40px; text-align: center; color: #666;">No backups found</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load backups:', error);
|
||||
listContent.innerHTML = '<div style="padding: 40px; text-align: center; color: #dc3545;">Failed to load backups</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup(type) {
|
||||
const alertsContainer = document.getElementById('backup-alerts');
|
||||
|
||||
// Show loading alert
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div class="loading-spinner" style="width: 20px; height: 20px; border-width: 2px; margin: 0;"></div>
|
||||
Creating ${type} backup...
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/backup/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ type: type })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
✅ ${type.charAt(0).toUpperCase() + type.slice(1)} backup created successfully!
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Refresh status and list
|
||||
loadBackupStatus();
|
||||
loadBackups();
|
||||
|
||||
// Clear alert after 5 seconds
|
||||
setTimeout(() => {
|
||||
alertsContainer.innerHTML = '';
|
||||
}, 5000);
|
||||
} else {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Failed to create backup: ${result.error}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Error creating backup: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBackup(filename) {
|
||||
window.open(`/api/backup/download/${filename}`, '_blank');
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatTimeAgo(date) {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'Just now';
|
||||
if (diffInSeconds < 3600) return Math.floor(diffInSeconds / 60) + 'm ago';
|
||||
if (diffInSeconds < 86400) return Math.floor(diffInSeconds / 3600) + 'h ago';
|
||||
if (diffInSeconds < 2592000) return Math.floor(diffInSeconds / 86400) + 'd ago';
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
async function deleteBackup(filename) {
|
||||
if (!confirm(`Are you sure you want to delete the backup "${filename}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertsContainer = document.getElementById('backup-alerts');
|
||||
|
||||
// Show loading alert
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div class="loading-spinner" style="width: 20px; height: 20px; border-width: 2px; margin: 0;"></div>
|
||||
Deleting backup...
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/backup/delete/${filename}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
✅ Backup deleted successfully!
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Refresh status and list
|
||||
loadBackupStatus();
|
||||
loadBackups();
|
||||
|
||||
// Clear alert after 5 seconds
|
||||
setTimeout(() => {
|
||||
alertsContainer.innerHTML = '';
|
||||
}, 5000);
|
||||
} else {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Failed to delete backup: ${result.error}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Error deleting backup: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupOldBackups() {
|
||||
const keepCount = prompt('How many recent backups would you like to keep?', '5');
|
||||
|
||||
if (keepCount === null) {
|
||||
return; // User cancelled
|
||||
}
|
||||
|
||||
const numKeep = parseInt(keepCount);
|
||||
if (isNaN(numKeep) || numKeep < 1) {
|
||||
alert('Please enter a valid number greater than 0.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`This will delete all but the ${numKeep} most recent backups. Are you sure?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertsContainer = document.getElementById('backup-alerts');
|
||||
|
||||
// Show loading alert
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div class="loading-spinner" style="width: 20px; height: 20px; border-width: 2px; margin: 0;"></div>
|
||||
Cleaning up old backups...
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/backup/cleanup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ keep: numKeep })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
✅ ${result.message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Refresh status and list
|
||||
loadBackupStatus();
|
||||
loadBackups();
|
||||
|
||||
// Clear alert after 7 seconds
|
||||
setTimeout(() => {
|
||||
alertsContainer.innerHTML = '';
|
||||
}, 7000);
|
||||
} else {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Failed to cleanup backups: ${result.error}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
alertsContainer.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
❌ Error during cleanup: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
283
app/templates/edit_links.html
Normal file → Executable file
283
app/templates/edit_links.html
Normal file → Executable file
@@ -135,16 +135,68 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.links-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.link-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.link-item.editing {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.link-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.link-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.link-icon-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-display {
|
||||
@@ -238,12 +290,93 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.links-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.link-header {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.link-logo,
|
||||
.link-icon-placeholder {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.link-actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link-actions .btn-small {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-actions .btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.links-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.links-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.links-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -253,6 +386,13 @@
|
||||
<div class="header">
|
||||
<h1>✏️ Edit Links</h1>
|
||||
<p>Manage your link collection: {{ page.title }}</p>
|
||||
{% if page.short_url %}
|
||||
<div style="margin-top: 15px; padding: 12px; background: rgba(255,255,255,0.2); border-radius: 8px; font-size: 0.9em;">
|
||||
<strong>🔗 Page Short URL:</strong>
|
||||
<a href="{{ page.short_url }}" target="_blank" style="color: #fff; text-decoration: underline;">{{ page.short_url }}</a>
|
||||
<button onclick="copyToClipboard('{{ page.short_url }}')" style="margin-left: 10px; padding: 4px 8px; background: rgba(255,255,255,0.3); color: white; border: 1px solid rgba(255,255,255,0.5); border-radius: 3px; cursor: pointer; font-size: 0.8em;">Copy</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success" id="success-alert">
|
||||
@@ -288,19 +428,35 @@
|
||||
|
||||
<div class="links-section">
|
||||
<h2>Current Links ({{ page.links|length }})</h2>
|
||||
<div id="links-container">
|
||||
<div class="links-grid" id="links-container">
|
||||
{% if page.links %}
|
||||
{% for link in page.links %}
|
||||
<div class="link-item" data-link-id="{{ link.id }}">
|
||||
<div class="link-display">
|
||||
<div class="link-header">
|
||||
<div class="link-icon-placeholder" style="display: flex;">🔗</div>
|
||||
<img class="link-logo" src="" alt="" style="display: none;" onerror="this.style.display='none'">
|
||||
<div class="link-content">
|
||||
<div class="link-title">{{ link.title }}</div>
|
||||
{% if link.description %}
|
||||
<div class="link-description">{{ link.description }}</div>
|
||||
{% endif %}
|
||||
<div class="link-url">{{ link.url }}</div>
|
||||
<div class="link-url" data-url="{{ link.url }}">{{ link.url }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if link.short_url %}
|
||||
<div class="short-url-display" style="margin-top: 12px; padding: 10px; background: #e3f2fd; border-radius: 8px; border-left: 3px solid #2196f3;">
|
||||
<small style="color: #1976d2; font-weight: 600;">🔗 Short URL:</small>
|
||||
<br>
|
||||
<a href="{{ link.short_url }}" target="_blank" style="color: #1976d2; text-decoration: none; font-family: monospace; font-size: 0.9em;">{{ link.short_url }}</a>
|
||||
<button class="btn-copy" onclick="copyToClipboard('{{ link.short_url }}')" style="margin-left: 8px; padding: 3px 8px; background: #2196f3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.8em;">Copy</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="link-actions">
|
||||
<button class="btn btn-small btn-secondary" onclick="editLink('{{ link.id }}')">Edit</button>
|
||||
<button class="btn btn-small btn-danger" onclick="deleteLink('{{ link.id }}')">Delete</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="editLink('{{ link.id }}')">✏️ Edit</button>
|
||||
<button class="btn btn-small btn-danger" onclick="deleteLink('{{ link.id }}')">🗑️ Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -318,14 +474,14 @@
|
||||
<textarea class="edit-description">{{ link.description or '' }}</textarea>
|
||||
</div>
|
||||
<div class="link-actions">
|
||||
<button class="btn btn-small btn-success" onclick="saveLink('{{ link.id }}')">Save</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="cancelEdit('{{ link.id }}')">Cancel</button>
|
||||
<button class="btn btn-small btn-success" onclick="saveLink('{{ link.id }}')">💾 Save</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="cancelEdit('{{ link.id }}')">❌ Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state" style="grid-column: 1 / -1;">
|
||||
<div class="icon">📝</div>
|
||||
<h3>No links yet</h3>
|
||||
<p>Add your first link using the form on the left.</p>
|
||||
@@ -336,7 +492,8 @@
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<a href="/links/{{ page.id }}" target="_blank" class="btn">👁️ View Public Page</a>
|
||||
<a href="/stats/{{ page.id }}" class="btn">📊 View Statistics</a>
|
||||
<a href="/links/{{ page.id }}" target="_blank" class="btn btn-secondary">👁️ Preview Public Page</a>
|
||||
<a href="/" class="btn btn-secondary">🏠 Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,6 +501,100 @@
|
||||
<script>
|
||||
const pageId = '{{ page.id }}';
|
||||
|
||||
// Social media and website logo detection
|
||||
function getWebsiteLogo(url) {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : 'https://' + url);
|
||||
const domain = urlObj.hostname.toLowerCase().replace('www.', '');
|
||||
|
||||
// Logo mapping for popular sites
|
||||
const logoMap = {
|
||||
'facebook.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/facebook.svg',
|
||||
'instagram.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/instagram.svg',
|
||||
'twitter.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/twitter.svg',
|
||||
'x.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/x.svg',
|
||||
'tiktok.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/tiktok.svg',
|
||||
'youtube.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/youtube.svg',
|
||||
'linkedin.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/linkedin.svg',
|
||||
'pinterest.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/pinterest.svg',
|
||||
'snapchat.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/snapchat.svg',
|
||||
'whatsapp.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/whatsapp.svg',
|
||||
'telegram.org': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/telegram.svg',
|
||||
'discord.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/discord.svg',
|
||||
'reddit.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/reddit.svg',
|
||||
'github.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/github.svg',
|
||||
'gmail.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/gmail.svg',
|
||||
'google.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/google.svg',
|
||||
'amazon.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/amazon.svg',
|
||||
'apple.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/apple.svg',
|
||||
'microsoft.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/microsoft.svg',
|
||||
'spotify.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/spotify.svg',
|
||||
'netflix.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/netflix.svg',
|
||||
'twitch.tv': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/twitch.svg',
|
||||
'dropbox.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/dropbox.svg',
|
||||
'zoom.us': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/zoom.svg'
|
||||
};
|
||||
|
||||
if (logoMap[domain]) {
|
||||
return logoMap[domain];
|
||||
}
|
||||
|
||||
// Fallback to favicon
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set logos for existing links
|
||||
function setLogosForLinks() {
|
||||
document.querySelectorAll('.link-url[data-url]').forEach(linkElement => {
|
||||
const url = linkElement.getAttribute('data-url');
|
||||
const linkItem = linkElement.closest('.link-item');
|
||||
const logoImg = linkItem.querySelector('.link-logo');
|
||||
const placeholder = linkItem.querySelector('.link-icon-placeholder');
|
||||
const logoSrc = getWebsiteLogo(url);
|
||||
|
||||
if (logoSrc && logoImg) {
|
||||
logoImg.src = logoSrc;
|
||||
logoImg.alt = new URL(url.startsWith('http') ? url : 'https://' + url).hostname;
|
||||
|
||||
// Show logo and hide placeholder when loaded
|
||||
logoImg.onload = function() {
|
||||
logoImg.style.display = 'block';
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
};
|
||||
|
||||
// Show placeholder if logo fails to load
|
||||
logoImg.onerror = function() {
|
||||
logoImg.style.display = 'none';
|
||||
if (placeholder) placeholder.style.display = 'flex';
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize logos when page loads
|
||||
document.addEventListener('DOMContentLoaded', setLogosForLinks);
|
||||
|
||||
// Copy to clipboard function
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
// Show temporary success message
|
||||
const btn = event.target;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#4caf50';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.style.background = '#2196f3';
|
||||
}, 1500);
|
||||
}).catch(function(err) {
|
||||
console.error('Could not copy text: ', err);
|
||||
alert('Failed to copy to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
// Add new link
|
||||
document.getElementById('add-link-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
@@ -358,7 +609,11 @@
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ title, url, description })
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
url,
|
||||
description
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
@@ -399,7 +654,11 @@
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ title, url, description })
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
url,
|
||||
description
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
340
app/templates/index.html
Normal file → Executable file
340
app/templates/index.html
Normal file → Executable file
@@ -148,7 +148,8 @@
|
||||
.link-page-fields,
|
||||
.email-fields,
|
||||
.sms-fields,
|
||||
.vcard-fields {
|
||||
.vcard-fields,
|
||||
.url-shortener-fields {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -156,7 +157,8 @@
|
||||
.link-page-fields.active,
|
||||
.email-fields.active,
|
||||
.sms-fields.active,
|
||||
.vcard-fields.active {
|
||||
.vcard-fields.active,
|
||||
.url-shortener-fields.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -212,12 +214,9 @@
|
||||
}
|
||||
|
||||
.download-section {
|
||||
display: none;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.download-section.active {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -230,6 +229,15 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: linear-gradient(135deg, #20c997 0%, #17a2b8 100%);
|
||||
}
|
||||
|
||||
.qr-history {
|
||||
margin-top: 30px;
|
||||
padding: 25px;
|
||||
@@ -286,16 +294,202 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.header div {
|
||||
position: static !important;
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.form-section h2 {
|
||||
font-size: 1.3em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.color-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.style-selector {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qr-preview {
|
||||
padding: 20px 10px;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.qr-preview img {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.download-section {
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.download-section .btn {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* QR History Mobile Optimization */
|
||||
.qr-history {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.qr-history h3 {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.qr-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.qr-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qr-item img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr-item-info {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-item-info h4 {
|
||||
font-size: 1em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.qr-item-info p {
|
||||
font-size: 0.85em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-item-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.qr-item-actions.full-width {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Special handling for link_page items with more buttons */
|
||||
.qr-item[data-type="link_page"] .qr-item-actions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.qr-item[data-type="link_page"] .btn-small {
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header h1 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
.qr-history {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.qr-item {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.qr-item img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.qr-item-actions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
/* Stack action buttons for very small screens */
|
||||
.qr-item[data-type="link_page"] .qr-item-actions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -305,7 +499,10 @@
|
||||
<div class="header">
|
||||
<h1>🎯 QR Code Manager</h1>
|
||||
<p>Create, customize, and manage your QR codes with ease</p>
|
||||
<div style="position: absolute; top: 20px; right: 20px;">
|
||||
<div style="position: absolute; top: 20px; right: 20px; display: flex; gap: 10px;">
|
||||
<a href="/backup" style="color: white; text-decoration: none; background: rgba(255,255,255,0.2); padding: 8px 15px; border-radius: 20px; font-size: 0.9em;">
|
||||
🛡️ Backup
|
||||
</a>
|
||||
<a href="/logout" style="color: white; text-decoration: none; background: rgba(255,255,255,0.2); padding: 8px 15px; border-radius: 20px; font-size: 0.9em;">
|
||||
👤 Logout
|
||||
</a>
|
||||
@@ -322,6 +519,7 @@
|
||||
<option value="text">Text</option>
|
||||
<option value="url">URL/Website</option>
|
||||
<option value="link_page">Dynamic Link Page</option>
|
||||
<option value="url_shortener">URL Shortener</option>
|
||||
<option value="wifi">WiFi</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="phone">Phone</option>
|
||||
@@ -368,7 +566,32 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<p style="background: #e3f2fd; padding: 15px; border-radius: 8px; color: #1565c0; font-size: 0.9em;">
|
||||
<strong>💡 Dynamic Link Page:</strong> This creates a QR code that points to a web page where you can add, edit, and manage links. The QR code stays the same, but you can update the links anytime!
|
||||
<strong>💡 Dynamic Link Page with Short URL:</strong> This creates a QR code with a short URL that points to a web page where you can add, edit, and manage links. The QR code stays the same, but you can update the links anytime!
|
||||
<br><br>✨ <strong>Auto Short URL:</strong> Your link page will automatically get a short URL like <code>qr.moto-adv.com/s/abc123</code> making the QR code simpler and easier to scan!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL Shortener fields -->
|
||||
<div class="url-shortener-fields" id="url-shortener-fields">
|
||||
<div class="form-group">
|
||||
<label for="shortener-url">URL to Shorten</label>
|
||||
<input type="url" id="shortener-url" placeholder="https://example.com/very/long/url">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="shortener-title">Title (optional)</label>
|
||||
<input type="text" id="shortener-title" placeholder="My Website">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="shortener-custom-code">Custom Short Code (optional)</label>
|
||||
<input type="text" id="shortener-custom-code" placeholder="mylink" maxlength="20">
|
||||
<small style="color: #666; font-size: 0.8em;">
|
||||
Leave empty for random code. Only letters and numbers allowed.
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<p style="background: #e3f2fd; padding: 15px; border-radius: 8px; color: #1565c0; font-size: 0.9em;">
|
||||
<strong>🔗 URL Shortener:</strong> Creates a short URL that redirects to your original URL. The QR code will contain the short URL. Perfect for long URLs or tracking clicks!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,8 +701,9 @@
|
||||
</div>
|
||||
|
||||
<div class="download-section" id="download-section">
|
||||
<button class="btn btn-primary" onclick="downloadQR()">Download PNG</button>
|
||||
<button class="btn btn-secondary" onclick="copyToClipboard()">Copy Image</button>
|
||||
<button class="btn btn-primary" onclick="downloadQR('png')">📥 Download PNG</button>
|
||||
<button class="btn btn-success" onclick="downloadQR('svg')">🎨 Download SVG</button>
|
||||
<button class="btn btn-secondary" onclick="copyToClipboard()">📋 Copy Image</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -501,7 +725,7 @@
|
||||
|
||||
// Hide all specific fields
|
||||
document.getElementById('text-field').style.display = 'none';
|
||||
document.querySelectorAll('.wifi-fields, .link-page-fields, .email-fields, .sms-fields, .vcard-fields').forEach(el => {
|
||||
document.querySelectorAll('.wifi-fields, .link-page-fields, .email-fields, .sms-fields, .vcard-fields, .url-shortener-fields').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
@@ -570,6 +794,12 @@
|
||||
email: document.getElementById('vcard-email').value,
|
||||
website: document.getElementById('vcard-website').value
|
||||
};
|
||||
} else if (type === 'url_shortener') {
|
||||
additionalData.shortener = {
|
||||
url: document.getElementById('shortener-url').value,
|
||||
title: document.getElementById('shortener-title').value,
|
||||
custom_code: document.getElementById('shortener-custom-code').value
|
||||
};
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
@@ -583,7 +813,13 @@
|
||||
};
|
||||
|
||||
try {
|
||||
const endpoint = type === 'link_page' ? '/api/create_link_page' : '/api/generate';
|
||||
let endpoint = '/api/generate';
|
||||
if (type === 'link_page') {
|
||||
endpoint = '/api/create_link_page';
|
||||
} else if (type === 'url_shortener') {
|
||||
endpoint = '/api/generate_shortened_qr';
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -606,10 +842,22 @@
|
||||
if (type === 'link_page') {
|
||||
previewHTML += `
|
||||
<div style="margin-top: 15px; padding: 15px; background: #e3f2fd; border-radius: 8px; text-align: left;">
|
||||
<h4 style="margin-bottom: 10px; color: #1565c0;">🎉 Dynamic Link Page Created!</h4>
|
||||
<p style="margin-bottom: 10px; font-size: 0.9em;"><strong>Public URL:</strong> <a href="${result.page_url}" target="_blank">${result.page_url}</a></p>
|
||||
<p style="margin-bottom: 10px; font-size: 0.9em;"><strong>Edit URL:</strong> <a href="${result.edit_url}" target="_blank">${result.edit_url}</a></p>
|
||||
<p style="font-size: 0.9em; color: #666;">Share the QR code - visitors will see your link collection. Use the edit URL to manage your links!</p>
|
||||
<h4 style="margin-bottom: 10px; color: #1565c0;">🎉 Dynamic Link Page Created with Short URL!</h4>
|
||||
<p style="margin-bottom: 10px; font-size: 0.9em;"><strong>🔗 Short URL:</strong> <a href="${result.page_url}" target="_blank">${result.page_url}</a>
|
||||
<button onclick="copyToClipboard('${result.page_url}')" style="margin-left: 10px; padding: 4px 8px; background: #1565c0; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.8em;">Copy</button></p>
|
||||
<p style="margin-bottom: 10px; font-size: 0.9em;"><strong>📝 Edit URL:</strong> <a href="${result.edit_url}" target="_blank">${result.edit_url}</a></p>
|
||||
${result.original_url ? `<p style="margin-bottom: 10px; font-size: 0.8em; color: #666;"><strong>Original URL:</strong> ${result.original_url}</p>` : ''}
|
||||
<p style="font-size: 0.9em; color: #666;">✨ QR code contains the short URL for easier scanning! Share it - visitors will see your link collection. Use the edit URL to manage your links!</p>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'url_shortener') {
|
||||
previewHTML += `
|
||||
<div style="margin-top: 15px; padding: 15px; background: #e8f5e8; border-radius: 8px; text-align: left;">
|
||||
<h4 style="margin-bottom: 10px; color: #2e7d32;">🔗 Short URL Created!</h4>
|
||||
<p style="margin-bottom: 10px; font-size: 0.9em;"><strong>Short URL:</strong> <a href="${result.short_url}" target="_blank">${result.short_url}</a></p>
|
||||
<p style="margin-bottom: 10px; font-size: 0.9em;"><strong>Original URL:</strong> <a href="${result.original_url}" target="_blank">${result.original_url}</a></p>
|
||||
<p style="font-size: 0.9em; color: #666;">The QR code contains your short URL. When scanned, it will redirect to your original URL!</p>
|
||||
<button onclick="copyToClipboard('${result.short_url}')" style="margin-top: 10px; padding: 8px 15px; background: #2e7d32; color: white; border: none; border-radius: 5px; cursor: pointer;">Copy Short URL</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -629,11 +877,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadQR() {
|
||||
async function downloadQR(format = 'png') {
|
||||
if (currentQRId) {
|
||||
if (format === 'svg') {
|
||||
window.open(`/api/download/${currentQRId}/svg`, '_blank');
|
||||
} else {
|
||||
window.open(`/api/download/${currentQRId}`, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (currentQRData) {
|
||||
@@ -650,6 +902,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Copy to clipboard function
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
const btn = event.target;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#4caf50';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.style.background = '#2e7d32';
|
||||
}, 1500);
|
||||
}).catch(function(err) {
|
||||
console.error('Could not copy text: ', err);
|
||||
alert('Failed to copy to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
async function loadQRHistory() {
|
||||
try {
|
||||
const response = await fetch('/api/qr_codes');
|
||||
@@ -663,16 +932,20 @@
|
||||
}
|
||||
|
||||
historyList.innerHTML = qrCodes.map(qr => `
|
||||
<div class="qr-item">
|
||||
<div class="qr-item" data-type="${qr.type}">
|
||||
<div class="qr-item-header">
|
||||
<img src="${qr.preview}" alt="QR Code">
|
||||
<div class="qr-item-info">
|
||||
<h4>${qr.type.toUpperCase()}${qr.type === 'link_page' ? ' 🔗' : ''}</h4>
|
||||
<p>Created: ${new Date(qr.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div class="qr-item-actions">
|
||||
<button class="btn btn-small btn-primary" onclick="downloadQRById('${qr.id}')">Download</button>
|
||||
${qr.type === 'link_page' ? `<button class="btn btn-small" onclick="openLinkPage('${qr.id}')" style="background: #28a745;">Manage</button>` : ''}
|
||||
<button class="btn btn-small btn-secondary" onclick="deleteQR('${qr.id}')">Delete</button>
|
||||
</div>
|
||||
<div class="qr-item-actions ${qr.type === 'link_page' ? 'full-width' : ''}">
|
||||
<button class="btn btn-small btn-primary" onclick="downloadQRById('${qr.id}', 'png')" title="Download PNG">📥 PNG</button>
|
||||
<button class="btn btn-small btn-success" onclick="downloadQRById('${qr.id}', 'svg')" title="Download SVG">🎨 SVG</button>
|
||||
${qr.type === 'link_page' ? `<button class="btn btn-small" onclick="openLinkPage('${qr.id}')" style="background: #28a745;" title="Manage Links">📝 Edit</button>` : ''}
|
||||
${qr.type === 'link_page' ? `<button class="btn btn-small" onclick="openStatistics('${qr.id}')" style="background: #17a2b8;" title="View Statistics">📊 Stats</button>` : ''}
|
||||
<button class="btn btn-small btn-secondary" onclick="deleteQR('${qr.id}')" title="Delete QR Code">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -681,9 +954,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadQRById(qrId) {
|
||||
async function downloadQRById(qrId, format = 'png') {
|
||||
if (format === 'svg') {
|
||||
window.open(`/api/download/${qrId}/svg`, '_blank');
|
||||
} else {
|
||||
window.open(`/api/download/${qrId}`, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteQR(qrId) {
|
||||
if (confirm('Are you sure you want to delete this QR code?')) {
|
||||
@@ -718,6 +995,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openStatistics(qrId) {
|
||||
try {
|
||||
const response = await fetch(`/api/qr_codes/${qrId}`);
|
||||
const qrData = await response.json();
|
||||
|
||||
if (qrData.page_id) {
|
||||
window.open(`/stats/${qrData.page_id}`, '_blank');
|
||||
} else {
|
||||
alert('Link page not found');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error opening statistics page: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Load history on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadQRHistory();
|
||||
|
||||
135
app/templates/link_page.html
Normal file → Executable file
135
app/templates/link_page.html
Normal file → Executable file
@@ -90,7 +90,10 @@
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -100,14 +103,22 @@
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.link-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
@@ -153,31 +164,6 @@
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
margin-top: 10px;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 30px 20px;
|
||||
@@ -232,15 +218,19 @@
|
||||
{% if page.links %}
|
||||
<h2>📚 Available Links</h2>
|
||||
{% for link in page.links %}
|
||||
<a href="{{ link.url }}" target="_blank" class="link-item">
|
||||
<a href="{{ link.short_url if link.short_url else link.url }}" target="_blank" class="link-item" data-url="{{ link.url }}">
|
||||
<div class="link-content">
|
||||
<div class="link-title">
|
||||
<div class="link-icon">🔗</div>
|
||||
{{ link.title }}
|
||||
{% if link.short_url %}<span style="background: #2196f3; color: white; font-size: 0.7em; padding: 2px 6px; border-radius: 10px; margin-left: 8px;">SHORT</span>{% endif %}
|
||||
</div>
|
||||
{% if link.description %}
|
||||
<div class="link-description">{{ link.description }}</div>
|
||||
{% endif %}
|
||||
<div class="link-url">{{ link.url }}</div>
|
||||
<div class="link-url">{{ link.short_url if link.short_url else link.url }}</div>
|
||||
</div>
|
||||
<img class="link-logo" src="" alt="" style="display: none;" onerror="this.style.display='none'">
|
||||
<div class="link-icon" style="display: block;">🔗</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
@@ -252,18 +242,81 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="/">QR Code Manager</a></p>
|
||||
{% if page.updated_at %}
|
||||
<div class="last-updated">
|
||||
Last updated: {{ page.updated_at[:10] }} at {{ page.updated_at[11:19] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Social media and website logo detection
|
||||
function getWebsiteLogo(url) {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : 'https://' + url);
|
||||
const domain = urlObj.hostname.toLowerCase().replace('www.', '');
|
||||
|
||||
// Logo mapping for popular sites
|
||||
const logoMap = {
|
||||
'facebook.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/facebook.svg',
|
||||
'instagram.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/instagram.svg',
|
||||
'twitter.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/twitter.svg',
|
||||
'x.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/x.svg',
|
||||
'tiktok.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/tiktok.svg',
|
||||
'youtube.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/youtube.svg',
|
||||
'linkedin.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/linkedin.svg',
|
||||
'pinterest.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/pinterest.svg',
|
||||
'snapchat.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/snapchat.svg',
|
||||
'whatsapp.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/whatsapp.svg',
|
||||
'telegram.org': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/telegram.svg',
|
||||
'discord.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/discord.svg',
|
||||
'reddit.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/reddit.svg',
|
||||
'github.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/github.svg',
|
||||
'gmail.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/gmail.svg',
|
||||
'google.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/google.svg',
|
||||
'amazon.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/amazon.svg',
|
||||
'apple.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/apple.svg',
|
||||
'microsoft.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/microsoft.svg',
|
||||
'spotify.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/spotify.svg',
|
||||
'netflix.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/netflix.svg',
|
||||
'twitch.tv': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/twitch.svg',
|
||||
'dropbox.com': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/dropbox.svg',
|
||||
'zoom.us': 'https://cdn.jsdelivr.net/npm/simple-icons@v9/icons/zoom.svg'
|
||||
};
|
||||
|
||||
if (logoMap[domain]) {
|
||||
return logoMap[domain];
|
||||
}
|
||||
|
||||
// Fallback to favicon
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set logos for links
|
||||
function setLogosForLinks() {
|
||||
document.querySelectorAll('.link-item[data-url]').forEach(linkElement => {
|
||||
const url = linkElement.getAttribute('data-url');
|
||||
const logoImg = linkElement.querySelector('.link-logo');
|
||||
const fallbackIcon = linkElement.querySelector('.link-icon');
|
||||
const logoSrc = getWebsiteLogo(url);
|
||||
|
||||
if (logoSrc && logoImg) {
|
||||
logoImg.src = logoSrc;
|
||||
logoImg.style.display = 'block';
|
||||
logoImg.alt = new URL(url.startsWith('http') ? url : 'https://' + url).hostname;
|
||||
// Hide the fallback icon when logo is shown
|
||||
logoImg.onload = function() {
|
||||
if (fallbackIcon) fallbackIcon.style.display = 'none';
|
||||
};
|
||||
logoImg.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
if (fallbackIcon) fallbackIcon.style.display = 'flex';
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize logos when page loads
|
||||
document.addEventListener('DOMContentLoaded', setLogosForLinks);
|
||||
|
||||
// Add click tracking (optional)
|
||||
document.querySelectorAll('.link-item').forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
|
||||
25
app/templates/login.html
Normal file → Executable file
25
app/templates/login.html
Normal file → Executable file
@@ -134,22 +134,6 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.default-credentials {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
color: #856404;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.default-credentials strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
margin: 10px;
|
||||
@@ -199,12 +183,6 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="default-credentials">
|
||||
<strong>Default Login Credentials:</strong>
|
||||
Username: admin<br>
|
||||
Password: admin123
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
@@ -222,9 +200,6 @@
|
||||
|
||||
<div class="login-footer">
|
||||
<p>🔒 Secure QR Code Management System</p>
|
||||
<p style="margin-top: 5px; font-size: 0.8em; opacity: 0.7;">
|
||||
Change default credentials in production
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
319
app/templates/public_page.html
Normal file
319
app/templates/public_page.html
Normal file
@@ -0,0 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ page.title }}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.links-section h2 {
|
||||
color: #333;
|
||||
margin-bottom: 25px;
|
||||
font-size: 1.8em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.link-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.link-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link-description {
|
||||
color: #666;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.link-url {
|
||||
color: #667eea;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 15px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.1em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
padding: 15px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header h1 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.links-section h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p>{{ page.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="links-section">
|
||||
{% if page.links %}
|
||||
<h2>📚 Available Links</h2>
|
||||
{% for link in page.links %}
|
||||
<a href="{{ link.short_url if link.short_url else link.url }}" target="_blank" class="link-item" data-url="{{ link.url }}">
|
||||
<div class="link-content">
|
||||
<div class="link-title">
|
||||
{{ link.title }}
|
||||
{% if link.short_url %}<span style="background: #2196f3; color: white; font-size: 0.7em; padding: 2px 6px; border-radius: 10px; margin-left: 8px;">SHORT</span>{% endif %}
|
||||
</div>
|
||||
{% if link.description %}
|
||||
<div class="link-description">{{ link.description }}</div>
|
||||
{% endif %}
|
||||
<div class="link-url">{{ link.short_url if link.short_url else link.url }}</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<img class="link-logo" style="display: none;" alt="Logo">
|
||||
<div class="link-icon">🔗</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No Links Yet</h3>
|
||||
<p>This page doesn't have any links yet. Check back later!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Website logo mapping for better recognition
|
||||
function getWebsiteLogo(url) {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : 'https://' + url);
|
||||
const domain = urlObj.hostname.replace('www.', '');
|
||||
|
||||
const logoMap = {
|
||||
'youtube.com': 'https://www.youtube.com/favicon.ico',
|
||||
'youtu.be': 'https://www.youtube.com/favicon.ico',
|
||||
'facebook.com': 'https://www.facebook.com/favicon.ico',
|
||||
'instagram.com': 'https://www.instagram.com/favicon.ico',
|
||||
'twitter.com': 'https://abs.twimg.com/favicons/twitter.ico',
|
||||
'x.com': 'https://abs.twimg.com/favicons/twitter.ico',
|
||||
'linkedin.com': 'https://static.licdn.com/sc/h/al2o9zrvru7aqj8e1x2rzsrca',
|
||||
'github.com': 'https://github.com/favicon.ico',
|
||||
'stackoverflow.com': 'https://stackoverflow.com/favicon.ico',
|
||||
'reddit.com': 'https://www.reddit.com/favicon.ico',
|
||||
'medium.com': 'https://medium.com/favicon.ico',
|
||||
'discord.com': 'https://discord.com/assets/f8389ca1a741a115313bede9ac02e2c0.ico',
|
||||
'twitch.tv': 'https://static.twitchcdn.net/assets/favicon-32-d6025c14e900565d6177.png',
|
||||
'spotify.com': 'https://open.spotify.com/favicon.ico',
|
||||
'apple.com': 'https://www.apple.com/favicon.ico',
|
||||
'google.com': 'https://www.google.com/favicon.ico',
|
||||
'microsoft.com': 'https://www.microsoft.com/favicon.ico',
|
||||
'amazon.com': 'https://www.amazon.com/favicon.ico',
|
||||
'netflix.com': 'https://assets.nflxext.com/ffe/siteui/common/icons/nficon2016.ico',
|
||||
'whatsapp.com': 'https://static.whatsapp.net/rsrc.php/v3/yz/r/ujTY9i_Jhs1.png'
|
||||
};
|
||||
|
||||
if (logoMap[domain]) {
|
||||
return logoMap[domain];
|
||||
}
|
||||
|
||||
// Fallback to favicon
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set logos for links
|
||||
function setLogosForLinks() {
|
||||
document.querySelectorAll('.link-item[data-url]').forEach(linkElement => {
|
||||
const url = linkElement.getAttribute('data-url');
|
||||
const logoImg = linkElement.querySelector('.link-logo');
|
||||
const fallbackIcon = linkElement.querySelector('.link-icon');
|
||||
const logoSrc = getWebsiteLogo(url);
|
||||
|
||||
if (logoSrc && logoImg) {
|
||||
logoImg.src = logoSrc;
|
||||
logoImg.style.display = 'block';
|
||||
logoImg.alt = new URL(url.startsWith('http') ? url : 'https://' + url).hostname;
|
||||
// Hide the fallback icon when logo is shown
|
||||
logoImg.onload = function() {
|
||||
if (fallbackIcon) fallbackIcon.style.display = 'none';
|
||||
};
|
||||
logoImg.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
if (fallbackIcon) fallbackIcon.style.display = 'flex';
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize logos when page loads
|
||||
document.addEventListener('DOMContentLoaded', setLogosForLinks);
|
||||
|
||||
// Add click tracking (optional)
|
||||
document.querySelectorAll('.link-item').forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
// You could add analytics here
|
||||
console.log('Link clicked:', this.querySelector('.link-title').textContent.trim());
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-refresh every 30 seconds to get latest links
|
||||
setInterval(() => {
|
||||
window.location.reload();
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
652
app/templates/statistics_page.html
Normal file
652
app/templates/statistics_page.html
Normal file
@@ -0,0 +1,652 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ page.title }} - Statistics</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
background: rgba(255,255,255,0.1);
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.admin-info {
|
||||
background: rgba(255,255,255,0.1);
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-info h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.8em;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.links-section h2 {
|
||||
color: #333;
|
||||
margin-bottom: 25px;
|
||||
font-size: 1.8em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.link-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.link-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link-description {
|
||||
color: #666;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.link-url {
|
||||
color: #667eea;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 15px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.1em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
background: #f8f9fa;
|
||||
padding: 20px 30px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.qr-info {
|
||||
background: #e8f4fd;
|
||||
border: 1px solid #b8daff;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.qr-info h3 {
|
||||
color: #0c5460;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.qr-info p {
|
||||
color: #0c5460;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.qr-url {
|
||||
background: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
word-break: break-all;
|
||||
border: 1px solid #b8daff;
|
||||
}
|
||||
|
||||
.qr-preview-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-preview-section h4 {
|
||||
color: #495057;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.qr-preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.qr-preview-image {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.qr-download-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-download-png {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-download-svg {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 15px 20px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
padding: 15px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.qr-preview-image {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.qr-download-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header h1 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.links-section h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{{ page.title }} - Statistics</h1>
|
||||
<p>{{ page.description }}</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ page.links|length }}</div>
|
||||
<div class="stat-label">Links</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ page.view_count }}</div>
|
||||
<div class="stat-label">Page Views</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ page.links|sum(attribute='clicks') or 0 }}</div>
|
||||
<div class="stat-label">Total Clicks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-info">
|
||||
<h3>📊 Page Information</h3>
|
||||
<div class="info-row">
|
||||
<span>Page ID:</span>
|
||||
<span>{{ page.id }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Created:</span>
|
||||
<span>{{ page.created_at or 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Last Updated:</span>
|
||||
<span>{{ page.updated_at or 'N/A' }}</span>
|
||||
</div>
|
||||
{% if page.short_url %}
|
||||
<div class="info-row">
|
||||
<span>Short URL:</span>
|
||||
<span>{{ page.short_url }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="section">
|
||||
<div class="qr-info">
|
||||
<h3>🔗 Public QR Code URL</h3>
|
||||
<p>This is the URL that your QR code points to (public page without statistics):</p>
|
||||
<div class="qr-url">{{ request.url_root }}links/{{ page.id }}</div>
|
||||
|
||||
<div class="qr-preview-section">
|
||||
<h4>📱 QR Code Preview</h4>
|
||||
<div class="qr-preview-container">
|
||||
<div id="qr-preview-loading" class="qr-loading">Loading QR code...</div>
|
||||
<img id="qr-preview-image" class="qr-preview-image" style="display: none;" alt="QR Code">
|
||||
<div class="qr-download-actions" id="qr-download-actions" style="display: none;">
|
||||
<button class="btn-download btn-download-png" onclick="downloadQRCode('png')">
|
||||
📥 Download PNG
|
||||
</button>
|
||||
<button class="btn-download btn-download-svg" onclick="downloadQRCode('svg')">
|
||||
🎨 Download SVG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section links-section">
|
||||
{% if page.links %}
|
||||
<h2>📚 Links with Statistics</h2>
|
||||
{% for link in page.links %}
|
||||
<div class="link-item" style="cursor: default;">
|
||||
<div class="link-content">
|
||||
<div class="link-title">
|
||||
{{ link.title }}
|
||||
{% if link.short_url %}<span style="background: #2196f3; color: white; font-size: 0.7em; padding: 2px 6px; border-radius: 10px; margin-left: 8px;">SHORT</span>{% endif %}
|
||||
</div>
|
||||
{% if link.description %}
|
||||
<div class="link-description">{{ link.description }}</div>
|
||||
{% endif %}
|
||||
<div class="link-url">{{ link.short_url if link.short_url else link.url }}</div>
|
||||
<div style="margin-top: 10px; font-size: 0.85em; color: #666;">
|
||||
<strong>Clicks:</strong> {{ link.clicks or 0 }} |
|
||||
<strong>Added:</strong> {{ link.created_at or 'N/A' }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<img class="link-logo" style="display: none;" alt="Logo">
|
||||
<div class="link-icon">📊</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No Links Yet</h3>
|
||||
<p>This page doesn't have any links yet. Add some links to see statistics here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/edit/{{ page.id }}" class="btn btn-primary">
|
||||
✏️ Edit Page
|
||||
</a>
|
||||
<a href="/links/{{ page.id }}" class="btn btn-secondary" target="_blank">
|
||||
👁️ View Public Page
|
||||
</a>
|
||||
<a href="/" class="btn btn-secondary">
|
||||
🏠 Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Website logo mapping for better recognition
|
||||
function getWebsiteLogo(url) {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : 'https://' + url);
|
||||
const domain = urlObj.hostname.replace('www.', '');
|
||||
|
||||
const logoMap = {
|
||||
'youtube.com': 'https://www.youtube.com/favicon.ico',
|
||||
'youtu.be': 'https://www.youtube.com/favicon.ico',
|
||||
'facebook.com': 'https://www.facebook.com/favicon.ico',
|
||||
'instagram.com': 'https://www.instagram.com/favicon.ico',
|
||||
'twitter.com': 'https://abs.twimg.com/favicons/twitter.ico',
|
||||
'x.com': 'https://abs.twimg.com/favicons/twitter.ico',
|
||||
'linkedin.com': 'https://static.licdn.com/sc/h/al2o9zrvru7aqj8e1x2rzsrca',
|
||||
'github.com': 'https://github.com/favicon.ico',
|
||||
'stackoverflow.com': 'https://stackoverflow.com/favicon.ico',
|
||||
'reddit.com': 'https://www.reddit.com/favicon.ico',
|
||||
'medium.com': 'https://medium.com/favicon.ico',
|
||||
'discord.com': 'https://discord.com/assets/f8389ca1a741a115313bede9ac02e2c0.ico',
|
||||
'twitch.tv': 'https://static.twitchcdn.net/assets/favicon-32-d6025c14e900565d6177.png',
|
||||
'spotify.com': 'https://open.spotify.com/favicon.ico',
|
||||
'apple.com': 'https://www.apple.com/favicon.ico',
|
||||
'google.com': 'https://www.google.com/favicon.ico',
|
||||
'microsoft.com': 'https://www.microsoft.com/favicon.ico',
|
||||
'amazon.com': 'https://www.amazon.com/favicon.ico',
|
||||
'netflix.com': 'https://assets.nflxext.com/ffe/siteui/common/icons/nficon2016.ico',
|
||||
'whatsapp.com': 'https://static.whatsapp.net/rsrc.php/v3/yz/r/ujTY9i_Jhs1.png'
|
||||
};
|
||||
|
||||
if (logoMap[domain]) {
|
||||
return logoMap[domain];
|
||||
}
|
||||
|
||||
// Fallback to favicon
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set logos for links
|
||||
function setLogosForLinks() {
|
||||
document.querySelectorAll('[data-url]').forEach(linkElement => {
|
||||
const url = linkElement.getAttribute('data-url');
|
||||
const logoImg = linkElement.querySelector('.link-logo');
|
||||
const fallbackIcon = linkElement.querySelector('.link-icon');
|
||||
const logoSrc = getWebsiteLogo(url);
|
||||
|
||||
if (logoSrc && logoImg) {
|
||||
logoImg.src = logoSrc;
|
||||
logoImg.style.display = 'block';
|
||||
logoImg.alt = new URL(url.startsWith('http') ? url : 'https://' + url).hostname;
|
||||
// Hide the fallback icon when logo is shown
|
||||
logoImg.onload = function() {
|
||||
if (fallbackIcon) fallbackIcon.style.display = 'none';
|
||||
};
|
||||
logoImg.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
if (fallbackIcon) fallbackIcon.style.display = 'flex';
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize logos when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setLogosForLinks();
|
||||
loadQRCodePreview();
|
||||
});
|
||||
|
||||
// QR Code functionality
|
||||
let currentQRId = null;
|
||||
|
||||
async function loadQRCodePreview() {
|
||||
try {
|
||||
const pageId = '{{ page.id }}';
|
||||
|
||||
// Find the QR code for this page
|
||||
const response = await fetch('/api/qr_codes');
|
||||
const qrCodes = await response.json();
|
||||
|
||||
const pageQR = qrCodes.find(qr => qr.type === 'link_page' && qr.page_id === pageId);
|
||||
|
||||
if (pageQR) {
|
||||
currentQRId = pageQR.id;
|
||||
const previewImage = document.getElementById('qr-preview-image');
|
||||
const loadingText = document.getElementById('qr-preview-loading');
|
||||
const downloadActions = document.getElementById('qr-download-actions');
|
||||
|
||||
previewImage.src = pageQR.preview;
|
||||
previewImage.style.display = 'block';
|
||||
loadingText.style.display = 'none';
|
||||
downloadActions.style.display = 'flex';
|
||||
} else {
|
||||
document.getElementById('qr-preview-loading').textContent = 'QR code not found';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load QR code preview:', error);
|
||||
document.getElementById('qr-preview-loading').textContent = 'Failed to load QR code';
|
||||
}
|
||||
}
|
||||
|
||||
function downloadQRCode(format) {
|
||||
if (!currentQRId) {
|
||||
alert('QR code not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === 'svg') {
|
||||
window.open(`/api/download/${currentQRId}/svg`, '_blank');
|
||||
} else {
|
||||
window.open(`/api/download/${currentQRId}`, '_blank');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
8
app/utils/__init__.py
Normal file → Executable file
8
app/utils/__init__.py
Normal file → Executable file
@@ -4,8 +4,9 @@ Utility modules for QR Code Manager
|
||||
|
||||
from .auth import init_admin, login_required, verify_password, get_admin_credentials
|
||||
from .qr_generator import QRCodeGenerator
|
||||
from .link_manager import LinkPageManager, link_pages_db
|
||||
from .data_manager import QRDataManager, qr_codes_db
|
||||
from .link_manager import LinkPageManager
|
||||
from .data_manager import QRDataManager
|
||||
from .url_shortener import URLShortener
|
||||
|
||||
__all__ = [
|
||||
'init_admin',
|
||||
@@ -14,7 +15,6 @@ __all__ = [
|
||||
'get_admin_credentials',
|
||||
'QRCodeGenerator',
|
||||
'LinkPageManager',
|
||||
'link_pages_db',
|
||||
'QRDataManager',
|
||||
'qr_codes_db'
|
||||
'URLShortener'
|
||||
]
|
||||
|
||||
0
app/utils/auth.py
Normal file → Executable file
0
app/utils/auth.py
Normal file → Executable file
58
app/utils/data_manager.py
Normal file → Executable file
58
app/utils/data_manager.py
Normal file → Executable file
@@ -3,14 +3,39 @@ Data storage utilities for QR codes
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# In-memory storage for QR codes (in production, use a database)
|
||||
qr_codes_db = {}
|
||||
# Data storage directory
|
||||
DATA_DIR = 'data'
|
||||
QR_CODES_FILE = os.path.join(DATA_DIR, 'qr_codes.json')
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
class QRDataManager:
|
||||
def __init__(self):
|
||||
pass
|
||||
self.qr_codes_db = self._load_qr_codes()
|
||||
|
||||
def _load_qr_codes(self):
|
||||
"""Load QR codes from JSON file"""
|
||||
try:
|
||||
if os.path.exists(QR_CODES_FILE):
|
||||
with open(QR_CODES_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"Error loading QR codes: {e}")
|
||||
return {}
|
||||
|
||||
def _save_qr_codes(self):
|
||||
"""Save QR codes to JSON file"""
|
||||
try:
|
||||
with open(QR_CODES_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.qr_codes_db, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"Error saving QR codes: {e}")
|
||||
|
||||
def save_qr_record(self, qr_type, content, settings, image_data, page_id=None):
|
||||
"""Save QR code record to database"""
|
||||
@@ -27,32 +52,45 @@ class QRDataManager:
|
||||
if page_id:
|
||||
qr_record['page_id'] = page_id
|
||||
|
||||
qr_codes_db[qr_id] = qr_record
|
||||
self.qr_codes_db[qr_id] = qr_record
|
||||
self._save_qr_codes() # Persist to file
|
||||
return qr_id
|
||||
|
||||
def get_qr_record(self, qr_id):
|
||||
"""Get QR code record"""
|
||||
return qr_codes_db.get(qr_id)
|
||||
# Reload data from file to ensure we have the latest data
|
||||
self.qr_codes_db = self._load_qr_codes()
|
||||
return self.qr_codes_db.get(qr_id)
|
||||
|
||||
def get_qr_code(self, qr_id):
|
||||
"""Get QR code record (alias for compatibility)"""
|
||||
return self.get_qr_record(qr_id)
|
||||
|
||||
def delete_qr_record(self, qr_id):
|
||||
"""Delete QR code record"""
|
||||
if qr_id in qr_codes_db:
|
||||
del qr_codes_db[qr_id]
|
||||
if qr_id in self.qr_codes_db:
|
||||
del self.qr_codes_db[qr_id]
|
||||
self._save_qr_codes() # Persist to file
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_qr_codes(self):
|
||||
"""List all QR codes"""
|
||||
# Reload data from file to ensure we have the latest data
|
||||
self.qr_codes_db = self._load_qr_codes()
|
||||
qr_list = []
|
||||
for qr_id, qr_data in qr_codes_db.items():
|
||||
for qr_id, qr_data in self.qr_codes_db.items():
|
||||
qr_list.append({
|
||||
'id': qr_id,
|
||||
'type': qr_data['type'],
|
||||
'created_at': qr_data['created_at'],
|
||||
'preview': f'data:image/png;base64,{qr_data["image_data"]}'
|
||||
'preview': f'data:image/png;base64,{qr_data["image_data"]}',
|
||||
'page_id': qr_data.get('page_id') # Include page_id if it exists
|
||||
})
|
||||
return qr_list
|
||||
|
||||
def qr_exists(self, qr_id):
|
||||
"""Check if QR code exists"""
|
||||
return qr_id in qr_codes_db
|
||||
# Reload data from file to ensure we have the latest data
|
||||
self.qr_codes_db = self._load_qr_codes()
|
||||
return qr_id in self.qr_codes_db
|
||||
|
||||
180
app/utils/link_manager.py
Normal file → Executable file
180
app/utils/link_manager.py
Normal file → Executable file
@@ -3,14 +3,41 @@ Dynamic Link Page Manager utilities
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from .url_shortener import URLShortener
|
||||
|
||||
# In-memory storage for dynamic link pages (in production, use a database)
|
||||
link_pages_db = {}
|
||||
# Data storage directory
|
||||
DATA_DIR = 'data'
|
||||
LINK_PAGES_FILE = os.path.join(DATA_DIR, 'link_pages.json')
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
class LinkPageManager:
|
||||
def __init__(self):
|
||||
pass
|
||||
self.url_shortener = URLShortener()
|
||||
self.link_pages_db = self._load_link_pages()
|
||||
|
||||
def _load_link_pages(self):
|
||||
"""Load link pages from JSON file"""
|
||||
try:
|
||||
if os.path.exists(LINK_PAGES_FILE):
|
||||
with open(LINK_PAGES_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"Error loading link pages: {e}")
|
||||
return {}
|
||||
|
||||
def _save_link_pages(self):
|
||||
"""Save link pages to JSON file"""
|
||||
try:
|
||||
with open(LINK_PAGES_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.link_pages_db, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"Error saving link pages: {e}")
|
||||
|
||||
def create_link_page(self, title="My Links", description="Collection of useful links"):
|
||||
"""Create a new dynamic link page"""
|
||||
@@ -22,34 +49,85 @@ class LinkPageManager:
|
||||
'links': [],
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'view_count': 0
|
||||
'view_count': 0,
|
||||
'short_url': None, # Will be set when short URL is created
|
||||
'short_code': None
|
||||
}
|
||||
link_pages_db[page_id] = page_data
|
||||
self.link_pages_db[page_id] = page_data
|
||||
self._save_link_pages() # Persist to file
|
||||
return page_id
|
||||
|
||||
def add_link(self, page_id, title, url, description=""):
|
||||
"""Add a link to a page"""
|
||||
if page_id not in link_pages_db:
|
||||
def set_page_short_url(self, page_id, short_url, short_code):
|
||||
"""Set the short URL for a link page"""
|
||||
if page_id in self.link_pages_db:
|
||||
self.link_pages_db[page_id]['short_url'] = short_url
|
||||
self.link_pages_db[page_id]['short_code'] = short_code
|
||||
self.link_pages_db[page_id]['updated_at'] = datetime.now().isoformat()
|
||||
self._save_link_pages() # Persist to file
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_link(self, page_id, title, url, description="", enable_shortener=False, custom_short_code=None):
|
||||
"""Add a link to a page with optional URL shortening"""
|
||||
print(f"DEBUG: LinkManager.add_link called for page {page_id}")
|
||||
print(f"DEBUG: Current pages in memory: {list(self.link_pages_db.keys())}")
|
||||
|
||||
if page_id not in self.link_pages_db:
|
||||
print(f"DEBUG: Page {page_id} not found in memory, reloading from file")
|
||||
self.link_pages_db = self._load_link_pages()
|
||||
print(f"DEBUG: After reload, pages: {list(self.link_pages_db.keys())}")
|
||||
|
||||
if page_id not in self.link_pages_db:
|
||||
print(f"DEBUG: Page {page_id} still not found after reload")
|
||||
return False
|
||||
|
||||
# Ensure URL has protocol
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
url = f'https://{url}'
|
||||
|
||||
# Create the link data
|
||||
link_data = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'title': title,
|
||||
'url': url if url.startswith(('http://', 'https://')) else f'https://{url}',
|
||||
'url': url,
|
||||
'description': description,
|
||||
'created_at': datetime.now().isoformat()
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'short_url': None,
|
||||
'short_code': None,
|
||||
'clicks': 0
|
||||
}
|
||||
|
||||
link_pages_db[page_id]['links'].append(link_data)
|
||||
link_pages_db[page_id]['updated_at'] = datetime.now().isoformat()
|
||||
# Generate short URL if enabled
|
||||
if enable_shortener:
|
||||
print(f"DEBUG: Creating short URL for {url}")
|
||||
try:
|
||||
short_result = self.url_shortener.create_short_url(
|
||||
url,
|
||||
custom_code=custom_short_code,
|
||||
title=title
|
||||
)
|
||||
print(f"DEBUG: Short URL created: {short_result}")
|
||||
link_data['short_url'] = short_result['short_url']
|
||||
link_data['short_code'] = short_result['short_code']
|
||||
except Exception as e:
|
||||
# If shortening fails, continue without it
|
||||
print(f"DEBUG: URL shortening failed: {e}")
|
||||
|
||||
print(f"DEBUG: Adding link to page data: {link_data}")
|
||||
self.link_pages_db[page_id]['links'].append(link_data)
|
||||
self.link_pages_db[page_id]['updated_at'] = datetime.now().isoformat()
|
||||
|
||||
print(f"DEBUG: Saving link pages to file")
|
||||
self._save_link_pages() # Persist to file
|
||||
print(f"DEBUG: Link added successfully")
|
||||
return True
|
||||
|
||||
def update_link(self, page_id, link_id, title=None, url=None, description=None):
|
||||
"""Update a specific link"""
|
||||
if page_id not in link_pages_db:
|
||||
def update_link(self, page_id, link_id, title=None, url=None, description=None, enable_shortener=None, custom_short_code=None):
|
||||
"""Update a specific link with optional URL shortening"""
|
||||
if page_id not in self.link_pages_db:
|
||||
return False
|
||||
|
||||
for link in link_pages_db[page_id]['links']:
|
||||
for link in self.link_pages_db[page_id]['links']:
|
||||
if link['id'] == link_id:
|
||||
if title is not None:
|
||||
link['title'] = title
|
||||
@@ -58,29 +136,81 @@ class LinkPageManager:
|
||||
if description is not None:
|
||||
link['description'] = description
|
||||
|
||||
link_pages_db[page_id]['updated_at'] = datetime.now().isoformat()
|
||||
# Handle URL shortening update
|
||||
if enable_shortener is not None:
|
||||
if enable_shortener and not link.get('short_url'):
|
||||
# Create new short URL
|
||||
try:
|
||||
short_result = self.url_shortener.create_short_url(
|
||||
link['url'],
|
||||
custom_code=custom_short_code,
|
||||
title=link['title']
|
||||
)
|
||||
link['short_url'] = short_result['short_url']
|
||||
link['short_code'] = short_result['short_code']
|
||||
except Exception as e:
|
||||
print(f"URL shortening failed: {e}")
|
||||
elif not enable_shortener and link.get('short_code'):
|
||||
# Remove short URL
|
||||
if link.get('short_code'):
|
||||
self.url_shortener.delete_url(link['short_code'])
|
||||
link['short_url'] = None
|
||||
link['short_code'] = None
|
||||
|
||||
self.link_pages_db[page_id]['updated_at'] = datetime.now().isoformat()
|
||||
self._save_link_pages() # Persist to file
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_link(self, page_id, link_id):
|
||||
"""Delete a specific link"""
|
||||
if page_id not in link_pages_db:
|
||||
if page_id not in self.link_pages_db:
|
||||
return False
|
||||
|
||||
links = link_pages_db[page_id]['links']
|
||||
link_pages_db[page_id]['links'] = [link for link in links if link['id'] != link_id]
|
||||
link_pages_db[page_id]['updated_at'] = datetime.now().isoformat()
|
||||
links = self.link_pages_db[page_id]['links']
|
||||
for link in links:
|
||||
if link['id'] == link_id and link.get('short_code'):
|
||||
# Delete the short URL if it exists
|
||||
self.url_shortener.delete_url(link['short_code'])
|
||||
|
||||
self.link_pages_db[page_id]['links'] = [link for link in links if link['id'] != link_id]
|
||||
self.link_pages_db[page_id]['updated_at'] = datetime.now().isoformat()
|
||||
self._save_link_pages() # Persist to file
|
||||
return True
|
||||
|
||||
def increment_view_count(self, page_id):
|
||||
"""Increment view count for a page"""
|
||||
if page_id in link_pages_db:
|
||||
link_pages_db[page_id]['view_count'] += 1
|
||||
if page_id in self.link_pages_db:
|
||||
self.link_pages_db[page_id]['view_count'] += 1
|
||||
self._save_link_pages() # Persist to file
|
||||
|
||||
def get_page(self, page_id):
|
||||
"""Get page data"""
|
||||
return link_pages_db.get(page_id)
|
||||
# Reload data from file to ensure we have the latest data
|
||||
self.link_pages_db = self._load_link_pages()
|
||||
return self.link_pages_db.get(page_id)
|
||||
|
||||
def page_exists(self, page_id):
|
||||
"""Check if page exists"""
|
||||
return page_id in link_pages_db
|
||||
# Reload data from file to ensure we have the latest data
|
||||
self.link_pages_db = self._load_link_pages()
|
||||
return page_id in self.link_pages_db
|
||||
|
||||
# URL Shortener management methods
|
||||
def create_standalone_short_url(self, url, title="", custom_code=None):
|
||||
"""Create a standalone short URL (not tied to a link page)"""
|
||||
return self.url_shortener.create_short_url(url, custom_code, title)
|
||||
|
||||
def get_short_url_stats(self, short_code):
|
||||
"""Get statistics for a short URL"""
|
||||
return self.url_shortener.get_url_stats(short_code)
|
||||
|
||||
def list_all_short_urls(self):
|
||||
"""List all short URLs in the system"""
|
||||
return self.url_shortener.list_urls()
|
||||
|
||||
def resolve_short_url(self, short_code):
|
||||
"""Resolve a short URL to its original URL"""
|
||||
# Reload URLShortener data to ensure we have the latest URLs
|
||||
self.url_shortener = URLShortener()
|
||||
return self.url_shortener.get_original_url(short_code)
|
||||
|
||||
54
app/utils/qr_generator.py
Normal file → Executable file
54
app/utils/qr_generator.py
Normal file → Executable file
@@ -6,7 +6,9 @@ import os
|
||||
import qrcode
|
||||
from qrcode.image.styledpil import StyledPilImage
|
||||
from qrcode.image.styles.moduledrawers import RoundedModuleDrawer, CircleModuleDrawer, SquareModuleDrawer
|
||||
from qrcode.image.svg import SvgPathImage, SvgFragmentImage, SvgFillImage
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
class QRCodeGenerator:
|
||||
def __init__(self):
|
||||
@@ -19,8 +21,8 @@ class QRCodeGenerator:
|
||||
'style': 'square'
|
||||
}
|
||||
|
||||
def generate_qr_code(self, data, settings=None):
|
||||
"""Generate QR code with custom settings"""
|
||||
def generate_qr_code(self, data, settings=None, format='PNG'):
|
||||
"""Generate QR code with custom settings in PNG or SVG format"""
|
||||
if settings is None:
|
||||
settings = self.default_settings.copy()
|
||||
else:
|
||||
@@ -28,6 +30,13 @@ class QRCodeGenerator:
|
||||
merged_settings.update(settings)
|
||||
settings = merged_settings
|
||||
|
||||
if format.upper() == 'SVG':
|
||||
return self._generate_svg_qr_code(data, settings)
|
||||
else:
|
||||
return self._generate_png_qr_code(data, settings)
|
||||
|
||||
def _generate_png_qr_code(self, data, settings):
|
||||
"""Generate PNG QR code (existing functionality)"""
|
||||
# Create QR code instance
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
@@ -64,6 +73,47 @@ class QRCodeGenerator:
|
||||
|
||||
return img
|
||||
|
||||
def _generate_svg_qr_code(self, data, settings):
|
||||
"""Generate SVG QR code"""
|
||||
# Create QR code instance
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=settings['error_correction'],
|
||||
box_size=settings['size'],
|
||||
border=settings['border'],
|
||||
)
|
||||
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Choose SVG image factory based on style
|
||||
if settings['style'] == 'circle':
|
||||
# Use SvgFillImage for better circle support
|
||||
factory = SvgFillImage
|
||||
else:
|
||||
# Use SvgPathImage for square and rounded styles
|
||||
factory = SvgPathImage
|
||||
|
||||
# Generate SVG image
|
||||
img = qr.make_image(
|
||||
image_factory=factory,
|
||||
fill_color=settings['foreground_color'],
|
||||
back_color=settings['background_color']
|
||||
)
|
||||
|
||||
return img
|
||||
|
||||
def generate_qr_code_svg_string(self, data, settings=None):
|
||||
"""Generate QR code as SVG string"""
|
||||
svg_img = self.generate_qr_code(data, settings, format='SVG')
|
||||
|
||||
# Convert SVG image to string
|
||||
svg_buffer = io.BytesIO()
|
||||
svg_img.save(svg_buffer)
|
||||
svg_buffer.seek(0)
|
||||
|
||||
return svg_buffer.getvalue().decode('utf-8')
|
||||
|
||||
def add_logo(self, qr_img, logo_path, logo_size_ratio=0.2):
|
||||
"""Add logo to QR code"""
|
||||
try:
|
||||
|
||||
131
app/utils/url_shortener.py
Executable file
131
app/utils/url_shortener.py
Executable file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
URL Shortener utilities for QR Code Manager
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import string
|
||||
import random
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Data storage directory
|
||||
DATA_DIR = 'data'
|
||||
SHORT_URLS_FILE = os.path.join(DATA_DIR, 'short_urls.json')
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
class URLShortener:
|
||||
def __init__(self):
|
||||
self.base_domain = os.environ.get('APP_DOMAIN', 'localhost:5000')
|
||||
# Ensure we have the protocol
|
||||
if not self.base_domain.startswith(('http://', 'https://')):
|
||||
# Use HTTPS for production domains, HTTP for localhost
|
||||
protocol = 'https://' if 'localhost' not in self.base_domain else 'http://'
|
||||
self.base_domain = f"{protocol}{self.base_domain}"
|
||||
|
||||
self.short_urls_db = self._load_short_urls()
|
||||
|
||||
def _load_short_urls(self):
|
||||
"""Load short URLs from JSON file"""
|
||||
try:
|
||||
if os.path.exists(SHORT_URLS_FILE):
|
||||
with open(SHORT_URLS_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"Error loading short URLs: {e}")
|
||||
return {}
|
||||
|
||||
def _save_short_urls(self):
|
||||
"""Save short URLs to JSON file"""
|
||||
try:
|
||||
with open(SHORT_URLS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.short_urls_db, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"Error saving short URLs: {e}")
|
||||
|
||||
def generate_short_code(self, length=6):
|
||||
"""Generate a random short code"""
|
||||
characters = string.ascii_letters + string.digits
|
||||
while True:
|
||||
short_code = ''.join(random.choice(characters) for _ in range(length))
|
||||
# Ensure uniqueness
|
||||
if short_code not in self.short_urls_db:
|
||||
return short_code
|
||||
|
||||
def create_short_url(self, original_url, custom_code=None, title=""):
|
||||
"""Create a shortened URL"""
|
||||
print(f"DEBUG: URLShortener.create_short_url called with url='{original_url}', custom_code='{custom_code}', title='{title}'")
|
||||
|
||||
# Generate or use custom short code
|
||||
if custom_code and custom_code not in self.short_urls_db:
|
||||
short_code = custom_code
|
||||
print(f"DEBUG: Using custom short code: {short_code}")
|
||||
else:
|
||||
short_code = self.generate_short_code()
|
||||
print(f"DEBUG: Generated short code: {short_code}")
|
||||
|
||||
# Ensure original URL has protocol
|
||||
if not original_url.startswith(('http://', 'https://')):
|
||||
original_url = f'https://{original_url}'
|
||||
|
||||
# Create URL record
|
||||
url_data = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'short_code': short_code,
|
||||
'original_url': original_url,
|
||||
'title': title,
|
||||
'clicks': 0,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'last_accessed': None
|
||||
}
|
||||
|
||||
print(f"DEBUG: Adding to short_urls_db: {short_code} -> {url_data}")
|
||||
self.short_urls_db[short_code] = url_data
|
||||
|
||||
print(f"DEBUG: Saving short URLs to file")
|
||||
self._save_short_urls() # Persist to file
|
||||
print(f"DEBUG: Short URLs saved successfully")
|
||||
|
||||
# Return the complete short URL
|
||||
short_url = f"{self.base_domain}/s/{short_code}"
|
||||
print(f"DEBUG: Returning short URL: {short_url}")
|
||||
return {
|
||||
'short_url': short_url,
|
||||
'short_code': short_code,
|
||||
'original_url': original_url,
|
||||
'id': url_data['id']
|
||||
}
|
||||
|
||||
def get_original_url(self, short_code):
|
||||
"""Get original URL from short code and track click"""
|
||||
if short_code in self.short_urls_db:
|
||||
url_data = self.short_urls_db[short_code]
|
||||
# Track click
|
||||
url_data['clicks'] += 1
|
||||
url_data['last_accessed'] = datetime.now().isoformat()
|
||||
self._save_short_urls() # Persist to file
|
||||
return url_data['original_url']
|
||||
return None
|
||||
|
||||
def get_url_stats(self, short_code):
|
||||
"""Get statistics for a short URL"""
|
||||
return self.short_urls_db.get(short_code)
|
||||
|
||||
def list_urls(self):
|
||||
"""List all short URLs"""
|
||||
return list(self.short_urls_db.values())
|
||||
|
||||
def delete_url(self, short_code):
|
||||
"""Delete a short URL"""
|
||||
if short_code in self.short_urls_db:
|
||||
del self.short_urls_db[short_code]
|
||||
self._save_short_urls() # Persist to file
|
||||
return True
|
||||
return False
|
||||
|
||||
def url_exists(self, short_code):
|
||||
"""Check if short URL exists"""
|
||||
return short_code in self.short_urls_db
|
||||
377
backup.py
Executable file
377
backup.py
Executable file
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QR Code Manager - Backup Utility
|
||||
|
||||
This script creates comprehensive backups of the QR Code Manager application
|
||||
including data files, configuration, and optionally the entire application.
|
||||
|
||||
Usage:
|
||||
python backup.py [options]
|
||||
|
||||
Options:
|
||||
--data-only : Backup only data files (default)
|
||||
--full : Full backup including application files
|
||||
--restore : Restore from backup
|
||||
--list : List available backups
|
||||
--config : Backup configuration files only
|
||||
--auto : Automated backup (for cron jobs)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
class QRCodeManagerBackup:
|
||||
def __init__(self):
|
||||
self.app_root = Path(__file__).parent
|
||||
self.backup_dir = self.app_root / "backups"
|
||||
self.data_dir = self.app_root / "data"
|
||||
self.config_files = [
|
||||
".env",
|
||||
".env.production",
|
||||
"docker-compose.yml",
|
||||
"Dockerfile",
|
||||
"gunicorn.conf.py",
|
||||
"requirements.txt"
|
||||
]
|
||||
self.data_files = [
|
||||
"data/link_pages.json",
|
||||
"data/qr_codes.json",
|
||||
"data/short_urls.json"
|
||||
]
|
||||
|
||||
# Ensure backup directory exists
|
||||
self.backup_dir.mkdir(exist_ok=True)
|
||||
|
||||
def get_timestamp(self):
|
||||
"""Get current timestamp for backup naming"""
|
||||
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
def create_data_backup(self, backup_name=None):
|
||||
"""Create backup of data files only"""
|
||||
if not backup_name:
|
||||
backup_name = f"qr_data_backup_{self.get_timestamp()}.tar.gz"
|
||||
|
||||
backup_path = self.backup_dir / backup_name
|
||||
|
||||
print(f"🗂️ Creating data backup: {backup_name}")
|
||||
|
||||
with tarfile.open(backup_path, "w:gz") as tar:
|
||||
# Add data files
|
||||
for data_file in self.data_files:
|
||||
file_path = self.app_root / data_file
|
||||
if file_path.exists():
|
||||
tar.add(file_path, arcname=data_file)
|
||||
print(f" ✅ Added: {data_file}")
|
||||
else:
|
||||
print(f" ⚠️ Missing: {data_file}")
|
||||
|
||||
# Add backup metadata
|
||||
metadata = {
|
||||
"backup_type": "data",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"app_version": self.get_app_version(),
|
||||
"files_included": self.data_files
|
||||
}
|
||||
|
||||
metadata_path = self.app_root / "backup_metadata.json"
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
tar.add(metadata_path, arcname="backup_metadata.json")
|
||||
metadata_path.unlink() # Remove temp file
|
||||
|
||||
print(f"✅ Data backup created: {backup_path}")
|
||||
return backup_path
|
||||
|
||||
def create_config_backup(self, backup_name=None):
|
||||
"""Create backup of configuration files only"""
|
||||
if not backup_name:
|
||||
backup_name = f"qr_config_backup_{self.get_timestamp()}.tar.gz"
|
||||
|
||||
backup_path = self.backup_dir / backup_name
|
||||
|
||||
print(f"⚙️ Creating config backup: {backup_name}")
|
||||
|
||||
with tarfile.open(backup_path, "w:gz") as tar:
|
||||
# Add config files
|
||||
for config_file in self.config_files:
|
||||
file_path = self.app_root / config_file
|
||||
if file_path.exists():
|
||||
tar.add(file_path, arcname=config_file)
|
||||
print(f" ✅ Added: {config_file}")
|
||||
else:
|
||||
print(f" ⚠️ Missing: {config_file}")
|
||||
|
||||
# Add backup metadata
|
||||
metadata = {
|
||||
"backup_type": "config",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"app_version": self.get_app_version(),
|
||||
"files_included": self.config_files
|
||||
}
|
||||
|
||||
metadata_path = self.app_root / "backup_metadata.json"
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
tar.add(metadata_path, arcname="backup_metadata.json")
|
||||
metadata_path.unlink() # Remove temp file
|
||||
|
||||
print(f"✅ Config backup created: {backup_path}")
|
||||
return backup_path
|
||||
|
||||
def create_full_backup(self, backup_name=None):
|
||||
"""Create full backup including application files"""
|
||||
if not backup_name:
|
||||
backup_name = f"qr_full_backup_{self.get_timestamp()}.tar.gz"
|
||||
|
||||
backup_path = self.backup_dir / backup_name
|
||||
|
||||
print(f"📦 Creating full backup: {backup_name}")
|
||||
|
||||
# Exclude patterns
|
||||
exclude_patterns = [
|
||||
"__pycache__",
|
||||
"*.pyc",
|
||||
".git",
|
||||
"backups",
|
||||
"*.log",
|
||||
".dockerignore",
|
||||
"backup.py"
|
||||
]
|
||||
|
||||
def exclude_filter(tarinfo):
|
||||
for pattern in exclude_patterns:
|
||||
if pattern in tarinfo.name:
|
||||
return None
|
||||
return tarinfo
|
||||
|
||||
with tarfile.open(backup_path, "w:gz") as tar:
|
||||
# Add entire application directory
|
||||
tar.add(self.app_root, arcname="qr-code-manager", filter=exclude_filter)
|
||||
|
||||
# Add backup metadata
|
||||
metadata = {
|
||||
"backup_type": "full",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"app_version": self.get_app_version(),
|
||||
"excluded_patterns": exclude_patterns
|
||||
}
|
||||
|
||||
metadata_path = self.app_root / "backup_metadata.json"
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
tar.add(metadata_path, arcname="backup_metadata.json")
|
||||
metadata_path.unlink() # Remove temp file
|
||||
|
||||
print(f"✅ Full backup created: {backup_path}")
|
||||
return backup_path
|
||||
|
||||
def list_backups(self):
|
||||
"""List all available backups"""
|
||||
print("📋 Available backups:")
|
||||
print("-" * 60)
|
||||
|
||||
backup_files = sorted(self.backup_dir.glob("*.tar.gz"), reverse=True)
|
||||
|
||||
if not backup_files:
|
||||
print(" No backups found")
|
||||
return
|
||||
|
||||
for backup_file in backup_files:
|
||||
# Get file info
|
||||
stat = backup_file.stat()
|
||||
size_mb = stat.st_size / (1024 * 1024)
|
||||
modified = datetime.fromtimestamp(stat.st_mtime)
|
||||
|
||||
# Try to get backup type from filename
|
||||
if "data" in backup_file.name:
|
||||
backup_type = "📄 Data"
|
||||
elif "config" in backup_file.name:
|
||||
backup_type = "⚙️ Config"
|
||||
elif "full" in backup_file.name:
|
||||
backup_type = "📦 Full"
|
||||
else:
|
||||
backup_type = "❓ Unknown"
|
||||
|
||||
print(f" {backup_type:12} | {backup_file.name:40} | {size_mb:6.1f}MB | {modified.strftime('%Y-%m-%d %H:%M')}")
|
||||
|
||||
def restore_backup(self, backup_file):
|
||||
"""Restore from backup file"""
|
||||
backup_path = Path(backup_file)
|
||||
|
||||
if not backup_path.exists():
|
||||
# Try in backup directory
|
||||
backup_path = self.backup_dir / backup_file
|
||||
if not backup_path.exists():
|
||||
print(f"❌ Backup file not found: {backup_file}")
|
||||
return False
|
||||
|
||||
print(f"🔄 Restoring from backup: {backup_path.name}")
|
||||
|
||||
# Create restoration directory
|
||||
restore_dir = self.app_root / f"restore_{self.get_timestamp()}"
|
||||
restore_dir.mkdir(exist_ok=True)
|
||||
|
||||
try:
|
||||
with tarfile.open(backup_path, "r:gz") as tar:
|
||||
# Extract to restoration directory
|
||||
tar.extractall(restore_dir)
|
||||
|
||||
# Check backup metadata
|
||||
metadata_file = restore_dir / "backup_metadata.json"
|
||||
if metadata_file.exists():
|
||||
with open(metadata_file) as f:
|
||||
metadata = json.load(f)
|
||||
print(f" Backup type: {metadata.get('backup_type', 'unknown')}")
|
||||
print(f" Created: {metadata.get('timestamp', 'unknown')}")
|
||||
print(f" App version: {metadata.get('app_version', 'unknown')}")
|
||||
|
||||
# Ask for confirmation
|
||||
confirm = input(" Proceed with restoration? (y/N): ").lower().strip()
|
||||
if confirm != 'y':
|
||||
shutil.rmtree(restore_dir)
|
||||
print("❌ Restoration cancelled")
|
||||
return False
|
||||
|
||||
# Restore files based on backup type
|
||||
backup_type = metadata.get('backup_type', 'unknown') if metadata_file.exists() else 'unknown'
|
||||
|
||||
if backup_type == 'data':
|
||||
# Restore data files
|
||||
for data_file in self.data_files:
|
||||
src = restore_dir / data_file
|
||||
dst = self.app_root / data_file
|
||||
if src.exists():
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
print(f" ✅ Restored: {data_file}")
|
||||
|
||||
elif backup_type == 'config':
|
||||
# Restore config files
|
||||
for config_file in self.config_files:
|
||||
src = restore_dir / config_file
|
||||
dst = self.app_root / config_file
|
||||
if src.exists():
|
||||
shutil.copy2(src, dst)
|
||||
print(f" ✅ Restored: {config_file}")
|
||||
|
||||
elif backup_type == 'full':
|
||||
# Full restoration - more complex
|
||||
print(" ⚠️ Full restoration requires manual review")
|
||||
print(f" Extracted to: {restore_dir}")
|
||||
print(" Please manually copy files as needed")
|
||||
return True
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(restore_dir)
|
||||
print("✅ Restoration completed successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Restoration failed: {e}")
|
||||
if restore_dir.exists():
|
||||
shutil.rmtree(restore_dir)
|
||||
return False
|
||||
|
||||
def get_app_version(self):
|
||||
"""Get application version from main.py or git"""
|
||||
try:
|
||||
# Try to get git commit hash
|
||||
import subprocess
|
||||
result = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'],
|
||||
cwd=self.app_root,
|
||||
capture_output=True,
|
||||
text=True)
|
||||
if result.returncode == 0:
|
||||
return f"git-{result.stdout.strip()}"
|
||||
except:
|
||||
pass
|
||||
|
||||
# Fallback to file modification time
|
||||
main_py = self.app_root / "main.py"
|
||||
if main_py.exists():
|
||||
mtime = datetime.fromtimestamp(main_py.stat().st_mtime)
|
||||
return f"modified-{mtime.strftime('%Y%m%d')}"
|
||||
|
||||
return "unknown"
|
||||
|
||||
def cleanup_old_backups(self, keep_count=10):
|
||||
"""Remove old backups, keeping only the most recent ones"""
|
||||
backup_files = sorted(self.backup_dir.glob("*.tar.gz"),
|
||||
key=lambda x: x.stat().st_mtime,
|
||||
reverse=True)
|
||||
|
||||
if len(backup_files) <= keep_count:
|
||||
return
|
||||
|
||||
print(f"🧹 Cleaning up old backups (keeping {keep_count} most recent)")
|
||||
|
||||
for backup_file in backup_files[keep_count:]:
|
||||
print(f" 🗑️ Removing: {backup_file.name}")
|
||||
backup_file.unlink()
|
||||
|
||||
def automated_backup(self):
|
||||
"""Perform automated backup suitable for cron jobs"""
|
||||
print(f"🤖 Automated backup started at {datetime.now()}")
|
||||
|
||||
try:
|
||||
# Create data backup
|
||||
self.create_data_backup()
|
||||
|
||||
# Create config backup weekly (if it's Monday)
|
||||
if datetime.now().weekday() == 0: # Monday
|
||||
self.create_config_backup()
|
||||
|
||||
# Create full backup monthly (if it's the 1st)
|
||||
if datetime.now().day == 1:
|
||||
self.create_full_backup()
|
||||
|
||||
# Cleanup old backups
|
||||
self.cleanup_old_backups(keep_count=15)
|
||||
|
||||
print("✅ Automated backup completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Automated backup failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="QR Code Manager Backup Utility")
|
||||
parser.add_argument("--data-only", action="store_true", help="Backup only data files")
|
||||
parser.add_argument("--config", action="store_true", help="Backup only config files")
|
||||
parser.add_argument("--full", action="store_true", help="Full backup including application files")
|
||||
parser.add_argument("--restore", type=str, help="Restore from backup file")
|
||||
parser.add_argument("--list", action="store_true", help="List available backups")
|
||||
parser.add_argument("--auto", action="store_true", help="Automated backup (for cron jobs)")
|
||||
parser.add_argument("--cleanup", type=int, help="Cleanup old backups, keeping N most recent")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
backup_util = QRCodeManagerBackup()
|
||||
|
||||
if args.list:
|
||||
backup_util.list_backups()
|
||||
elif args.restore:
|
||||
backup_util.restore_backup(args.restore)
|
||||
elif args.auto:
|
||||
backup_util.automated_backup()
|
||||
elif args.cleanup:
|
||||
backup_util.cleanup_old_backups(args.cleanup)
|
||||
elif args.config:
|
||||
backup_util.create_config_backup()
|
||||
elif args.full:
|
||||
backup_util.create_full_backup()
|
||||
else:
|
||||
# Default: data backup
|
||||
backup_util.create_data_backup()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
backups/qr_full_backup_20250801_165725.tar.gz
Normal file
BIN
backups/qr_full_backup_20250801_165725.tar.gz
Normal file
Binary file not shown.
196
clean_data.py
Executable file
196
clean_data.py
Executable file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QR Code Manager - Data Cleanup Script
|
||||
|
||||
This script removes all persistent data to prepare for a clean deployment.
|
||||
It will delete:
|
||||
- All QR codes and their data
|
||||
- All dynamic link pages
|
||||
- All short URLs
|
||||
- All uploaded QR code images
|
||||
- Flask session files
|
||||
|
||||
Use this script when you want to start fresh or prepare for deployment.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def clean_json_data():
|
||||
"""Clean all JSON data files"""
|
||||
data_dir = Path('data')
|
||||
json_files = [
|
||||
'qr_codes.json',
|
||||
'link_pages.json',
|
||||
'short_urls.json'
|
||||
]
|
||||
|
||||
print("🗑️ Cleaning JSON data files...")
|
||||
|
||||
for json_file in json_files:
|
||||
file_path = data_dir / json_file
|
||||
if file_path.exists():
|
||||
# Reset to empty object
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({}, f, indent=2)
|
||||
print(f" ✅ Cleared {json_file}")
|
||||
else:
|
||||
print(f" ⚠️ {json_file} not found")
|
||||
|
||||
def clean_qr_images():
|
||||
"""Clean all QR code image files"""
|
||||
qr_dir = Path('app/static/qr_codes')
|
||||
|
||||
print("🖼️ Cleaning QR code images...")
|
||||
|
||||
if qr_dir.exists():
|
||||
# Count files before deletion
|
||||
files = list(qr_dir.glob('*.png'))
|
||||
count = len(files)
|
||||
|
||||
# Delete all PNG files
|
||||
for file in files:
|
||||
file.unlink()
|
||||
|
||||
print(f" ✅ Deleted {count} QR code images")
|
||||
else:
|
||||
print(" ⚠️ QR codes directory not found")
|
||||
|
||||
def clean_flask_sessions():
|
||||
"""Clean Flask session files"""
|
||||
session_dir = Path('flask_session')
|
||||
|
||||
print("🔐 Cleaning Flask sessions...")
|
||||
|
||||
if session_dir.exists():
|
||||
# Count files before deletion
|
||||
files = list(session_dir.iterdir())
|
||||
count = len(files)
|
||||
|
||||
# Delete all session files
|
||||
for file in files:
|
||||
if file.is_file():
|
||||
file.unlink()
|
||||
|
||||
print(f" ✅ Deleted {count} session files")
|
||||
else:
|
||||
print(" ⚠️ Flask session directory not found")
|
||||
|
||||
def clean_logs():
|
||||
"""Clean any log files"""
|
||||
print("📝 Cleaning log files...")
|
||||
|
||||
log_patterns = ['*.log', '*.log.*']
|
||||
found_logs = False
|
||||
|
||||
for pattern in log_patterns:
|
||||
for log_file in Path('.').glob(pattern):
|
||||
log_file.unlink()
|
||||
print(f" ✅ Deleted {log_file}")
|
||||
found_logs = True
|
||||
|
||||
if not found_logs:
|
||||
print(" ✅ No log files found")
|
||||
|
||||
def clean_pycache():
|
||||
"""Clean Python cache files"""
|
||||
print("🐍 Cleaning Python cache...")
|
||||
|
||||
cache_dirs = list(Path('.').rglob('__pycache__'))
|
||||
pyc_files = list(Path('.').rglob('*.pyc'))
|
||||
|
||||
# Remove __pycache__ directories
|
||||
for cache_dir in cache_dirs:
|
||||
if cache_dir.is_dir():
|
||||
shutil.rmtree(cache_dir)
|
||||
|
||||
# Remove .pyc files
|
||||
for pyc_file in pyc_files:
|
||||
pyc_file.unlink()
|
||||
|
||||
total_cleaned = len(cache_dirs) + len(pyc_files)
|
||||
print(f" ✅ Cleaned {total_cleaned} cache files/directories")
|
||||
|
||||
def create_fresh_directories():
|
||||
"""Ensure required directories exist"""
|
||||
print("📁 Creating fresh directories...")
|
||||
|
||||
directories = [
|
||||
'data',
|
||||
'app/static/qr_codes',
|
||||
'app/static/logos',
|
||||
'flask_session'
|
||||
]
|
||||
|
||||
for directory in directories:
|
||||
Path(directory).mkdir(parents=True, exist_ok=True)
|
||||
print(f" ✅ Ensured {directory} exists")
|
||||
|
||||
def main():
|
||||
"""Main cleanup function"""
|
||||
print("🧹 QR Code Manager - Data Cleanup Script")
|
||||
print("=" * 50)
|
||||
|
||||
# Change to script directory
|
||||
script_dir = Path(__file__).parent
|
||||
os.chdir(script_dir)
|
||||
|
||||
print(f"📂 Working directory: {os.getcwd()}")
|
||||
print()
|
||||
|
||||
# Ask for confirmation
|
||||
print("⚠️ WARNING: This will delete ALL persistent data!")
|
||||
print(" - All QR codes and their images")
|
||||
print(" - All dynamic link pages")
|
||||
print(" - All short URLs")
|
||||
print(" - All Flask sessions")
|
||||
print(" - All log files")
|
||||
print(" - All Python cache files")
|
||||
print()
|
||||
|
||||
confirm = input("Are you sure you want to continue? Type 'YES' to confirm: ")
|
||||
|
||||
if confirm != 'YES':
|
||||
print("❌ Cleanup cancelled.")
|
||||
return
|
||||
|
||||
print()
|
||||
print("🚀 Starting cleanup process...")
|
||||
print()
|
||||
|
||||
try:
|
||||
# Clean different types of data
|
||||
clean_json_data()
|
||||
print()
|
||||
|
||||
clean_qr_images()
|
||||
print()
|
||||
|
||||
clean_flask_sessions()
|
||||
print()
|
||||
|
||||
clean_logs()
|
||||
print()
|
||||
|
||||
clean_pycache()
|
||||
print()
|
||||
|
||||
create_fresh_directories()
|
||||
print()
|
||||
|
||||
print("✅ Cleanup completed successfully!")
|
||||
print()
|
||||
print("🎉 Your QR Code Manager is now ready for a fresh deployment!")
|
||||
print(" Next steps:")
|
||||
print(" 1. Start the application: python main.py")
|
||||
print(" 2. Login with: admin / admin123")
|
||||
print(" 3. Change the default password")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error during cleanup: {e}")
|
||||
print("Please check the error and try again.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
101
clean_data.sh
Executable file
101
clean_data.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
"""
|
||||
QR Code Manager - Quick Data Cleanup Script (Shell Version)
|
||||
|
||||
A simple shell script to clean all persistent data for deployment.
|
||||
"""
|
||||
|
||||
echo "🧹 QR Code Manager - Quick Data Cleanup"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# Get script directory
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "📂 Working directory: $(pwd)"
|
||||
echo ""
|
||||
|
||||
echo "⚠️ WARNING: This will delete ALL persistent data!"
|
||||
echo " - All QR codes and their images"
|
||||
echo " - All dynamic link pages"
|
||||
echo " - All short URLs"
|
||||
echo " - All Flask sessions"
|
||||
echo ""
|
||||
|
||||
read -p "Are you sure you want to continue? Type 'YES' to confirm: " confirm
|
||||
|
||||
if [ "$confirm" != "YES" ]; then
|
||||
echo "❌ Cleanup cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🚀 Starting cleanup process..."
|
||||
echo ""
|
||||
|
||||
# Clean JSON data files
|
||||
echo "🗑️ Cleaning JSON data files..."
|
||||
if [ -d "data" ]; then
|
||||
echo '{}' > data/qr_codes.json 2>/dev/null && echo " ✅ Cleared qr_codes.json" || echo " ⚠️ qr_codes.json not found"
|
||||
echo '{}' > data/link_pages.json 2>/dev/null && echo " ✅ Cleared link_pages.json" || echo " ⚠️ link_pages.json not found"
|
||||
echo '{}' > data/short_urls.json 2>/dev/null && echo " ✅ Cleared short_urls.json" || echo " ⚠️ short_urls.json not found"
|
||||
else
|
||||
echo " ⚠️ Data directory not found"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Clean QR code images
|
||||
echo "🖼️ Cleaning QR code images..."
|
||||
if [ -d "app/static/qr_codes" ]; then
|
||||
COUNT=$(find app/static/qr_codes -name "*.png" | wc -l)
|
||||
find app/static/qr_codes -name "*.png" -delete
|
||||
echo " ✅ Deleted $COUNT QR code images"
|
||||
else
|
||||
echo " ⚠️ QR codes directory not found"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Clean Flask sessions
|
||||
echo "🔐 Cleaning Flask sessions..."
|
||||
if [ -d "flask_session" ]; then
|
||||
COUNT=$(find flask_session -type f | wc -l)
|
||||
find flask_session -type f -delete
|
||||
echo " ✅ Deleted $COUNT session files"
|
||||
else
|
||||
echo " ⚠️ Flask session directory not found"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Clean log files
|
||||
echo "📝 Cleaning log files..."
|
||||
LOG_COUNT=$(find . -maxdepth 1 -name "*.log*" | wc -l)
|
||||
if [ $LOG_COUNT -gt 0 ]; then
|
||||
find . -maxdepth 1 -name "*.log*" -delete
|
||||
echo " ✅ Deleted $LOG_COUNT log files"
|
||||
else
|
||||
echo " ✅ No log files found"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Clean Python cache
|
||||
echo "🐍 Cleaning Python cache..."
|
||||
CACHE_COUNT=$(find . -name "__pycache__" -o -name "*.pyc" | wc -l)
|
||||
find . -name "__pycache__" -exec rm -rf {} + 2>/dev/null
|
||||
find . -name "*.pyc" -delete 2>/dev/null
|
||||
echo " ✅ Cleaned $CACHE_COUNT cache files/directories"
|
||||
echo ""
|
||||
|
||||
# Create fresh directories
|
||||
echo "📁 Creating fresh directories..."
|
||||
mkdir -p data app/static/qr_codes app/static/logos flask_session
|
||||
echo " ✅ Ensured all directories exist"
|
||||
echo ""
|
||||
|
||||
echo "✅ Cleanup completed successfully!"
|
||||
echo ""
|
||||
echo "🎉 Your QR Code Manager is now ready for a fresh deployment!"
|
||||
echo " Next steps:"
|
||||
echo " 1. Start the application: python main.py"
|
||||
echo " 2. Login with: admin / admin123"
|
||||
echo " 3. Change the default password"
|
||||
13
data/link_pages.json
Executable file
13
data/link_pages.json
Executable file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"69247bcc-a1eb-439c-a5dd-fcfcf12cd7a1": {
|
||||
"id": "69247bcc-a1eb-439c-a5dd-fcfcf12cd7a1",
|
||||
"title": "Test Page",
|
||||
"description": "Test page for debugging",
|
||||
"links": [],
|
||||
"created_at": "2025-07-18T12:27:00.000Z",
|
||||
"updated_at": "2025-07-18T12:27:00.000Z",
|
||||
"view_count": 0,
|
||||
"short_url": null,
|
||||
"short_code": null
|
||||
}
|
||||
}
|
||||
1
data/qr_codes.json
Executable file
1
data/qr_codes.json
Executable file
@@ -0,0 +1 @@
|
||||
{}
|
||||
9
data/short_urls.json
Executable file
9
data/short_urls.json
Executable file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"W7BsLT": {
|
||||
"short_code": "W7BsLT",
|
||||
"original_url": "http://qr.moto-adv.com/links/69247bcc-a1eb-439c-a5dd-fcfcf12cd7a1",
|
||||
"title": "Test Page",
|
||||
"created_at": "2025-07-18T12:28:00.000Z",
|
||||
"click_count": 0
|
||||
}
|
||||
}
|
||||
72
docker-compose.yml
Normal file → Executable file
72
docker-compose.yml
Normal file → Executable file
@@ -1,39 +1,81 @@
|
||||
# Docker Compose configuration for QR Code Manager
|
||||
# This file defines the containerized deployment of the QR Code Manager application
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
qr-manager:
|
||||
# Build the Docker image from the Dockerfile in the current directory
|
||||
build: .
|
||||
|
||||
# Set a custom container name for easier identification and management
|
||||
container_name: qr-code-manager
|
||||
|
||||
# Automatically restart the container unless explicitly stopped
|
||||
# Options: no, always, unless-stopped, on-failure
|
||||
restart: unless-stopped
|
||||
|
||||
# Port mapping: host_port:container_port
|
||||
# Maps host port 8066 to container port 5000 (where Flask/Gunicorn runs)
|
||||
# Access the app at: http://localhost:8066 or https://your-domain (via reverse proxy)
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "8066:5000"
|
||||
|
||||
# Load environment variables from .env file
|
||||
# Contains production settings like SECRET_KEY, ADMIN_USERNAME, etc.
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# Additional environment variables set directly in compose
|
||||
# FLASK_ENV=production enables production mode with Gunicorn server
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- SECRET_KEY=${SECRET_KEY:-your-super-secret-key-change-me}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
|
||||
# Volume mounts: host_path:container_path
|
||||
# Persist data and files outside the container for backup and updates
|
||||
volumes:
|
||||
- qr_data:/app/app/static/qr_codes
|
||||
- logo_data:/app/app/static/logos
|
||||
- session_data:/app/flask_session
|
||||
# QR code images - stores generated PNG files
|
||||
- /opt/qr/qr_codes:/app/app/static/qr_codes
|
||||
|
||||
# Logo files - stores uploaded custom logos for QR codes
|
||||
- /opt/qr/logos:/app/app/static/logos
|
||||
|
||||
# Flask session files - stores user session data
|
||||
# Uses /app/flask_session to avoid permission issues (mapped from /tmp/flask_session in container)
|
||||
- /opt/qr/sessions:/app/flask_session
|
||||
|
||||
# Application data - stores JSON databases (link_pages.json, qr_codes.json, short_urls.json)
|
||||
- /opt/qr/persistent:/app/data
|
||||
|
||||
# Backup files - stores backup archives created by backup scripts
|
||||
- /opt/qr/backups:/app/backups
|
||||
|
||||
# Health check configuration - monitors container health
|
||||
# Ensures the application is responding correctly
|
||||
healthcheck:
|
||||
# Command to test application health - checks the /health endpoint
|
||||
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:5000/health')"]
|
||||
|
||||
# How often to run the health check
|
||||
interval: 30s
|
||||
|
||||
# Maximum time to wait for health check response
|
||||
timeout: 10s
|
||||
|
||||
# Number of consecutive failures before marking as unhealthy
|
||||
retries: 3
|
||||
|
||||
# Grace period before starting health checks (allows app startup time)
|
||||
start_period: 10s
|
||||
|
||||
# Docker labels for metadata and container management
|
||||
# Useful for monitoring, backup scripts, and documentation
|
||||
labels:
|
||||
- "com.example.description=QR Code Manager Application"
|
||||
- "com.example.service=qr-manager"
|
||||
|
||||
volumes:
|
||||
qr_data:
|
||||
driver: local
|
||||
logo_data:
|
||||
driver: local
|
||||
session_data:
|
||||
driver: local
|
||||
|
||||
# Network configuration
|
||||
# Creates a custom network for better container isolation and communication
|
||||
networks:
|
||||
default:
|
||||
# Custom network name for the QR Manager application
|
||||
name: qr-manager-network
|
||||
|
||||
407
docker_backup.sh
Executable file
407
docker_backup.sh
Executable file
@@ -0,0 +1,407 @@
|
||||
#!/bin/bash
|
||||
"""
|
||||
QR Code Manager - Docker Backup Script
|
||||
|
||||
This script provides backup and restore functionality for dockerized QR Code Manager.
|
||||
It handles both data volumes and complete application backups.
|
||||
|
||||
Usage:
|
||||
./docker_backup.sh [command] [options]
|
||||
|
||||
Commands:
|
||||
backup-data - Backup data volumes only
|
||||
backup-full - Full backup including application and data
|
||||
restore - Restore from backup
|
||||
list - List available backups
|
||||
cleanup - Remove old backups
|
||||
schedule - Set up automated backups
|
||||
|
||||
Examples:
|
||||
./docker_backup.sh backup-data
|
||||
./docker_backup.sh backup-full
|
||||
./docker_backup.sh restore backup_20240801_120000.tar.gz
|
||||
./docker_backup.sh list
|
||||
"""
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_DIR="${SCRIPT_DIR}/backups"
|
||||
CONTAINER_NAME="qr-code-manager"
|
||||
COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yml"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Helper functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# Ensure backup directory exists
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
# Check if Docker and docker-compose are available
|
||||
check_dependencies() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v docker &> /dev/null && docker compose version &> /dev/null 2>&1; then
|
||||
# Use docker compose (newer syntax)
|
||||
COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
# Fallback to docker-compose (older syntax)
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
log_error "Neither 'docker compose' nor 'docker-compose' is available"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Get timestamp for backup naming
|
||||
get_timestamp() {
|
||||
date +"%Y%m%d_%H%M%S"
|
||||
}
|
||||
|
||||
# Check if container is running
|
||||
is_container_running() {
|
||||
docker ps --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"
|
||||
}
|
||||
|
||||
# Backup data volumes only
|
||||
backup_data() {
|
||||
local timestamp=$(get_timestamp)
|
||||
local backup_name="qr_data_backup_${timestamp}.tar.gz"
|
||||
local backup_path="${BACKUP_DIR}/${backup_name}"
|
||||
|
||||
log_info "Creating data backup: ${backup_name}"
|
||||
|
||||
if is_container_running; then
|
||||
log_info "Container is running, creating hot backup..."
|
||||
|
||||
# Create backup from running container
|
||||
docker exec "${CONTAINER_NAME}" tar czf /tmp/data_backup.tar.gz \
|
||||
-C /app data/ || {
|
||||
log_error "Failed to create backup inside container"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Copy backup from container to host
|
||||
docker cp "${CONTAINER_NAME}:/tmp/data_backup.tar.gz" "${backup_path}" || {
|
||||
log_error "Failed to copy backup from container"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Cleanup temp file in container
|
||||
docker exec "${CONTAINER_NAME}" rm -f /tmp/data_backup.tar.gz
|
||||
|
||||
else
|
||||
log_info "Container is not running, creating backup from volumes..."
|
||||
|
||||
# Create backup using temporary container
|
||||
docker run --rm \
|
||||
-v "${SCRIPT_DIR}/data:/source/data:ro" \
|
||||
-v "${BACKUP_DIR}:/backup" \
|
||||
alpine:latest tar czf "/backup/${backup_name}" -C /source data/ || {
|
||||
log_error "Failed to create backup from volumes"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Add metadata
|
||||
local metadata_file="${BACKUP_DIR}/${backup_name%.tar.gz}.json"
|
||||
cat > "${metadata_file}" << EOF
|
||||
{
|
||||
"backup_type": "data",
|
||||
"timestamp": "$(date -Iseconds)",
|
||||
"container_name": "${CONTAINER_NAME}",
|
||||
"backup_method": "$(if is_container_running; then echo 'hot'; else echo 'cold'; fi)",
|
||||
"backup_size": "$(stat -f%z "${backup_path}" 2>/dev/null || stat -c%s "${backup_path}" 2>/dev/null || echo 'unknown')"
|
||||
}
|
||||
EOF
|
||||
|
||||
log_success "Data backup created: ${backup_path}"
|
||||
log_info "Backup size: $(du -h "${backup_path}" | cut -f1)"
|
||||
}
|
||||
|
||||
# Full backup including application
|
||||
backup_full() {
|
||||
local timestamp=$(get_timestamp)
|
||||
local backup_name="qr_full_backup_${timestamp}.tar.gz"
|
||||
local backup_path="${BACKUP_DIR}/${backup_name}"
|
||||
|
||||
log_info "Creating full backup: ${backup_name}"
|
||||
|
||||
# Stop container for consistent backup
|
||||
local was_running=false
|
||||
if is_container_running; then
|
||||
log_warning "Stopping container for consistent backup..."
|
||||
${COMPOSE_CMD} -f "${COMPOSE_FILE}" down
|
||||
was_running=true
|
||||
fi
|
||||
|
||||
# Create full backup
|
||||
tar czf "${backup_path}" \
|
||||
--exclude="backups" \
|
||||
--exclude=".git" \
|
||||
--exclude="__pycache__" \
|
||||
--exclude="*.pyc" \
|
||||
--exclude="*.log" \
|
||||
-C "${SCRIPT_DIR}/.." \
|
||||
"$(basename "${SCRIPT_DIR}")" || {
|
||||
log_error "Failed to create full backup"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Restart container if it was running
|
||||
if [ "$was_running" = true ]; then
|
||||
log_info "Restarting container..."
|
||||
${COMPOSE_CMD} -f "${COMPOSE_FILE}" up -d
|
||||
fi
|
||||
|
||||
# Add metadata
|
||||
local metadata_file="${BACKUP_DIR}/${backup_name%.tar.gz}.json"
|
||||
cat > "${metadata_file}" << EOF
|
||||
{
|
||||
"backup_type": "full",
|
||||
"timestamp": "$(date -Iseconds)",
|
||||
"container_name": "${CONTAINER_NAME}",
|
||||
"backup_method": "cold",
|
||||
"backup_size": "$(stat -f%z "${backup_path}" 2>/dev/null || stat -c%s "${backup_path}" 2>/dev/null || echo 'unknown')"
|
||||
}
|
||||
EOF
|
||||
|
||||
log_success "Full backup created: ${backup_path}"
|
||||
log_info "Backup size: $(du -h "${backup_path}" | cut -f1)"
|
||||
}
|
||||
|
||||
# List available backups
|
||||
list_backups() {
|
||||
log_info "Available backups:"
|
||||
echo "$(printf '%s' '─%.0s' {1..80})"
|
||||
|
||||
if [ ! "$(ls -A "${BACKUP_DIR}"/*.tar.gz 2>/dev/null)" ]; then
|
||||
echo "No backups found"
|
||||
return
|
||||
fi
|
||||
|
||||
for backup_file in "${BACKUP_DIR}"/*.tar.gz; do
|
||||
if [ -f "$backup_file" ]; then
|
||||
local basename=$(basename "$backup_file")
|
||||
local size=$(du -h "$backup_file" | cut -f1)
|
||||
local date=$(stat -f%Sm -t "%Y-%m-%d %H:%M" "$backup_file" 2>/dev/null || \
|
||||
stat -c%y "$backup_file" 2>/dev/null | cut -d' ' -f1-2)
|
||||
|
||||
# Determine backup type
|
||||
local type="Unknown"
|
||||
if [[ "$basename" == *"data"* ]]; then
|
||||
type="📄 Data"
|
||||
elif [[ "$basename" == *"full"* ]]; then
|
||||
type="📦 Full"
|
||||
fi
|
||||
|
||||
printf " %-12s | %-40s | %8s | %s\n" "$type" "$basename" "$size" "$date"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Restore from backup
|
||||
restore_backup() {
|
||||
local backup_file="$1"
|
||||
|
||||
if [ -z "$backup_file" ]; then
|
||||
log_error "Please specify backup file to restore"
|
||||
echo "Available backups:"
|
||||
list_backups
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if backup file exists
|
||||
local backup_path="${backup_file}"
|
||||
if [ ! -f "$backup_path" ]; then
|
||||
backup_path="${BACKUP_DIR}/${backup_file}"
|
||||
if [ ! -f "$backup_path" ]; then
|
||||
log_error "Backup file not found: $backup_file"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log_warning "Restoring from backup: $(basename "$backup_path")"
|
||||
|
||||
# Confirm restoration
|
||||
echo -n "This will overwrite current data. Continue? (y/N): "
|
||||
read -r confirm
|
||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||
log_info "Restoration cancelled"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Stop container
|
||||
if is_container_running; then
|
||||
log_info "Stopping container..."
|
||||
${COMPOSE_CMD} -f "${COMPOSE_FILE}" down
|
||||
fi
|
||||
|
||||
# Determine backup type and restore accordingly
|
||||
if [[ "$(basename "$backup_path")" == *"data"* ]]; then
|
||||
log_info "Restoring data backup..."
|
||||
|
||||
# Backup current data
|
||||
if [ -d "${SCRIPT_DIR}/data" ]; then
|
||||
mv "${SCRIPT_DIR}/data" "${SCRIPT_DIR}/data.backup.$(get_timestamp)"
|
||||
fi
|
||||
|
||||
# Extract data backup
|
||||
tar xzf "$backup_path" -C "${SCRIPT_DIR}" || {
|
||||
log_error "Failed to extract data backup"
|
||||
return 1
|
||||
}
|
||||
|
||||
elif [[ "$(basename "$backup_path")" == *"full"* ]]; then
|
||||
log_info "Restoring full backup..."
|
||||
|
||||
# Create restoration directory
|
||||
local restore_dir="${SCRIPT_DIR}/../qr-restore-$(get_timestamp)"
|
||||
mkdir -p "$restore_dir"
|
||||
|
||||
# Extract full backup
|
||||
tar xzf "$backup_path" -C "$restore_dir" || {
|
||||
log_error "Failed to extract full backup"
|
||||
return 1
|
||||
}
|
||||
|
||||
log_warning "Full backup extracted to: $restore_dir"
|
||||
log_warning "Please manually review and copy files as needed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Restart container
|
||||
log_info "Starting container..."
|
||||
${COMPOSE_CMD} -f "${COMPOSE_FILE}" up -d
|
||||
|
||||
log_success "Restoration completed successfully"
|
||||
}
|
||||
|
||||
# Cleanup old backups
|
||||
cleanup_backups() {
|
||||
local keep_count=${1:-10}
|
||||
|
||||
log_info "Cleaning up old backups (keeping $keep_count most recent)"
|
||||
|
||||
# Get list of backup files sorted by modification time (newest first)
|
||||
local backup_files=($(ls -t "${BACKUP_DIR}"/*.tar.gz 2>/dev/null || true))
|
||||
|
||||
if [ ${#backup_files[@]} -le $keep_count ]; then
|
||||
log_info "No cleanup needed (${#backup_files[@]} backups found)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Remove old backups
|
||||
for ((i=$keep_count; i<${#backup_files[@]}; i++)); do
|
||||
local backup_file="${backup_files[$i]}"
|
||||
local metadata_file="${backup_file%.tar.gz}.json"
|
||||
|
||||
log_info "Removing old backup: $(basename "$backup_file")"
|
||||
rm -f "$backup_file" "$metadata_file"
|
||||
done
|
||||
|
||||
log_success "Cleanup completed"
|
||||
}
|
||||
|
||||
# Set up automated backups
|
||||
schedule_backups() {
|
||||
log_info "Setting up automated backups with cron..."
|
||||
|
||||
# Create backup script for cron
|
||||
local cron_script="${SCRIPT_DIR}/automated_backup.sh"
|
||||
cat > "$cron_script" << 'EOF'
|
||||
#!/bin/bash
|
||||
# Automated backup script for QR Code Manager
|
||||
cd "$(dirname "$0")"
|
||||
./docker_backup.sh backup-data
|
||||
if [ $(date +%u) -eq 1 ]; then # Monday
|
||||
./docker_backup.sh backup-full
|
||||
fi
|
||||
./docker_backup.sh cleanup 15
|
||||
EOF
|
||||
chmod +x "$cron_script"
|
||||
|
||||
# Suggest cron entries
|
||||
echo
|
||||
log_info "Automated backup script created: $cron_script"
|
||||
echo
|
||||
echo "Add this to your crontab (crontab -e) for daily backups:"
|
||||
echo "# QR Code Manager daily backup at 2 AM"
|
||||
echo "0 2 * * * $cron_script >> ${SCRIPT_DIR}/backup.log 2>&1"
|
||||
echo
|
||||
echo "Or for hourly data backups:"
|
||||
echo "# QR Code Manager hourly data backup"
|
||||
echo "0 * * * * ${SCRIPT_DIR}/docker_backup.sh backup-data >> ${SCRIPT_DIR}/backup.log 2>&1"
|
||||
}
|
||||
|
||||
# Main script logic
|
||||
main() {
|
||||
check_dependencies
|
||||
|
||||
case "${1:-}" in
|
||||
"backup-data")
|
||||
backup_data
|
||||
;;
|
||||
"backup-full")
|
||||
backup_full
|
||||
;;
|
||||
"restore")
|
||||
restore_backup "$2"
|
||||
;;
|
||||
"list")
|
||||
list_backups
|
||||
;;
|
||||
"cleanup")
|
||||
cleanup_backups "$2"
|
||||
;;
|
||||
"schedule")
|
||||
schedule_backups
|
||||
;;
|
||||
*)
|
||||
echo "QR Code Manager - Docker Backup Script"
|
||||
echo
|
||||
echo "Usage: $0 [command] [options]"
|
||||
echo
|
||||
echo "Commands:"
|
||||
echo " backup-data - Backup data volumes only"
|
||||
echo " backup-full - Full backup including application and data"
|
||||
echo " restore <file> - Restore from backup"
|
||||
echo " list - List available backups"
|
||||
echo " cleanup [n] - Remove old backups (keep n most recent, default: 10)"
|
||||
echo " schedule - Set up automated backups"
|
||||
echo
|
||||
echo "Examples:"
|
||||
echo " $0 backup-data"
|
||||
echo " $0 backup-full"
|
||||
echo " $0 restore qr_data_backup_20240801_120000.tar.gz"
|
||||
echo " $0 list"
|
||||
echo " $0 cleanup 5"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Binary file not shown.
Binary file not shown.
38
gunicorn.conf.py
Executable file
38
gunicorn.conf.py
Executable file
@@ -0,0 +1,38 @@
|
||||
# Gunicorn configuration file
|
||||
# Documentation: https://docs.gunicorn.org/en/stable/configure.html
|
||||
|
||||
# Server socket
|
||||
bind = "0.0.0.0:5000"
|
||||
backlog = 2048
|
||||
|
||||
# Worker processes
|
||||
workers = 1 # Reduced to 1 to avoid file concurrency issues
|
||||
worker_class = "sync"
|
||||
worker_connections = 1000
|
||||
timeout = 60 # Increased timeout to handle potentially slow operations
|
||||
keepalive = 2
|
||||
|
||||
# Restart workers after this many requests, to prevent memory leaks
|
||||
max_requests = 1000
|
||||
max_requests_jitter = 50
|
||||
|
||||
# Logging
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
|
||||
|
||||
# Process naming
|
||||
proc_name = "qr-code-manager"
|
||||
|
||||
# Server mechanics
|
||||
preload_app = True
|
||||
pidfile = "/tmp/gunicorn.pid"
|
||||
# Comment out user/group for Docker deployment
|
||||
# user = "app"
|
||||
# group = "app"
|
||||
tmp_upload_dir = None
|
||||
|
||||
# SSL (uncomment and configure for HTTPS)
|
||||
# keyfile = "/path/to/keyfile"
|
||||
# certfile = "/path/to/certfile"
|
||||
19
main.py
Normal file → Executable file
19
main.py
Normal file → Executable file
@@ -6,28 +6,39 @@ A modern Flask web application for generating and managing QR codes with authent
|
||||
Features include:
|
||||
- Multiple QR code types (text, URL, WiFi, email, SMS, vCard)
|
||||
- Dynamic link pages for managing collections of links
|
||||
- URL shortener functionality with custom domains
|
||||
- Admin authentication with bcrypt password hashing
|
||||
- Docker deployment ready
|
||||
- Modern responsive web interface
|
||||
"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from app import create_app
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Create Flask application
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Production vs Development configuration
|
||||
is_production = os.environ.get('FLASK_ENV') == 'production'
|
||||
app_domain = os.environ.get('APP_DOMAIN', 'localhost:5000')
|
||||
|
||||
if is_production:
|
||||
print("🚀 Starting QR Code Manager in PRODUCTION mode")
|
||||
print("🔐 Admin user: admin")
|
||||
print("🔒 Make sure to change default credentials!")
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
print("🚀 QR Code Manager - Production Mode")
|
||||
print("ℹ️ This should be run with Gunicorn in production!")
|
||||
print("<EFBFBD> Use: gunicorn -c gunicorn.conf.py main:app")
|
||||
print(f"🌐 Domain configured: {app_domain}")
|
||||
print("🔗 URL shortener available at: /s/")
|
||||
# In production, this file is used by Gunicorn as WSGI application
|
||||
# The Flask dev server should NOT be started in production
|
||||
else:
|
||||
print("🛠️ Starting QR Code Manager in DEVELOPMENT mode")
|
||||
print("🔐 Admin user: admin")
|
||||
print("🔑 Default password: admin123")
|
||||
print(f"🌐 Domain configured: {app_domain}")
|
||||
print("🔗 URL shortener available at: /s/")
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
2
requirements.txt
Normal file → Executable file
2
requirements.txt
Normal file → Executable file
@@ -7,3 +7,5 @@ requests==2.31.0
|
||||
flask-session==0.5.0
|
||||
werkzeug==2.3.7
|
||||
bcrypt==4.0.1
|
||||
lxml==4.9.3
|
||||
gunicorn==21.2.0
|
||||
|
||||
Reference in New Issue
Block a user