updated backups solution
This commit is contained in:
@@ -5,6 +5,9 @@ def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'your_secret_key'
|
||||
|
||||
# Set max upload size to 10GB for large database backups
|
||||
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 * 1024 # 10GB
|
||||
|
||||
# Application uses direct MariaDB connections via external_server.conf
|
||||
# No SQLAlchemy ORM needed - all database operations use raw SQL
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ class DatabaseBackupManager:
|
||||
backup_file = os.path.join(self.backup_path, filename)
|
||||
|
||||
# Build mysqldump command
|
||||
# Note: --skip-lock-tables and --force help with views that have permission issues
|
||||
cmd = [
|
||||
'mysqldump',
|
||||
f"--host={self.config['host']}",
|
||||
@@ -109,6 +110,8 @@ class DatabaseBackupManager:
|
||||
f"--user={self.config['user']}",
|
||||
f"--password={self.config['password']}",
|
||||
'--single-transaction',
|
||||
'--skip-lock-tables',
|
||||
'--force',
|
||||
'--routines',
|
||||
'--triggers',
|
||||
'--events',
|
||||
@@ -391,6 +394,181 @@ class DatabaseBackupManager:
|
||||
'message': f'Failed to save schedule: {str(e)}'
|
||||
}
|
||||
|
||||
def validate_backup_file(self, filename):
|
||||
"""
|
||||
Validate uploaded backup file for integrity and compatibility
|
||||
|
||||
Checks:
|
||||
- File exists and is readable
|
||||
- File contains valid SQL syntax
|
||||
- File contains expected database structure (users table, etc.)
|
||||
- File size is reasonable
|
||||
- No malicious commands (DROP statements outside of backup context)
|
||||
|
||||
Args:
|
||||
filename (str): Name of the backup file to validate
|
||||
|
||||
Returns:
|
||||
dict: Validation result with success status, message, and details
|
||||
"""
|
||||
try:
|
||||
# Security: ensure filename doesn't contain path traversal
|
||||
if '..' in filename or '/' in filename:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Invalid filename - potential security issue',
|
||||
'details': {}
|
||||
}
|
||||
|
||||
file_path = os.path.join(self.backup_path, filename)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Backup file not found',
|
||||
'details': {}
|
||||
}
|
||||
|
||||
# Check file size (warn if too small or too large)
|
||||
file_size = os.path.getsize(file_path)
|
||||
size_mb = round(file_size / (1024 * 1024), 2)
|
||||
|
||||
if file_size < 1024: # Less than 1KB is suspicious
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'File too small - may be empty or corrupted',
|
||||
'details': {'size_mb': size_mb}
|
||||
}
|
||||
|
||||
# For very large files (>2GB), skip detailed validation to avoid timeouts
|
||||
# Just do basic checks
|
||||
if file_size > 2 * 1024 * 1024 * 1024: # Over 2GB
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Large backup file accepted ({size_mb:.2f} MB) - detailed validation skipped for performance',
|
||||
'details': {
|
||||
'size_mb': size_mb,
|
||||
'validation_skipped': True,
|
||||
'reason': 'File too large for line-by-line validation'
|
||||
},
|
||||
'warnings': ['Detailed content validation skipped due to large file size']
|
||||
}
|
||||
|
||||
# Read and validate SQL content (only for files < 2GB)
|
||||
validation_details = {
|
||||
'size_mb': size_mb,
|
||||
'has_create_database': False,
|
||||
'has_users_table': False,
|
||||
'has_insert_statements': False,
|
||||
'suspicious_commands': [],
|
||||
'line_count': 0
|
||||
}
|
||||
|
||||
# For large files (100MB - 2GB), only read first 10MB for validation
|
||||
max_bytes_to_read = 10 * 1024 * 1024 if file_size > 100 * 1024 * 1024 else None
|
||||
bytes_read = 0
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content_preview = []
|
||||
line_count = 0
|
||||
|
||||
for line in f:
|
||||
line_count += 1
|
||||
bytes_read += len(line.encode('utf-8'))
|
||||
|
||||
# Stop reading after max_bytes for large files
|
||||
if max_bytes_to_read and bytes_read > max_bytes_to_read:
|
||||
validation_details['partial_validation'] = True
|
||||
validation_details['bytes_validated'] = f'{bytes_read / (1024*1024):.2f} MB'
|
||||
break
|
||||
|
||||
line_upper = line.strip().upper()
|
||||
|
||||
# Store first 10 non-comment lines for preview
|
||||
if len(content_preview) < 10 and line_upper and not line_upper.startswith('--') and not line_upper.startswith('/*'):
|
||||
content_preview.append(line.strip()[:100]) # First 100 chars
|
||||
|
||||
# Check for expected SQL commands
|
||||
if 'CREATE DATABASE' in line_upper or 'CREATE SCHEMA' in line_upper:
|
||||
validation_details['has_create_database'] = True
|
||||
|
||||
if 'CREATE TABLE' in line_upper and 'USERS' in line_upper:
|
||||
validation_details['has_users_table'] = True
|
||||
|
||||
if line_upper.startswith('INSERT INTO'):
|
||||
validation_details['has_insert_statements'] = True
|
||||
|
||||
# Check for potentially dangerous commands (outside of normal backup context)
|
||||
if 'DROP DATABASE' in line_upper and 'IF EXISTS' not in line_upper:
|
||||
validation_details['suspicious_commands'].append('Unconditional DROP DATABASE found')
|
||||
|
||||
if 'TRUNCATE TABLE' in line_upper:
|
||||
validation_details['suspicious_commands'].append('TRUNCATE TABLE found')
|
||||
|
||||
# Check for very long lines (potential binary data)
|
||||
if len(line) > 50000:
|
||||
validation_details['suspicious_commands'].append('Very long lines detected (possible binary data)')
|
||||
break
|
||||
|
||||
validation_details['line_count'] = line_count
|
||||
validation_details['preview'] = content_preview[:5] # First 5 lines
|
||||
|
||||
# Evaluate validation results
|
||||
issues = []
|
||||
warnings = []
|
||||
|
||||
if not validation_details['has_insert_statements']:
|
||||
warnings.append('No INSERT statements found - backup may be empty')
|
||||
|
||||
if not validation_details['has_users_table']:
|
||||
warnings.append('Users table not found - may not be compatible with this application')
|
||||
|
||||
if validation_details['suspicious_commands']:
|
||||
issues.extend(validation_details['suspicious_commands'])
|
||||
|
||||
if validation_details['line_count'] < 10:
|
||||
issues.append('Too few lines - file may be incomplete')
|
||||
|
||||
# Final validation decision
|
||||
if issues:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Validation failed: {"; ".join(issues)}',
|
||||
'details': validation_details,
|
||||
'warnings': warnings
|
||||
}
|
||||
|
||||
if warnings:
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Validation passed with warnings',
|
||||
'details': validation_details,
|
||||
'warnings': warnings
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Backup file validated successfully',
|
||||
'details': validation_details,
|
||||
'warnings': []
|
||||
}
|
||||
|
||||
except UnicodeDecodeError as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'File contains invalid characters - may be corrupted or not a text file',
|
||||
'details': {'error': str(e)}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error validating backup file: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Validation error: {str(e)}',
|
||||
'details': {}
|
||||
}
|
||||
|
||||
def cleanup_old_backups(self, retention_days=30):
|
||||
"""
|
||||
Delete backups older than retention_days
|
||||
|
||||
@@ -3711,4 +3711,106 @@ def api_backup_restore(filename):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'Restore failed: {str(e)}'
|
||||
}), 500
|
||||
}), 500
|
||||
@bp.route('/api/backup/upload', methods=['POST'])
|
||||
@superadmin_only
|
||||
def api_backup_upload():
|
||||
"""Upload an external backup file (superadmin only)"""
|
||||
try:
|
||||
from app.database_backup import DatabaseBackupManager
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Check if file was uploaded
|
||||
if 'backup_file' not in request.files:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'No file uploaded'
|
||||
}), 400
|
||||
|
||||
file = request.files['backup_file']
|
||||
|
||||
# Check if file was selected
|
||||
if file.filename == '':
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'No file selected'
|
||||
}), 400
|
||||
|
||||
# Validate file extension
|
||||
if not file.filename.lower().endswith('.sql'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Invalid file format. Only .sql files are allowed.'
|
||||
}), 400
|
||||
|
||||
# Get backup manager and backup path
|
||||
backup_manager = DatabaseBackupManager()
|
||||
backup_path = backup_manager.backup_path
|
||||
|
||||
# Ensure backup_path is a Path object
|
||||
from pathlib import Path
|
||||
if not isinstance(backup_path, Path):
|
||||
backup_path = Path(backup_path)
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
backup_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate secure filename with timestamp to avoid conflicts
|
||||
original_filename = secure_filename(file.filename)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
# If filename already starts with "backup_", keep it; otherwise add prefix
|
||||
if original_filename.startswith('backup_'):
|
||||
new_filename = f"{original_filename.rsplit('.', 1)[0]}_{timestamp}.sql"
|
||||
else:
|
||||
new_filename = f"backup_uploaded_{timestamp}_{original_filename}"
|
||||
|
||||
# Save file to backup directory
|
||||
file_path = backup_path / new_filename
|
||||
file.save(str(file_path))
|
||||
|
||||
# Get file size
|
||||
file_size = file_path.stat().st_size
|
||||
size_mb = round(file_size / (1024 * 1024), 2)
|
||||
|
||||
# Validate the uploaded file for integrity and compatibility
|
||||
validation_result = backup_manager.validate_backup_file(new_filename)
|
||||
|
||||
if not validation_result['success']:
|
||||
# Validation failed - remove the uploaded file
|
||||
file_path.unlink() # Delete the invalid file
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'Validation failed: {validation_result["message"]}',
|
||||
'validation_details': validation_result.get('details', {}),
|
||||
'warnings': validation_result.get('warnings', [])
|
||||
}), 400
|
||||
|
||||
# Build response with validation details
|
||||
response = {
|
||||
'success': True,
|
||||
'message': 'Backup file uploaded and validated successfully',
|
||||
'filename': new_filename,
|
||||
'size': f'{size_mb} MB',
|
||||
'path': str(file_path),
|
||||
'validation': {
|
||||
'status': 'passed',
|
||||
'message': validation_result['message'],
|
||||
'details': validation_result.get('details', {}),
|
||||
'warnings': validation_result.get('warnings', [])
|
||||
}
|
||||
}
|
||||
|
||||
# Add warning flag if there are warnings
|
||||
if validation_result.get('warnings'):
|
||||
response['message'] = f'Backup uploaded with warnings: {"; ".join(validation_result["warnings"])}'
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'Upload failed: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@@ -217,7 +217,7 @@ def settings_handler():
|
||||
key, value = line.strip().split('=', 1)
|
||||
external_settings[key] = value
|
||||
|
||||
return render_template('settings.html', users=users, external_settings=external_settings)
|
||||
return render_template('settings.html', users=users, external_settings=external_settings, current_user={'role': session.get('role', '')})
|
||||
|
||||
# Helper function to get external database connection
|
||||
def get_external_db_connection():
|
||||
|
||||
@@ -22,16 +22,16 @@
|
||||
<div class="card">
|
||||
<h3>External Server Settings</h3>
|
||||
<form method="POST" action="{{ url_for('main.save_external_db') }}" class="form-centered">
|
||||
<label for="server_domain">Server Domain/IP Address:</label>
|
||||
<input type="text" id="server_domain" name="server_domain" value="{{ external_settings.get('server_domain', '') }}" required>
|
||||
<label for="port">Port:</label>
|
||||
<input type="number" id="port" name="port" value="{{ external_settings.get('port', '') }}" required>
|
||||
<label for="database_name">Database Name:</label>
|
||||
<input type="text" id="database_name" name="database_name" value="{{ external_settings.get('database_name', '') }}" required>
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" id="username" name="username" value="{{ external_settings.get('username', '') }}" required>
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password" value="{{ external_settings.get('password', '') }}" required>
|
||||
<label for="db_server_domain">Server Domain/IP Address:</label>
|
||||
<input type="text" id="db_server_domain" name="server_domain" value="{{ external_settings.get('server_domain', '') }}" required>
|
||||
<label for="db_port">Port:</label>
|
||||
<input type="number" id="db_port" name="port" value="{{ external_settings.get('port', '') }}" required>
|
||||
<label for="db_database_name">Database Name:</label>
|
||||
<input type="text" id="db_database_name" name="database_name" value="{{ external_settings.get('database_name', '') }}" required>
|
||||
<label for="db_username">Username:</label>
|
||||
<input type="text" id="db_username" name="username" value="{{ external_settings.get('username', '') }}" required>
|
||||
<label for="db_password">Password:</label>
|
||||
<input type="password" id="db_password" name="password" value="{{ external_settings.get('password', '') }}" required>
|
||||
<button type="submit" class="btn">Save/Update External Database Info Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -52,12 +52,12 @@
|
||||
</div>
|
||||
|
||||
{% if session.role in ['superadmin', 'admin'] %}
|
||||
<div class="card" style="margin-top: 32px;">
|
||||
<div class="card backup-card" style="margin-top: 32px;">
|
||||
<h3>💾 Database Backup Management</h3>
|
||||
<p><strong>Automated Backup System:</strong> Schedule and manage database backups</p>
|
||||
|
||||
<!-- Backup Controls -->
|
||||
<div style="margin-top: 20px; padding: 15px; background: #f5f5f5; border-radius: 8px;">
|
||||
<div class="backup-controls">
|
||||
<h4 style="margin-top: 0;">Quick Actions</h4>
|
||||
<button id="backup-now-btn" class="btn" style="background-color: #4caf50; color: white; margin-right: 10px;">
|
||||
⚡ Backup Now
|
||||
@@ -68,9 +68,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Schedule Configuration -->
|
||||
<div style="margin-top: 20px; padding: 15px; background: #f9f9f9; border-radius: 8px;">
|
||||
<div class="backup-schedule">
|
||||
<h4 style="margin-top: 0;">Backup Schedule</h4>
|
||||
<form id="backup-schedule-form" style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||
<form id="backup-schedule-form" class="schedule-form">
|
||||
<div>
|
||||
<label for="schedule-enabled">
|
||||
<input type="checkbox" id="schedule-enabled" name="enabled"> Enable Scheduled Backups
|
||||
@@ -103,18 +103,246 @@
|
||||
<!-- Backup List -->
|
||||
<div style="margin-top: 20px;">
|
||||
<h4>Available Backups</h4>
|
||||
<div id="backup-list" style="max-height: 400px; overflow-y: auto;">
|
||||
<div id="backup-list" class="backup-list-container">
|
||||
<p style="text-align: center; color: #999;">Loading backups...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Path Info -->
|
||||
<div style="margin-top: 15px; padding: 10px; background: #e3f2fd; border-left: 4px solid #2196f3; border-radius: 4px;">
|
||||
<div class="backup-info">
|
||||
<strong>ℹ️ Backup Location:</strong> <code id="backup-path-display">/srv/quality_app/backups</code>
|
||||
<br>
|
||||
<small>Configure backup path in docker-compose.yml (BACKUP_PATH environment variable)</small>
|
||||
</div>
|
||||
|
||||
<!-- Restore Database Section (Superadmin Only) -->
|
||||
{% if current_user.role == 'superadmin' %}
|
||||
<div class="restore-section" style="margin-top: 30px; padding: 20px; border: 2px solid #ff9800; border-radius: 8px; background: #fff3e0;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #e65100;">⚠️ Restore Database</h4>
|
||||
<p style="margin: 0 0 15px 0; color: #e65100; font-weight: bold;">
|
||||
WARNING: Restoring will permanently replace ALL current data with the backup data. This action cannot be undone!
|
||||
</p>
|
||||
|
||||
<!-- Upload External Backup File -->
|
||||
<div style="margin-bottom: 20px; padding: 15px; background: #e3f2fd; border: 1px solid #2196f3; border-radius: 4px;">
|
||||
<h5 style="margin: 0 0 10px 0; color: #1976d2;">📤 Upload External Backup File</h5>
|
||||
<p style="margin: 0 0 10px 0; font-size: 0.9em; color: #555;">
|
||||
Upload a backup file from another server or external source. File will be saved to the backups directory.
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<input type="file" id="backup-file-upload" accept=".sql" style="flex: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
<button id="upload-backup-btn" class="btn" style="background: #2196f3; color: white; padding: 10px 20px; white-space: nowrap;">
|
||||
⬆️ Upload File
|
||||
</button>
|
||||
</div>
|
||||
<small style="color: #666; display: block; margin-top: 5px;">
|
||||
Accepted format: .sql files only | Max size: 100MB
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Select Backup to Restore -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label for="restore-backup-select" style="display: block; margin-bottom: 5px; font-weight: bold;">Select Backup to Restore:</label>
|
||||
<select id="restore-backup-select" style="width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
<option value="">-- Select a backup file --</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="restore-btn" class="btn" style="background: #ff5722; color: white; padding: 10px 20px; width: 100%; font-weight: bold;" disabled>
|
||||
🔄 Restore Database from Selected Backup
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Backup Card Styles */
|
||||
.backup-card {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.backup-controls {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.backup-schedule {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.schedule-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.schedule-form label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.schedule-form input[type="time"],
|
||||
.schedule-form input[type="number"],
|
||||
.schedule-form select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.backup-list-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: #fafafa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.backup-list-container table {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.backup-list-container th {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.backup-list-container tr {
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.backup-info {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
border-radius: 4px;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.backup-info code {
|
||||
background: #bbdefb;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #01579b;
|
||||
}
|
||||
|
||||
.backup-info small {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
/* Dark Mode Styles */
|
||||
body.dark-mode .backup-card {
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-controls {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-schedule {
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
body.dark-mode .schedule-form input[type="time"],
|
||||
body.dark-mode .schedule-form input[type="number"],
|
||||
body.dark-mode .schedule-form select {
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
body.dark-mode .schedule-form label {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-list-container {
|
||||
background: #2d2d2d;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-list-container table {
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-list-container th {
|
||||
background: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-list-container tr {
|
||||
border-bottom-color: #555;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-list-container td {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-info {
|
||||
background: #1e3a5f;
|
||||
border-left-color: #2196f3;
|
||||
color: #90caf9;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-info code {
|
||||
background: #2d4a6d;
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
body.dark-mode .backup-info small {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
/* Dark mode for restore section */
|
||||
body.dark-mode .restore-section {
|
||||
background: #3a2a1f !important;
|
||||
border-color: #ff9800 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .restore-section h4,
|
||||
body.dark-mode .restore-section p {
|
||||
color: #ffb74d !important;
|
||||
}
|
||||
|
||||
body.dark-mode .restore-section label {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .restore-section select {
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* Dark mode for upload section */
|
||||
body.dark-mode .restore-section div[style*="background: #e3f2fd"] {
|
||||
background: #1a3a52 !important;
|
||||
border-color: #2196f3 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .restore-section h5 {
|
||||
color: #64b5f6 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .restore-section input[type="file"] {
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
border-color: #555;
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -124,14 +352,14 @@
|
||||
<h3 id="user-popup-title">Create/Edit User</h3>
|
||||
<form id="user-form" method="POST" action="{{ url_for('main.create_user') }}">
|
||||
<input type="hidden" id="user-id" name="user_id">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
<label for="email">Email (Optional):</label>
|
||||
<input type="email" id="email" name="email">
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<label for="role">Role:</label>
|
||||
<select id="role" name="role" required>
|
||||
<label for="user_username">Username:</label>
|
||||
<input type="text" id="user_username" name="username" required>
|
||||
<label for="user_email">Email (Optional):</label>
|
||||
<input type="email" id="user_email" name="email">
|
||||
<label for="user_password">Password:</label>
|
||||
<input type="password" id="user_password" name="password" required>
|
||||
<label for="user_role">Role:</label>
|
||||
<select id="user_role" name="role" required>
|
||||
<option value="superadmin">Superadmin</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="manager">Manager</option>
|
||||
@@ -164,9 +392,9 @@ document.getElementById('create-user-btn').onclick = function() {
|
||||
document.getElementById('user-form').reset();
|
||||
document.getElementById('user-form').setAttribute('action', '{{ url_for("main.create_user") }}');
|
||||
document.getElementById('user-id').value = '';
|
||||
document.getElementById('password').required = true;
|
||||
document.getElementById('password').placeholder = '';
|
||||
document.getElementById('username').readOnly = false;
|
||||
document.getElementById('user_password').required = true;
|
||||
document.getElementById('user_password').placeholder = '';
|
||||
document.getElementById('user_username').readOnly = false;
|
||||
};
|
||||
|
||||
document.getElementById('close-user-popup-btn').onclick = function() {
|
||||
@@ -179,13 +407,13 @@ Array.from(document.getElementsByClassName('edit-user-btn')).forEach(function(bt
|
||||
document.getElementById('user-popup').style.display = 'flex';
|
||||
document.getElementById('user-popup-title').innerText = 'Edit User';
|
||||
document.getElementById('user-id').value = btn.getAttribute('data-user-id');
|
||||
document.getElementById('username').value = btn.getAttribute('data-username');
|
||||
document.getElementById('email').value = btn.getAttribute('data-email') || '';
|
||||
document.getElementById('role').value = btn.getAttribute('data-role');
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('password').required = false;
|
||||
document.getElementById('password').placeholder = 'Leave blank to keep current password';
|
||||
document.getElementById('username').readOnly = true;
|
||||
document.getElementById('user_username').value = btn.getAttribute('data-username');
|
||||
document.getElementById('user_email').value = btn.getAttribute('data-email') || '';
|
||||
document.getElementById('user_role').value = btn.getAttribute('data-role');
|
||||
document.getElementById('user_password').value = '';
|
||||
document.getElementById('user_password').required = false;
|
||||
document.getElementById('user_password').placeholder = 'Leave blank to keep current password';
|
||||
document.getElementById('user_username').readOnly = true;
|
||||
document.getElementById('user-form').setAttribute('action', '{{ url_for("main.edit_user") }}');
|
||||
};
|
||||
});
|
||||
@@ -251,8 +479,22 @@ function loadBackupList() {
|
||||
|
||||
html += '</tbody></table>';
|
||||
backupList.innerHTML = html;
|
||||
|
||||
// Populate restore dropdown
|
||||
const restoreSelect = document.getElementById('restore-backup-select');
|
||||
if (restoreSelect) {
|
||||
restoreSelect.innerHTML = '<option value="">-- Select a backup file --</option>';
|
||||
data.backups.forEach(backup => {
|
||||
restoreSelect.innerHTML += `<option value="${backup.filename}">${backup.filename} (${backup.size_mb} MB - ${backup.created})</option>`;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
backupList.innerHTML = '<p style="text-align: center; color: #999;">No backups available</p>';
|
||||
// Clear restore dropdown
|
||||
const restoreSelect = document.getElementById('restore-backup-select');
|
||||
if (restoreSelect) {
|
||||
restoreSelect.innerHTML = '<option value="">-- No backups available --</option>';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -353,6 +595,197 @@ function deleteBackup(filename) {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore dropdown change - enable/disable button
|
||||
document.getElementById('restore-backup-select')?.addEventListener('change', function() {
|
||||
const restoreBtn = document.getElementById('restore-btn');
|
||||
if (this.value) {
|
||||
restoreBtn.disabled = false;
|
||||
} else {
|
||||
restoreBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Restore backup function
|
||||
document.getElementById('restore-btn')?.addEventListener('click', function() {
|
||||
const filename = document.getElementById('restore-backup-select').value;
|
||||
|
||||
if (!filename) {
|
||||
alert('❌ Please select a backup file to restore');
|
||||
return;
|
||||
}
|
||||
|
||||
// First confirmation
|
||||
const firstConfirm = confirm(
|
||||
`⚠️ CRITICAL WARNING ⚠️\n\n` +
|
||||
`You are about to RESTORE the database from:\n${filename}\n\n` +
|
||||
`This will PERMANENTLY DELETE all current data and replace it with the backup data.\n\n` +
|
||||
`This action CANNOT be undone!\n\n` +
|
||||
`Do you want to continue?`
|
||||
);
|
||||
|
||||
if (!firstConfirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Second confirmation (require typing confirmation)
|
||||
const secondConfirm = prompt(
|
||||
`⚠️ FINAL CONFIRMATION ⚠️\n\n` +
|
||||
`Type "RESTORE" in capital letters to confirm you understand:\n` +
|
||||
`• All current database data will be PERMANENTLY DELETED\n` +
|
||||
`• This action is IRREVERSIBLE\n` +
|
||||
`• Users may experience downtime during restore\n\n` +
|
||||
`Type RESTORE to continue:`
|
||||
);
|
||||
|
||||
if (secondConfirm !== 'RESTORE') {
|
||||
alert('❌ Restore cancelled - confirmation text did not match');
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform restore
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '⏳ Restoring database... Please wait...';
|
||||
|
||||
fetch(`/api/backup/restore/${filename}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(
|
||||
`✅ DATABASE RESTORE COMPLETE!\n\n` +
|
||||
`${data.message}\n\n` +
|
||||
`The application will now reload to apply changes.`
|
||||
);
|
||||
// Reload the page to ensure all data is fresh
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(`❌ RESTORE FAILED\n\n${data.message}`);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '🔄 Restore Database from Selected Backup';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error restoring backup:', error);
|
||||
alert(`❌ RESTORE FAILED\n\nAn error occurred while restoring the database.`);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '🔄 Restore Database from Selected Backup';
|
||||
});
|
||||
});
|
||||
|
||||
// Upload backup file
|
||||
document.getElementById('upload-backup-btn')?.addEventListener('click', function() {
|
||||
const fileInput = document.getElementById('backup-file-upload');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
alert('❌ Please select a file to upload');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
if (!file.name.toLowerCase().endsWith('.sql')) {
|
||||
alert('❌ Invalid file format. Only .sql files are allowed.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (10GB max for large databases)
|
||||
const maxSize = 10 * 1024 * 1024 * 1024; // 10GB in bytes
|
||||
if (file.size > maxSize) {
|
||||
alert('❌ File is too large. Maximum size is 10GB.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn about large files
|
||||
const warningSize = 1 * 1024 * 1024 * 1024; // 1GB
|
||||
if (file.size > warningSize) {
|
||||
const sizeGB = (file.size / (1024 * 1024 * 1024)).toFixed(2);
|
||||
if (!confirm(`⚠️ Large File Warning\n\nYou are uploading a ${sizeGB} GB file.\nThis may take several minutes.\n\nDo you want to continue?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare form data
|
||||
const formData = new FormData();
|
||||
formData.append('backup_file', file);
|
||||
|
||||
// Disable button and show loading
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '⏳ Uploading and validating...';
|
||||
|
||||
// Upload file
|
||||
fetch('/api/backup/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Build detailed success message with validation info
|
||||
let message = `✅ File uploaded and validated successfully!\n\n`;
|
||||
message += `Filename: ${data.filename}\n`;
|
||||
message += `Size: ${data.size}\n`;
|
||||
|
||||
// Add validation details if available
|
||||
if (data.validation && data.validation.details) {
|
||||
const details = data.validation.details;
|
||||
message += `\n📊 Validation Results:\n`;
|
||||
message += `• Lines: ${details.line_count || 'N/A'}\n`;
|
||||
message += `• Has Users Table: ${details.has_users_table ? '✓' : '✗'}\n`;
|
||||
message += `• Has Data: ${details.has_insert_statements ? '✓' : '✗'}\n`;
|
||||
}
|
||||
|
||||
// Add warnings if any
|
||||
if (data.validation && data.validation.warnings && data.validation.warnings.length > 0) {
|
||||
message += `\n⚠️ Warnings:\n`;
|
||||
data.validation.warnings.forEach(warning => {
|
||||
message += `• ${warning}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
message += `\nThe file is now available in the restore dropdown.`;
|
||||
|
||||
alert(message);
|
||||
|
||||
// Clear file input
|
||||
fileInput.value = '';
|
||||
// Reload backup list to show the new file
|
||||
loadBackupList();
|
||||
} else {
|
||||
// Build detailed error message
|
||||
let message = `❌ Upload failed\n\n${data.message}`;
|
||||
|
||||
// Add validation details if available
|
||||
if (data.validation_details) {
|
||||
message += `\n\n📊 Validation Details:\n`;
|
||||
const details = data.validation_details;
|
||||
if (details.size_mb) message += `• File Size: ${details.size_mb} MB\n`;
|
||||
if (details.line_count) message += `• Lines: ${details.line_count}\n`;
|
||||
}
|
||||
|
||||
// Add warnings if any
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
message += `\n⚠️ Issues Found:\n`;
|
||||
data.warnings.forEach(warning => {
|
||||
message += `• ${warning}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
alert(message);
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '⬆️ Upload File';
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error uploading backup:', error);
|
||||
alert('❌ Failed to upload file');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '⬆️ Upload File';
|
||||
});
|
||||
});
|
||||
|
||||
// Load backup data on page load
|
||||
if (document.getElementById('backup-list')) {
|
||||
loadBackupSchedule();
|
||||
|
||||
@@ -26,8 +26,9 @@ worker_class = os.getenv("GUNICORN_WORKER_CLASS", "sync")
|
||||
worker_connections = int(os.getenv("GUNICORN_WORKER_CONNECTIONS", "1000"))
|
||||
|
||||
# Workers silent for more than this many seconds are killed and restarted
|
||||
# Increase for long-running requests (file uploads, reports)
|
||||
timeout = int(os.getenv("GUNICORN_TIMEOUT", "120"))
|
||||
# Increase for long-running requests (file uploads, reports, large backups)
|
||||
# For 5GB+ database operations, allow up to 30 minutes
|
||||
timeout = int(os.getenv("GUNICORN_TIMEOUT", "1800")) # 30 minutes
|
||||
|
||||
# Keep-alive for reusing connections
|
||||
keepalive = int(os.getenv("GUNICORN_KEEPALIVE", "5"))
|
||||
|
||||
@@ -35,10 +35,19 @@ echo "=============================================="
|
||||
# Check if we're in the right directory
|
||||
if [[ ! -f "wsgi.py" ]]; then
|
||||
print_error "Please run this script from the py_app directory"
|
||||
print_error "Expected location: /srv/quality_recticel/py_app"
|
||||
print_error "Expected location: /srv/quality_app/py_app or /srv/quality_recticel/py_app"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect which installation we're running from
|
||||
if [[ "$PWD" == *"/quality_app/"* ]]; then
|
||||
LOG_DIR="/srv/quality_app/logs"
|
||||
PROJECT_NAME="quality_app"
|
||||
else
|
||||
LOG_DIR="/srv/quality_recticel/logs"
|
||||
PROJECT_NAME="quality_recticel"
|
||||
fi
|
||||
|
||||
print_step "Checking Prerequisites"
|
||||
|
||||
# Check if virtual environment exists
|
||||
@@ -134,8 +143,9 @@ if [[ -f "$PID_FILE" ]]; then
|
||||
echo "📋 Server Information:"
|
||||
echo " • Process ID: $PID"
|
||||
echo " • Configuration: gunicorn.conf.py"
|
||||
echo " • Access Log: /srv/quality_recticel/logs/access.log"
|
||||
echo " • Error Log: /srv/quality_recticel/logs/error.log"
|
||||
echo " • Project: $PROJECT_NAME"
|
||||
echo " • Access Log: $LOG_DIR/access.log"
|
||||
echo " • Error Log: $LOG_DIR/error.log"
|
||||
echo ""
|
||||
echo "🌐 Application URLs:"
|
||||
echo " • Local: http://127.0.0.1:8781"
|
||||
@@ -147,14 +157,14 @@ if [[ -f "$PID_FILE" ]]; then
|
||||
echo ""
|
||||
echo "🔧 Management Commands:"
|
||||
echo " • Stop server: kill $PID && rm $PID_FILE"
|
||||
echo " • View logs: tail -f /srv/quality_recticel/logs/error.log"
|
||||
echo " • Monitor access: tail -f /srv/quality_recticel/logs/access.log"
|
||||
echo " • View logs: tail -f $LOG_DIR/error.log"
|
||||
echo " • Monitor access: tail -f $LOG_DIR/access.log"
|
||||
echo " • Server status: ps -p $PID"
|
||||
echo ""
|
||||
print_warning "Server is running in daemon mode (background)"
|
||||
else
|
||||
print_error "Failed to start application. Check logs:"
|
||||
print_error "tail /srv/quality_recticel/logs/error.log"
|
||||
print_error "tail $LOG_DIR/error.log"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
|
||||
@@ -27,6 +27,15 @@ echo "=============================================="
|
||||
|
||||
PID_FILE="../run/trasabilitate.pid"
|
||||
|
||||
# Detect which installation we're running from
|
||||
if [[ "$PWD" == *"/quality_app/"* ]]; then
|
||||
LOG_DIR="/srv/quality_app/logs"
|
||||
PROJECT_NAME="quality_app"
|
||||
else
|
||||
LOG_DIR="/srv/quality_recticel/logs"
|
||||
PROJECT_NAME="quality_recticel"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$PID_FILE" ]]; then
|
||||
print_error "Application is not running (no PID file found)"
|
||||
echo "To start the application, run: ./start_production.sh"
|
||||
@@ -44,19 +53,20 @@ if ps -p "$PID" > /dev/null 2>&1; then
|
||||
done
|
||||
echo ""
|
||||
echo "🌐 Server Information:"
|
||||
echo " • Project: $PROJECT_NAME"
|
||||
echo " • Listening on: 0.0.0.0:8781"
|
||||
echo " • Local URL: http://127.0.0.1:8781"
|
||||
echo " • Network URL: http://$(hostname -I | awk '{print $1}'):8781"
|
||||
echo ""
|
||||
echo "📁 Log Files:"
|
||||
echo " • Access Log: /srv/quality_recticel/logs/access.log"
|
||||
echo " • Error Log: /srv/quality_recticel/logs/error.log"
|
||||
echo " • Access Log: $LOG_DIR/access.log"
|
||||
echo " • Error Log: $LOG_DIR/error.log"
|
||||
echo ""
|
||||
echo "🔧 Quick Commands:"
|
||||
echo " • Stop server: ./stop_production.sh"
|
||||
echo " • Restart server: ./stop_production.sh && ./start_production.sh"
|
||||
echo " • View error log: tail -f /srv/quality_recticel/logs/error.log"
|
||||
echo " • View access log: tail -f /srv/quality_recticel/logs/access.log"
|
||||
echo " • View error log: tail -f $LOG_DIR/error.log"
|
||||
echo " • View access log: tail -f $LOG_DIR/access.log"
|
||||
echo ""
|
||||
|
||||
# Check if the web server is responding
|
||||
|
||||
Reference in New Issue
Block a user