mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-28 03:39:32 +00:00
Backup and Restore of Mail DB and Data
This commit is contained in:
parent
2ed140d45b
commit
9738a30201
8 changed files with 273 additions and 1 deletions
|
|
@ -774,5 +774,16 @@
|
|||
"email.teardown": "Teardown",
|
||||
"email.no_domains": "No domains configured.",
|
||||
"email.choose_domain": "Choose a domain...",
|
||||
"email.select_zone": "Select Cloudflare Zone"
|
||||
"email.select_zone": "Select Cloudflare Zone",
|
||||
"email.backup_restore": "System Backup & Restore",
|
||||
"email.backup_title": "Create System Backup",
|
||||
"email.backup_description": "Download a full backup of your email database and attachments (.zip).",
|
||||
"email.backup_security_warning": "Security Warning: This backup contains highly sensitive data (emails and attachments) in plain text. Please store it securely.",
|
||||
"email.download_backup": "Download Backup",
|
||||
"email.restore_title": "Restore from Backup",
|
||||
"email.restore_description": "Upload a backup archive to restore your email database and attachments.",
|
||||
"email.restore_warning": "Warning: Restoring a backup will permanently overwrite your current email data and attachments. This action cannot be undone.",
|
||||
"email.restore_backup": "Restore Backup",
|
||||
"email.restore_confirm": "Are you absolutely sure you want to restore this backup? All current email data will be overwritten.",
|
||||
"email.restore_success": "Restore successful. Restarting mail system..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2109,3 +2109,49 @@ async function emailRedeployWorkers() {
|
|||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailRestoreBackup() {
|
||||
const fileInput = document.getElementById('emailRestoreFile');
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!await dfConfirm(t('email.restore_confirm'), t('email.restore_title'))) {
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('emailRestoreBtn');
|
||||
const feedback = document.getElementById('emailRestoreFeedback');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> ' + t('common.loading');
|
||||
feedback.classList.remove('hidden', 'text-error', 'text-success');
|
||||
feedback.textContent = 'Uploading and restoring...';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/email/restore', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders(),
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
feedback.classList.add('text-success');
|
||||
feedback.textContent = t('email.restore_success');
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 3000);
|
||||
} else {
|
||||
feedback.classList.add('text-error');
|
||||
feedback.textContent = 'Error: ' + (data.error || 'Unknown error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = t('email.restore_backup');
|
||||
}
|
||||
} catch (e) {
|
||||
feedback.classList.add('text-error');
|
||||
feedback.textContent = 'Error: ' + e.message;
|
||||
btn.disabled = false;
|
||||
btn.textContent = t('email.restore_backup');
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,31 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.backup_restore') }}</h2>
|
||||
<div class="divider mt-0 mb-4"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{{ t('email.backup_title') }}</h3>
|
||||
<p class="text-sm opacity-80 mb-2">{{ t('email.backup_description') }}</p>
|
||||
<div class="badge badge-warning mb-4 p-3 h-auto whitespace-normal text-left">{{ t('email.backup_security_warning') }}</div>
|
||||
<br>
|
||||
<a href="{{ url_for('email.email_backup') }}" class="btn btn-primary btn-sm">{{ t('email.download_backup') }}</a>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{{ t('email.restore_title') }}</h3>
|
||||
<p class="text-sm opacity-80 mb-2">{{ t('email.restore_description') }}</p>
|
||||
<div class="badge badge-error mb-4 p-3 h-auto whitespace-normal text-left">{{ t('email.restore_warning') }}</div>
|
||||
<div class="flex gap-2">
|
||||
<input type="file" id="emailRestoreFile" accept=".zip" class="file-input file-input-bordered file-input-sm w-full max-w-xs" />
|
||||
<button class="btn btn-error btn-sm" id="emailRestoreBtn" onclick="emailRestoreBackup()">{{ t('email.restore_backup') }}</button>
|
||||
</div>
|
||||
<p id="emailRestoreFeedback" class="text-sm mt-2 font-semibold hidden"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.container_status') }}</h2>
|
||||
|
|
|
|||
|
|
@ -453,6 +453,50 @@ def mailbox_login():
|
|||
response.headers['Access-Control-Allow-Origin'] = origin
|
||||
return response
|
||||
|
||||
@email_bp.route('/backup', methods=['GET'])
|
||||
@login_required
|
||||
def email_backup():
|
||||
import requests
|
||||
from flask import Response, stream_with_context
|
||||
token = _generate_jwt(current_user.get_id(), role='admin')
|
||||
if not token:
|
||||
return jsonify({'error': 'JWT configuration missing'}), 500
|
||||
url = f"{config.MAIL_MANAGER_INTERNAL_URL}/api/v1/system/backup"
|
||||
try:
|
||||
req = requests.get(url, headers={'Authorization': f'Bearer {token}'}, stream=True, timeout=30)
|
||||
req.raise_for_status()
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return Response(
|
||||
stream_with_context(req.iter_content(chunk_size=8192)),
|
||||
content_type=req.headers.get('content-type', 'application/zip'),
|
||||
headers={'Content-Disposition': req.headers.get('content-disposition', 'attachment; filename="dockflare_email_backup.zip"')}
|
||||
)
|
||||
|
||||
@email_bp.route('/restore', methods=['POST'])
|
||||
@login_required
|
||||
def email_restore():
|
||||
import requests
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'success': False, 'error': 'No file uploaded'}), 400
|
||||
file = request.files['file']
|
||||
if not file.filename.endswith('.zip'):
|
||||
return jsonify({'success': False, 'error': 'Must be a ZIP file'}), 400
|
||||
token = _generate_jwt(current_user.get_id(), role='admin')
|
||||
if not token:
|
||||
return jsonify({'success': False, 'error': 'JWT configuration missing'}), 500
|
||||
url = f"{config.MAIL_MANAGER_INTERNAL_URL}/api/v1/system/restore"
|
||||
try:
|
||||
resp = requests.post(url, headers={'Authorization': f'Bearer {token}'}, files={'file': (file.filename, file.stream, file.mimetype)}, timeout=300)
|
||||
resp.raise_for_status()
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
try:
|
||||
err_data = resp.json()
|
||||
return jsonify({'success': False, 'error': err_data.get('error', str(e))}), 500
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def _check_internal_request():
|
||||
# Block any request that carries Cloudflare edge headers (all public internet
|
||||
# requests via the CF tunnel have CF-Ray; internal Docker requests never do)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from flask import Flask
|
||||
from .api.routes import api_bp
|
||||
from .api.webhook import webhook_bp
|
||||
from .api.system import system_bp
|
||||
from .core.database import init_db, register_db
|
||||
from .core.scheduler import start_scheduler
|
||||
|
||||
|
|
@ -14,4 +15,5 @@ def create_app():
|
|||
|
||||
app.register_blueprint(api_bp, url_prefix='/api/v1')
|
||||
app.register_blueprint(webhook_bp, url_prefix='/api/v1/webhook')
|
||||
app.register_blueprint(system_bp, url_prefix='/api/v1/system')
|
||||
return app
|
||||
|
|
|
|||
140
mail-manager/app/api/system.py
Normal file
140
mail-manager/app/api/system.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import os
|
||||
import sqlite3
|
||||
import zipfile
|
||||
import json
|
||||
import threading
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from flask import Blueprint, jsonify, send_file, request, after_this_request
|
||||
from app.config import config
|
||||
from app.api.middleware import admin_required
|
||||
|
||||
system_bp = Blueprint('system', __name__)
|
||||
|
||||
@system_bp.route('/backup', methods=['GET'])
|
||||
@admin_required
|
||||
def backup_system():
|
||||
tmp_db_path = '/tmp/backup.db'
|
||||
tmp_zip_path = f'/tmp/email_backup_{datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")}.zip'
|
||||
|
||||
if os.path.exists(tmp_db_path):
|
||||
os.remove(tmp_db_path)
|
||||
|
||||
db_path = config.DB_PATH
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(f"VACUUM INTO '{tmp_db_path}'")
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"files": []
|
||||
}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(tmp_db_path, arcname='db/mail.db')
|
||||
|
||||
att_path = config.ATTACHMENTS_PATH
|
||||
if os.path.exists(att_path):
|
||||
for root, dirs, files in os.walk(att_path):
|
||||
for f in files:
|
||||
file_path = os.path.join(root, f)
|
||||
arc_name = os.path.relpath(file_path, config.MAIL_DATA_PATH)
|
||||
zf.write(file_path, arcname=arc_name)
|
||||
|
||||
zf.writestr('manifest.json', json.dumps(manifest, indent=2))
|
||||
|
||||
@after_this_request
|
||||
def cleanup(response):
|
||||
if os.path.exists(tmp_db_path):
|
||||
os.remove(tmp_db_path)
|
||||
if os.path.exists(tmp_zip_path):
|
||||
os.remove(tmp_zip_path)
|
||||
return response
|
||||
|
||||
return send_file(tmp_zip_path, as_attachment=True, download_name=os.path.basename(tmp_zip_path))
|
||||
except Exception as e:
|
||||
if os.path.exists(tmp_db_path):
|
||||
os.remove(tmp_db_path)
|
||||
if os.path.exists(tmp_zip_path):
|
||||
os.remove(tmp_zip_path)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
def _schedule_restart():
|
||||
def restart():
|
||||
import time
|
||||
time.sleep(2)
|
||||
os._exit(0)
|
||||
threading.Thread(target=restart).start()
|
||||
|
||||
@system_bp.route('/restore', methods=['POST'])
|
||||
@admin_required
|
||||
def restore_system():
|
||||
config.IN_MAINTENANCE = True
|
||||
|
||||
if 'file' not in request.files:
|
||||
config.IN_MAINTENANCE = False
|
||||
return jsonify({"error": "No file uploaded"}), 400
|
||||
|
||||
file = request.files['file']
|
||||
tmp_upload_path = '/tmp/restore_upload.zip'
|
||||
staging_path = '/data/restore_staging'
|
||||
old_data_path = '/data/old_data'
|
||||
|
||||
file.save(tmp_upload_path)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_upload_path, 'r') as zf:
|
||||
if 'manifest.json' not in zf.namelist():
|
||||
raise ValueError("Invalid backup archive: missing manifest.json")
|
||||
if os.path.exists(staging_path):
|
||||
shutil.rmtree(staging_path)
|
||||
os.makedirs(staging_path)
|
||||
zf.extractall(staging_path)
|
||||
|
||||
if os.path.exists(old_data_path):
|
||||
shutil.rmtree(old_data_path)
|
||||
os.makedirs(old_data_path)
|
||||
|
||||
db_dir = os.path.dirname(config.DB_PATH)
|
||||
att_dir = config.ATTACHMENTS_PATH
|
||||
|
||||
if os.path.exists(db_dir):
|
||||
shutil.move(db_dir, os.path.join(old_data_path, 'db'))
|
||||
if os.path.exists(att_dir):
|
||||
shutil.move(att_dir, os.path.join(old_data_path, 'attachments'))
|
||||
|
||||
staged_db = os.path.join(staging_path, 'db')
|
||||
staged_att = os.path.join(staging_path, 'attachments')
|
||||
|
||||
if os.path.exists(staged_db):
|
||||
shutil.move(staged_db, db_dir)
|
||||
if os.path.exists(staged_att):
|
||||
shutil.move(staged_att, att_dir)
|
||||
|
||||
shutil.rmtree(staging_path)
|
||||
os.remove(tmp_upload_path)
|
||||
|
||||
_schedule_restart()
|
||||
return jsonify({"status": "success"})
|
||||
|
||||
except Exception as e:
|
||||
config.IN_MAINTENANCE = False
|
||||
if os.path.exists(tmp_upload_path):
|
||||
os.remove(tmp_upload_path)
|
||||
if os.path.exists(staging_path):
|
||||
shutil.rmtree(staging_path)
|
||||
if os.path.exists(os.path.join(old_data_path, 'db')):
|
||||
if os.path.exists(db_dir):
|
||||
shutil.rmtree(db_dir)
|
||||
shutil.move(os.path.join(old_data_path, 'db'), db_dir)
|
||||
if os.path.exists(os.path.join(old_data_path, 'attachments')):
|
||||
if os.path.exists(att_dir):
|
||||
shutil.rmtree(att_dir)
|
||||
shutil.move(os.path.join(old_data_path, 'attachments'), att_dir)
|
||||
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
|
@ -33,6 +33,9 @@ def _verify_signature(req, secret):
|
|||
|
||||
@webhook_bp.route('/inbound', methods=['POST'])
|
||||
def inbound():
|
||||
if getattr(config, 'IN_MAINTENANCE', False):
|
||||
return jsonify({"error": "Service unavailable during maintenance"}), 503
|
||||
|
||||
domain = request.headers.get('X-DockFlare-Domain', '').strip()
|
||||
|
||||
if domain and domain != 'undefined':
|
||||
|
|
|
|||
|
|
@ -68,3 +68,4 @@ class _Config:
|
|||
|
||||
|
||||
config = _Config()
|
||||
config.IN_MAINTENANCE = False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue