Add database backup/restore and WebDAV backup features

Developer tab now includes:
- Database Backup & Restore section with download/upload JSON backup
- WebDAV Backup section to upload backups to Nextcloud/WebDAV servers
  with configurable server URL, username, and password

Features:
- Full JSON export of entries, settings, logos, users, license
- Import with data replacement and transaction safety
- WebDAV upload via cURL with PUT request
- Automatic timestamped filenames (ipmanager-YYYY-MM-DD_HH-mm-ss.json)
- Settings persistence for WebDAV credentials

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Purple
2026-01-18 13:42:13 +00:00
parent 38d1cf8c7a
commit c67d7ff139
3 changed files with 579 additions and 1 deletions

View File

@@ -171,7 +171,10 @@ INSERT INTO geofeed_settings (setting_key, setting_value) VALUES
('whitelabel_company_name', ''), ('whitelabel_company_name', ''),
('whitelabel_icon_url', ''), ('whitelabel_icon_url', ''),
('whitelabel_favicon_url', ''), ('whitelabel_favicon_url', ''),
('whitelabel_default_import_url', '') ('whitelabel_default_import_url', ''),
('webdav_server_url', ''),
('webdav_username', ''),
('webdav_password', '')
ON DUPLICATE KEY UPDATE setting_key = setting_key; ON DUPLICATE KEY UPDATE setting_key = setting_key;
-- ============================================ -- ============================================

View File

