Files
qr-code_manager/app/routes/api.py
ske087 9e4c21996b 🔄 Add Comprehensive Backup Management System
 New Features:
- Complete backup lifecycle management (create, list, download, delete, cleanup)
- Web-based backup interface with real-time status updates
- Individual backup deletion and bulk cleanup for old backups
- Docker-aware backup operations with volume persistence
- Automated backup scheduling and retention policies

📁 Added Files:
- backup.py - Core backup script for creating timestamped archives
- docker_backup.sh - Docker-compatible backup wrapper script
- app/templates/backup.html - Web interface for backup management
- BACKUP_SYSTEM.md - Comprehensive backup system documentation
- BACKUP_GUIDE.md - Quick reference guide for backup operations

🔧 Enhanced Files:
- Dockerfile - Added backup.py copy for container availability
- docker-compose.yml - Added backup volume mount for persistence
- app/routes/api.py - Added backup API endpoints (create, list, delete, cleanup)
- app/routes/main.py - Added backup management route
- app/templates/index.html - Added backup management navigation
- README.md - Updated with backup system overview and quick start

🎯 Key Improvements:
- Fixed backup creation errors in Docker environment
- Added Docker-aware path detection for container operations
- Implemented proper error handling and user confirmation dialogs
- Added real-time backup status updates via JavaScript
- Enhanced data persistence with volume mounting

💡 Use Cases:
- Data protection and disaster recovery
- Environment migration and cloning
- Development data management
- Automated maintenance workflows
2025-08-01 13:01:15 -04:00

786 lines
27 KiB
Python
Executable File

"""
API routes for QR Code Manager
"""
import os
import io
import base64
import uuid
from datetime import datetime
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
from app.utils.data_manager import QRDataManager
bp = Blueprint('api', __name__)
# Initialize managers
qr_generator = QRCodeGenerator()
link_manager = LinkPageManager()
data_manager = QRDataManager()
# 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)
@bp.route('/generate', methods=['POST'])
@login_required
def generate_qr():
"""Generate QR code API endpoint"""
try:
data = request.json
# Extract QR code content
qr_type = data.get('type', 'text')
content = data.get('content', '')
# Process content based on type
if qr_type == 'url':
qr_content = content if content.startswith(('http://', 'https://')) else f'https://{content}'
elif qr_type == 'wifi':
wifi_data = data.get('wifi', {})
qr_content = f"WIFI:T:{wifi_data.get('security', 'WPA')};S:{wifi_data.get('ssid', '')};P:{wifi_data.get('password', '')};H:{wifi_data.get('hidden', 'false')};;"
elif qr_type == 'email':
email_data = data.get('email', {})
qr_content = f"mailto:{email_data.get('email', '')}?subject={email_data.get('subject', '')}&body={email_data.get('body', '')}"
elif qr_type == 'phone':
qr_content = f"tel:{content}"
elif qr_type == 'sms':
sms_data = data.get('sms', {})
qr_content = f"smsto:{sms_data.get('phone', '')}:{sms_data.get('message', '')}"
elif qr_type == 'vcard':
vcard_data = data.get('vcard', {})
qr_content = f"""BEGIN:VCARD
VERSION:3.0
FN:{vcard_data.get('name', '')}
ORG:{vcard_data.get('organization', '')}
TEL:{vcard_data.get('phone', '')}
EMAIL:{vcard_data.get('email', '')}
URL:{vcard_data.get('website', '')}
END:VCARD"""
else: # text
qr_content = content
# Extract styling options
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')
}
# Generate QR code
qr_img = qr_generator.generate_qr_code(qr_content, settings)
# Add logo if provided
logo_path = data.get('logo_path')
if logo_path and os.path.exists(logo_path):
qr_img = qr_generator.add_logo(qr_img, logo_path)
# 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(qr_type, qr_content, 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,
'image_data': f'data:image/png;base64,{img_base64}',
'download_url': f'/api/download/{qr_id}'
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/download/<qr_id>')
@login_required
def download_qr(qr_id):
"""Download QR code in PNG format"""
try:
img_path = os.path.join(UPLOAD_FOLDER, f'{qr_id}.png')
if os.path.exists(img_path):
return send_file(img_path, as_attachment=True, download_name=f'qr_code_{qr_id}.png')
else:
return jsonify({'error': 'QR code not found'}), 404
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():
"""List all generated QR codes"""
return jsonify(data_manager.list_qr_codes())
@bp.route('/qr_codes/<qr_id>')
@login_required
def get_qr_code(qr_id):
"""Get specific QR code details"""
qr_data = data_manager.get_qr_record(qr_id)
if qr_data:
return jsonify(qr_data)
else:
return jsonify({'error': 'QR code not found'}), 404
@bp.route('/qr_codes/<qr_id>', methods=['DELETE'])
@login_required
def delete_qr_code(qr_id):
"""Delete QR code"""
try:
if data_manager.qr_exists(qr_id):
# Remove from database
data_manager.delete_qr_record(qr_id)
# Remove image file
img_path = os.path.join(UPLOAD_FOLDER, f'{qr_id}.png')
if os.path.exists(img_path):
os.remove(img_path)
return jsonify({'success': True})
else:
return jsonify({'error': 'QR code not found'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/upload_logo', methods=['POST'])
@login_required
def upload_logo():
"""Upload logo for QR code"""
try:
if 'logo' not in request.files:
return jsonify({'error': 'No logo file provided'}), 400
file = request.files['logo']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
# Save logo
logo_id = str(uuid.uuid4())
logo_extension = file.filename.rsplit('.', 1)[1].lower()
logo_filename = f'{logo_id}.{logo_extension}'
logo_path = os.path.join(LOGOS_FOLDER, logo_filename)
file.save(logo_path)
return jsonify({
'success': True,
'logo_path': logo_path,
'logo_id': logo_id
})
except Exception as e:
return jsonify({'error': str(e)}), 500
# Dynamic Link Pages API Routes
@bp.route('/create_link_page', methods=['POST'])
@login_required
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 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),
'border': data.get('border', 4),
'foreground_color': data.get('foreground_color', '#000000'),
'background_color': data.get('background_color', '#FFFFFF'),
'style': data.get('style', 'square')
}
# 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()
qr_img.save(img_buffer, format='PNG')
img_buffer.seek(0)
img_base64 = base64.b64encode(img_buffer.getvalue()).decode()
# 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')
qr_img.save(img_path)
return jsonify({
'success': True,
'qr_id': qr_id,
'page_id': page_id,
'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}'
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/link_pages/<page_id>/links', methods=['POST'])
@login_required
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
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'])
@login_required
def update_link_in_page(page_id, link_id):
"""Update a link in a page"""
try:
data = request.json
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,
enable_shortener=enable_shortener,
custom_short_code=custom_short_code
)
if success:
return jsonify({'success': True})
else:
return jsonify({'error': 'Page or link not found'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/link_pages/<page_id>/links/<link_id>', methods=['DELETE'])
@login_required
def delete_link_from_page(page_id, link_id):
"""Delete a link from a page"""
try:
success = link_manager.delete_link(page_id, link_id)
if success:
return jsonify({'success': True})
else:
return jsonify({'error': 'Page or link not found'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/link_pages/<page_id>')
@login_required
def get_link_page(page_id):
"""Get link page data"""
page_data = link_manager.get_page(page_id)
if page_data:
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