✨ 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
786 lines
27 KiB
Python
Executable File
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
|