@@ -171,6 +171,26 @@ try {
handleSchemaApply($db); handleSchemaApply($db);
break; break;
case 'backup_export':
handleBackupExport($db);
break;
case 'backup_import':
handleBackupImport($db);
break;
case 'webdav_settings_get':
handleWebDAVSettingsGet($db);
break;
case 'webdav_settings_save':
handleWebDAVSettingsSave($db);
break;
case 'webdav_backup':
handleWebDAVBackup($db);
break;
case 'error_logs': case 'error_logs':
handleErrorLogs($db); handleErrorLogs($db);
break; break;
@@ -3712,3 +3732,299 @@ function handleLicenseUsage($db) {
'usage' => $usage 'usage' => $usage
]); ]);
} }
/**
* Export full database backup as JSON
*/
function handleBackupExport($db) {
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
try {
$backup = [
'version' => APP_VERSION,
'exported_at' => date('Y-m-d H:i:s'),
'entries' => [],
'settings' => [],
'logos' => [],
'users' => [],
'license' => null
];
// Export geofeed entries
$stmt = $db->query("SELECT * FROM geofeed_entries ORDER BY id");
$backup['entries'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Export settings
$stmt = $db->query("SELECT * FROM geofeed_settings");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$backup['settings'][$row['setting_key']] = $row['setting_value'];
}
// Export logos
$stmt = $db->query("SELECT * FROM client_logos ORDER BY id");
$backup['logos'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Export admin users
$stmt = $db->query("SELECT id, email, role, display_name, active, created_at FROM admin_users ORDER BY id");
$backup['users'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Export license info
$stmt = $db->query("SELECT * FROM license_info WHERE is_active = 1 ORDER BY id DESC LIMIT 1");
$backup['license'] = $stmt->fetch(PDO::FETCH_ASSOC);
jsonResponse([
'success' => true,
'backup' => $backup
]);
} catch (Exception $e) {
jsonResponse(['error' => 'Backup export failed: ' . $e->getMessage()], 500);
}
}
/**
* Import database from JSON backup
*/
function handleBackupImport($db) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
$input = json_decode(file_get_contents('php://input'), true);
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
jsonResponse(['error' => 'Invalid CSRF token'], 403);
}
if (empty($input['backup_data'])) {
jsonResponse(['error' => 'No backup data provided'], 400);
}
$backup = $input['backup_data'];
try {
$db->beginTransaction();
// Clear existing data
$db->exec("DELETE FROM geofeed_entries");
$db->exec("DELETE FROM client_logos");
// Import entries
if (!empty($backup['entries'])) {
$columns = ['ip_prefix', 'country_code', 'region_code', 'city', 'postal_code', 'client_short_name', 'notes', 'sort_order'];
foreach ($backup['entries'] as $entry) {
$stmt = $db->prepare("INSERT INTO geofeed_entries (ip_prefix, country_code, region_code, city, postal_code, client_short_name, notes, sort_order) VALUES (:ip_prefix, :country_code, :region_code, :city, :postal_code, :client_short_name, :notes, :sort_order)");
$stmt->execute([
':ip_prefix' => $entry['ip_prefix'],
':country_code' => $entry['country_code'] ?? null,
':region_code' => $entry['region_code'] ?? null,
':city' => $entry['city'] ?? null,
':postal_code' => $entry['postal_code'] ?? null,
':client_short_name' => $entry['client_short_name'] ?? null,
':notes' => $entry['notes'] ?? null,
':sort_order' => $entry['sort_order'] ?? 0
]);
}
}
// Import settings (skip sensitive ones)
$skipSettings = ['aws_secret_access_key', 'ipregistry_api_key', 'webdav_password'];
if (!empty($backup['settings'])) {
foreach ($backup['settings'] as $key => $value) {
if (!in_array($key, $skipSettings) && $value !== null) {
saveSetting($db, $key, $value);
}
}
}
// Import logos
if (!empty($backup['logos'])) {
foreach ($backup['logos'] as $logo) {
$stmt = $db->prepare("INSERT INTO client_logos (short_name, logo_url) VALUES (:short_name, :logo_url) ON DUPLICATE KEY UPDATE logo_url = VALUES(logo_url)");
$stmt->execute([
':short_name' => $logo['short_name'],
':logo_url' => $logo['logo_url']
]);
}
}
$db->commit();
// Log the import
logAudit($db, null, 'backup_import', null, [
'entries_imported' => count($backup['entries'] ?? []),
'logos_imported' => count($backup['logos'] ?? [])
]);
jsonResponse([
'success' => true,
'message' => 'Backup imported successfully',
'entries_imported' => count($backup['entries'] ?? [])
]);
} catch (Exception $e) {
$db->rollBack();
jsonResponse(['error' => 'Backup import failed: ' . $e->getMessage()], 500);
}
}
/**
* Get WebDAV settings
*/
function handleWebDAVSettingsGet($db) {
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
jsonResponse([
'success' => true,
'settings' => [
'webdav_server_url' => getSetting($db, 'webdav_server_url', ''),
'webdav_username' => getSetting($db, 'webdav_username', '')
// Password not returned for security
]
]);
}
/**
* Save WebDAV settings
*/
function handleWebDAVSettingsSave($db) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
$input = json_decode(file_get_contents('php://input'), true);
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
jsonResponse(['error' => 'Invalid CSRF token'], 403);
}
saveSetting($db, 'webdav_server_url', trim($input['webdav_server_url'] ?? ''));
saveSetting($db, 'webdav_username', trim($input['webdav_username'] ?? ''));
// Only update password if provided
if (!empty($input['webdav_password'])) {
saveSetting($db, 'webdav_password', $input['webdav_password']);
}
jsonResponse(['success' => true, 'message' => 'WebDAV settings saved']);
}
/**
* Backup to WebDAV server
*/
function handleWebDAVBackup($db) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405);
}
$input = json_decode(file_get_contents('php://input'), true);
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
jsonResponse(['error' => 'Invalid CSRF token'], 403);
}
$serverUrl = trim($input['webdav_server_url'] ?? '');
$username = trim($input['webdav_username'] ?? '');
$password = $input['webdav_password'] ?? '';
// Use saved password if not provided
if (empty($password)) {
$password = getSetting($db, 'webdav_password', '');
}
if (empty($serverUrl) || empty($username)) {
jsonResponse(['error' => 'WebDAV server URL and username are required'], 400);
}
try {
// Generate backup data
$backup = [
'version' => APP_VERSION,
'exported_at' => date('Y-m-d H:i:s'),
'entries' => [],
'settings' => [],
'logos' => [],
'users' => [],
'license' => null
];
// Export geofeed entries
$stmt = $db->query("SELECT * FROM geofeed_entries ORDER BY id");
$backup['entries'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Export settings (skip sensitive)
$skipSettings = ['aws_secret_access_key', 'ipregistry_api_key', 'webdav_password'];
$stmt = $db->query("SELECT * FROM geofeed_settings");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (!in_array($row['setting_key'], $skipSettings)) {
$backup['settings'][$row['setting_key']] = $row['setting_value'];
}
}
// Export logos
$stmt = $db->query("SELECT * FROM client_logos ORDER BY id");
$backup['logos'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Export admin users
$stmt = $db->query("SELECT id, email, role, display_name, active, created_at FROM admin_users ORDER BY id");
$backup['users'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
$jsonData = json_encode($backup, JSON_PRETTY_PRINT);
// Generate filename with timestamp
$timestamp = date('Y-m-d_H-i-s');
$filename = "ipmanager-{$timestamp}.json";
// Build WebDAV URL
$webdavUrl = rtrim($serverUrl, '/') . '/public.php/webdav/' . $filename;
// Upload via cURL
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $webdavUrl,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $jsonData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Requested-With: XMLHttpRequest'
],
CURLOPT_USERPWD => $username . ':' . $password,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 60
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
jsonResponse(['error' => 'WebDAV upload failed: ' . $error], 500);
}
if ($httpCode >= 200 && $httpCode < 300) {
// Log the backup
logAudit($db, null, 'webdav_backup', null, [
'filename' => $filename,
'server' => $serverUrl,
'entries' => count($backup['entries'])
]);
jsonResponse([
'success' => true,
'message' => 'Backup uploaded successfully',
'filename' => $filename
]);
} else {
jsonResponse(['error' => "WebDAV upload failed with HTTP $httpCode"], 500);
}
} catch (Exception $e) {
jsonResponse(['error' => 'WebDAV backup failed: ' . $e->getMessage()], 500);
}
}

View File

@@ -747,6 +747,86 @@ require_once __DIR__ . '/includes/header.php';
<div id="schemaCheckResult" style="margin-top: 16px; display: none;"></div> <div id="schemaCheckResult" style="margin-top: 16px; display: none;"></div>
</div> </div>
<!-- Database Backup/Restore -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Database Backup & Restore
</h2>
<p class="advanced-section-desc">Export or import a full database backup as JSON (entries, settings, logos, audit log).</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="downloadBackup()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download Backup
</button>
<input type="file" id="restoreFileInput" accept=".json" style="display: none;" onchange="handleRestoreFile(this)">
<button class="btn btn-secondary" onclick="document.getElementById('restoreFileInput').click()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Restore from File
</button>
</div>
<div id="backupRestoreStatus" style="margin-top: 12px; display: none;"></div>
</div>
<!-- WebDAV Backup -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
WebDAV Backup
</h2>
<p class="advanced-section-desc">Backup database to a WebDAV server (e.g., Nextcloud public share).</p>
<div class="form-grid" style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">WebDAV Server URL</label>
<input type="url" class="form-input" id="webdavServerUrl" placeholder="https://cloud.example.com">
</div>
<div class="form-group">
<label class="form-label">Username / Share Token</label>
<input type="text" class="form-input" id="webdavUsername" placeholder="SoXpcAG5WPdBTKi">
</div>
</div>
<div class="form-group">
<label class="form-label">Password (leave empty if none)</label>
<input type="password" class="form-input" id="webdavPassword" placeholder="Optional password" style="max-width: 300px;">
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="backupToWebDAV()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Backup to WebDAV
</button>
<button class="btn btn-secondary" onclick="saveWebDAVSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Settings
</button>
</div>
<div id="webdavBackupStatus" style="margin-top: 12px; display: none;"></div>
</div>
<!-- System Info --> <!-- System Info -->
<div class="advanced-section"> <div class="advanced-section">
<h2 class="advanced-section-title">System Information</h2> <h2 class="advanced-section-title">System Information</h2>
@@ -1364,6 +1444,185 @@ async function applySchemaUpdates() {
applyBtn.disabled = false; applyBtn.disabled = false;
} }
} }
// Database Backup/Restore Functions
async function downloadBackup() {
try {
showNotification("Generating backup...", "info");
const response = await fetch("api.php?action=backup_export", {
headers: { "X-CSRF-Token": CSRF_TOKEN },
credentials: "same-origin"
});
if (!response.ok) {
throw new Error("Failed to generate backup");
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Backup failed");
}
// Create download
const blob = new Blob([JSON.stringify(data.backup, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
a.href = url;
a.download = `ipmanager-backup-${timestamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification("Backup downloaded successfully", "success");
} catch (error) {
console.error("Backup error:", error);
showNotification("Backup failed: " + error.message, "error");
}
}
async function handleRestoreFile(input) {
const file = input.files[0];
if (!file) return;
if (!confirm("WARNING: This will replace all existing data with the backup. Are you sure you want to continue?")) {
input.value = "";
return;
}
const statusDiv = document.getElementById("backupRestoreStatus");
statusDiv.style.display = "block";
statusDiv.innerHTML = \'<div class="loading"><div class="spinner"></div></div>\';
try {
const text = await file.text();
const backupData = JSON.parse(text);
const response = await fetch("api.php?action=backup_import", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
credentials: "same-origin",
body: JSON.stringify({ backup_data: backupData, csrf_token: CSRF_TOKEN })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Restore failed");
}
statusDiv.innerHTML = \'<div style="padding: 12px; background: var(--success-bg); border: 1px solid var(--success); border-radius: var(--radius-md);"><strong style="color: var(--success);">Restore completed successfully!</strong></div>\';
showNotification("Database restored successfully", "success");
} catch (error) {
console.error("Restore error:", error);
statusDiv.innerHTML = `<div style="padding: 12px; background: var(--error-bg); border: 1px solid var(--error); border-radius: var(--radius-md);"><strong style="color: var(--error);">Restore failed:</strong> ${escapeHtml(error.message)}</div>`;
}
input.value = "";
}
// WebDAV Backup Functions
async function loadWebDAVSettings() {
try {
const response = await fetch("api.php?action=webdav_settings_get", {
headers: { "X-CSRF-Token": CSRF_TOKEN },
credentials: "same-origin"
});
const data = await response.json();
if (data.success) {
document.getElementById("webdavServerUrl").value = data.settings.webdav_server_url || "";
document.getElementById("webdavUsername").value = data.settings.webdav_username || "";
// Password is not returned for security
}
} catch (error) {
console.error("Failed to load WebDAV settings:", error);
}
}
async function saveWebDAVSettings() {
const serverUrl = document.getElementById("webdavServerUrl").value.trim();
const username = document.getElementById("webdavUsername").value.trim();
const password = document.getElementById("webdavPassword").value;
try {
const response = await fetch("api.php?action=webdav_settings_save", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
credentials: "same-origin",
body: JSON.stringify({
webdav_server_url: serverUrl,
webdav_username: username,
webdav_password: password,
csrf_token: CSRF_TOKEN
})
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to save settings");
}
showNotification("WebDAV settings saved", "success");
} catch (error) {
console.error("Save WebDAV settings error:", error);
showNotification("Failed to save settings: " + error.message, "error");
}
}
async function backupToWebDAV() {
const serverUrl = document.getElementById("webdavServerUrl").value.trim();
const username = document.getElementById("webdavUsername").value.trim();
const password = document.getElementById("webdavPassword").value;
if (!serverUrl || !username) {
showNotification("Please enter WebDAV server URL and username", "error");
return;
}
const statusDiv = document.getElementById("webdavBackupStatus");
statusDiv.style.display = "block";
statusDiv.innerHTML = \'<div class="loading"><div class="spinner"></div></div>\';
try {
const response = await fetch("api.php?action=webdav_backup", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
credentials: "same-origin",
body: JSON.stringify({
webdav_server_url: serverUrl,
webdav_username: username,
webdav_password: password,
csrf_token: CSRF_TOKEN
})
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "WebDAV backup failed");
}
statusDiv.innerHTML = `<div style="padding: 12px; background: var(--success-bg); border: 1px solid var(--success); border-radius: var(--radius-md);"><strong style="color: var(--success);">Backup uploaded successfully!</strong><br><small>Filename: ${escapeHtml(data.filename)}</small></div>`;
showNotification("Backup uploaded to WebDAV", "success");
} catch (error) {
console.error("WebDAV backup error:", error);
statusDiv.innerHTML = `<div style="padding: 12px; background: var(--error-bg); border: 1px solid var(--error); border-radius: var(--radius-md);"><strong style="color: var(--error);">WebDAV backup failed:</strong> ${escapeHtml(error.message)}</div>`;
}
}
// Load WebDAV settings when on developer tab
if (document.getElementById("webdavServerUrl")) {
loadWebDAVSettings();
}
</script>'; </script>';
// Include footer // Include footer