403 lines
13 KiB
PHP
403 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* Dapper Backup Module – Low-Memory, Dated Folders Version
|
||
*/
|
||
|
||
if (!defined('ABSPATH')) {
|
||
exit;
|
||
}
|
||
|
||
define('DAPPER_BACKUP_BASE_DIR', WP_CONTENT_DIR . '/dapper-backups');
|
||
define('DAPPER_BACKUP_RETENTION', 7); // keep last 7 full backup sets
|
||
|
||
/**
|
||
* Ensure base backup directory exists and is protected
|
||
*/
|
||
function dapper_ensure_backup_dir() {
|
||
$base_dir = DAPPER_BACKUP_BASE_DIR;
|
||
if (!wp_mkdir_p($base_dir)) {
|
||
dapper_debug_log('Failed to create base backup dir: ' . $base_dir);
|
||
return false;
|
||
}
|
||
|
||
$htaccess = $base_dir . '/.htaccess';
|
||
if (!file_exists($htaccess)) {
|
||
file_put_contents($htaccess, "Order deny,allow\nDeny from all\n");
|
||
}
|
||
|
||
$index = $base_dir . '/index.php';
|
||
if (!file_exists($index)) {
|
||
file_put_contents($index, '<?php // Silence is golden');
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Create a new dated backup subfolder and return its path
|
||
*/
|
||
function dapper_create_backup_folder() {
|
||
if (!dapper_ensure_backup_dir()) return false;
|
||
|
||
$timestamp = date('Y-m-d_H-i-s');
|
||
$backup_folder = DAPPER_BACKUP_BASE_DIR . '/' . $timestamp;
|
||
|
||
if (!wp_mkdir_p($backup_folder)) {
|
||
dapper_debug_log('Failed to create dated backup folder: ' . $backup_folder);
|
||
return false;
|
||
}
|
||
|
||
// Protect subfolder too
|
||
file_put_contents($backup_folder . '/index.php', '<?php // Silence is golden');
|
||
|
||
dapper_debug_log('Created dated backup folder: ' . $timestamp);
|
||
return $backup_folder;
|
||
}
|
||
|
||
/**
|
||
* Chunked database export to dated folder
|
||
*/
|
||
function dapper_export_database($backup_folder) {
|
||
global $wpdb;
|
||
|
||
$file = $backup_folder . '/database-' . date('Y-m-d_H-i-s') . '.sql';
|
||
$handle = fopen($file, 'w');
|
||
if ($handle === false) {
|
||
dapper_debug_log('Failed to open DB file: ' . $file);
|
||
return false;
|
||
}
|
||
|
||
fwrite($handle, "-- Dapper Database Backup\n-- Generated: " . date('Y-m-d H:i:s') . "\n\n");
|
||
|
||
$tables = $wpdb->get_col('SHOW TABLES');
|
||
$chunk_size = 300; // Smaller chunks for safety
|
||
|
||
foreach ($tables as $table) {
|
||
$create = $wpdb->get_row("SHOW CREATE TABLE `$table`", ARRAY_N);
|
||
fwrite($handle, $create[1] . ";\n\n");
|
||
|
||
$offset = 0;
|
||
while (true) {
|
||
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM `$table` LIMIT %d OFFSET %d", $chunk_size, $offset), ARRAY_A);
|
||
if (empty($rows)) break;
|
||
|
||
foreach ($rows as $row) {
|
||
$values = array_map([$wpdb, '_real_escape'], array_values($row));
|
||
$insert = "INSERT INTO `$table` VALUES ('" . implode("','", $values) . "');\n";
|
||
fwrite($handle, $insert);
|
||
}
|
||
|
||
$offset += $chunk_size;
|
||
unset($rows);
|
||
gc_collect_cycles();
|
||
|
||
$mem = memory_get_usage(true) / 1024 / 1024;
|
||
if ($mem > 110) {
|
||
fclose($handle);
|
||
@unlink($file);
|
||
dapper_debug_log('Memory high during DB export — aborted');
|
||
return false;
|
||
}
|
||
}
|
||
fwrite($handle, "\n\n");
|
||
}
|
||
|
||
fclose($handle);
|
||
dapper_debug_log('DB backup written to: ' . basename($file));
|
||
return $file;
|
||
}
|
||
|
||
/*
|
||
* Memory-safe zip to dated folder (optional folders)
|
||
*/
|
||
function dapper_zip_essential_files($backup_folder) {
|
||
if (!dapper_ensure_backup_dir()) return false;
|
||
|
||
$zip_files = []; // Collect successful zips
|
||
|
||
$backup_folders = [];
|
||
|
||
if (get_option('dapper_backup_themes', 'on') === 'on') {
|
||
$backup_folders['themes'] = WP_CONTENT_DIR . '/themes';
|
||
}
|
||
if (get_option('dapper_backup_plugins', 'on') === 'on') {
|
||
$backup_folders['plugins'] = WP_CONTENT_DIR . '/plugins';
|
||
}
|
||
if (get_option('dapper_backup_include_media', 'off') === 'on') {
|
||
$backup_folders['uploads'] = WP_CONTENT_DIR . '/uploads';
|
||
}
|
||
|
||
if (empty($backup_folders)) {
|
||
dapper_debug_log('No folders selected for file backup');
|
||
return $zip_files;
|
||
}
|
||
|
||
foreach ($backup_folders as $type => $root) {
|
||
if (!is_dir($root)) {
|
||
dapper_debug_log('Folder not found, skipping: ' . $type);
|
||
continue;
|
||
}
|
||
|
||
$zip_file = $backup_folder . '/' . $type . '-' . date('Y-m-d_H-i-s') . '.zip';
|
||
$zip = new ZipArchive();
|
||
|
||
if ($zip->open($zip_file, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||
dapper_debug_log('Failed to open ' . $type . ' zip: ' . $zip_file);
|
||
continue;
|
||
}
|
||
|
||
$file_count = 0;
|
||
$chunk_count = 0;
|
||
$max_per_chunk = 10000; // Reopen every 10k files to avoid timeout
|
||
|
||
$iterator = new RecursiveIteratorIterator(
|
||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
|
||
RecursiveIteratorIterator::LEAVES_ONLY
|
||
);
|
||
|
||
foreach ($iterator as $file) {
|
||
if ($file->isDir()) continue;
|
||
|
||
$file_path = $file->getRealPath();
|
||
$relative = substr($file_path, strlen(WP_CONTENT_DIR) + 1);
|
||
|
||
$zip->addFile($file_path, $relative);
|
||
$file_count++;
|
||
$chunk_count++;
|
||
|
||
if ($chunk_count % 500 === 0) {
|
||
$mem_mb = memory_get_usage(true) / 1024 / 1024;
|
||
dapper_debug_log("Added $file_count files to $type zip — memory: $mem_mb MB");
|
||
}
|
||
|
||
// Reopen zip every max_per_chunk files to reset buffers and avoid timeout
|
||
if ($chunk_count >= $max_per_chunk) {
|
||
$zip->close();
|
||
dapper_debug_log("Chunk complete for $type ($file_count total) — reopening zip");
|
||
$zip = new ZipArchive();
|
||
if ($zip->open($zip_file, ZipArchive::CREATE) !== true) { // CREATE appends, doesn't overwrite
|
||
dapper_debug_log('Failed to reopen ' . $type . ' zip');
|
||
break;
|
||
}
|
||
$chunk_count = 0; // Reset chunk counter
|
||
}
|
||
}
|
||
|
||
$zip->close();
|
||
|
||
if (!file_exists($zip_file) || filesize($zip_file) < 1024) {
|
||
dapper_debug_log($type . ' zip empty or failed: ' . $zip_file);
|
||
@unlink($zip_file);
|
||
continue;
|
||
}
|
||
|
||
dapper_debug_log($type . ' zip completed with ' . $file_count . ' files: ' . basename($zip_file));
|
||
$zip_files[] = $zip_file;
|
||
}
|
||
|
||
return $zip_files;
|
||
}
|
||
|
||
/**
|
||
* Create full dated backup set
|
||
*/
|
||
function dapper_create_new_backup() {
|
||
$backup_folder = dapper_create_backup_folder();
|
||
if (!$backup_folder) return false;
|
||
|
||
$db_file = dapper_export_database($backup_folder);
|
||
$files_zip = dapper_zip_essential_files($backup_folder);
|
||
|
||
if (!$db_file && !$files_zip) {
|
||
// Clean up empty folder if both failed
|
||
@rmdir($backup_folder);
|
||
return false;
|
||
}
|
||
|
||
dapper_cleanup_old_backups();
|
||
|
||
return $backup_folder;
|
||
}
|
||
|
||
/**
|
||
* Cleanup: keep only last N dated folders
|
||
*/
|
||
function dapper_cleanup_old_backups($keep = DAPPER_BACKUP_RETENTION) {
|
||
$folders = glob(DAPPER_BACKUP_BASE_DIR . '/*', GLOB_ONLYDIR);
|
||
if (count($folders) <= $keep) return;
|
||
|
||
usort($folders, function($a, $b) {
|
||
return filemtime($b) <=> filemtime($a);
|
||
});
|
||
|
||
for ($i = $keep; $i < count($folders); $i++) {
|
||
// Delete folder recursively
|
||
array_map('unlink', glob("$folders[$i]/*"));
|
||
@rmdir($folders[$i]);
|
||
dapper_debug_log('Deleted old backup folder: ' . basename($folders[$i]));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get list of dated backups for display
|
||
*/
|
||
function dapper_get_old_backups() {
|
||
$folders = glob(DAPPER_BACKUP_BASE_DIR . '/*', GLOB_ONLYDIR);
|
||
$backups = [];
|
||
|
||
foreach ($folders as $folder) {
|
||
$timestamp = basename($folder);
|
||
$ts = strtotime(str_replace('_', ' ', $timestamp));
|
||
|
||
$db_file = glob($folder . '/database-*.sql');
|
||
$zip_file = glob($folder . '/files-*.zip');
|
||
|
||
$backups[] = [
|
||
'folder' => $folder,
|
||
'timestamp' => $ts,
|
||
'date_fmt' => date('Y-m-d H:i:s', $ts),
|
||
'db' => !empty($db_file) ? $db_file[0] : '',
|
||
'files' => !empty($zip_file) ? $zip_file[0] : '',
|
||
'size_db' => !empty($db_file) ? filesize($db_file[0]) : 0,
|
||
'size_files'=> !empty($zip_file) ? filesize($zip_file[0]) : 0,
|
||
];
|
||
}
|
||
|
||
// Sort newest first
|
||
usort($backups, function($a, $b) {
|
||
return $b['timestamp'] <=> $a['timestamp'];
|
||
});
|
||
|
||
return $backups;
|
||
}
|
||
|
||
/**
|
||
* Display backups in settings (dated folders)
|
||
*/
|
||
function display_old_backup_list() {
|
||
$folders = glob(DAPPER_BACKUP_BASE_DIR . '/*', GLOB_ONLYDIR);
|
||
if (empty($folders)) {
|
||
echo '<p>No backups yet.</p>';
|
||
return;
|
||
}
|
||
|
||
usort($folders, function($a, $b) { return filemtime($b) <=> filemtime($a); });
|
||
|
||
echo '<table class="widefat striped">';
|
||
echo '<thead><tr><th>Date</th><th>Files</th><th>Size</th><th>Actions</th></tr></thead>';
|
||
echo '<tbody>';
|
||
|
||
foreach ($folders as $folder) {
|
||
$date = date('Y-m-d H:i:s', filemtime($folder));
|
||
$files = glob($folder . '/*.{sql,zip}', GLOB_BRACE);
|
||
$total_size = 0;
|
||
$file_links = [];
|
||
|
||
foreach ($files as $file) {
|
||
$size = filesize($file);
|
||
$total_size += $size;
|
||
$fname = basename($file);
|
||
$nonce = wp_create_nonce('dapper_dl_' . md5($fname));
|
||
$link = admin_url('admin-post.php?action=dapper_backup_dl&f=' . urlencode($fname) . '&folder=' . urlencode(basename($folder)) . '&_wpnonce=' . $nonce);
|
||
$file_links[] = '<a href="' . esc_url($link) . '">' . esc_html($fname) . '</a> (' . size_format($size) . ')';
|
||
}
|
||
|
||
$delete_nonce = wp_create_nonce('dapper_delete_' . md5(basename($folder)));
|
||
$delete_link = admin_url('admin-post.php?action=dapper_delete_backup&folder=' . urlencode(basename($folder)) . '&_wpnonce=' . $delete_nonce);
|
||
|
||
echo '<tr>';
|
||
echo '<td>' . esc_html($date) . '</td>';
|
||
echo '<td>' . implode('<br>', $file_links) . '</td>';
|
||
echo '<td>' . size_format($total_size) . '</td>';
|
||
echo '<td><a href="' . esc_url($delete_link) . '" onclick="return confirm(\'Delete this backup set?\');">Delete</a></td>';
|
||
echo '</tr>';
|
||
}
|
||
|
||
echo '</tbody></table>';
|
||
}
|
||
|
||
// Secure download (updated for dated folders)
|
||
add_action('admin_post_dapper_backup_dl', 'dapper_handle_backup_download');
|
||
|
||
function dapper_handle_backup_download() {
|
||
if (!current_user_can('manage_options')) {
|
||
wp_die('Access denied.');
|
||
}
|
||
|
||
$file = isset($_GET['f']) ? basename($_GET['f']) : '';
|
||
$folder = isset($_GET['folder']) ? basename($_GET['folder']) : '';
|
||
$nonce = $_GET['_wpnonce'] ?? '';
|
||
|
||
if (!wp_verify_nonce($nonce, 'dapper_dl_' . md5($file))) {
|
||
wp_die('Security check failed.');
|
||
}
|
||
|
||
$path = DAPPER_BACKUP_BASE_DIR . '/' . $folder . '/' . $file;
|
||
if (!file_exists($path) || !is_readable($path)) {
|
||
wp_die('File not found or inaccessible.');
|
||
}
|
||
|
||
// Disable output buffering & compression for large files
|
||
if (ob_get_level()) ob_end_clean();
|
||
header_remove('Content-Encoding'); // Prevent gzip
|
||
|
||
// Proper headers for binary download
|
||
header('Content-Description: File Transfer');
|
||
header('Content-Type: application/zip'); // Force zip type
|
||
header('Content-Disposition: attachment; filename="' . $file . '"');
|
||
header('Content-Transfer-Encoding: binary');
|
||
header('Expires: 0');
|
||
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
|
||
header('Pragma: public');
|
||
|
||
// Chunked output to avoid memory issues
|
||
header('Content-Length: ' . filesize($path));
|
||
|
||
// Stream file in 1 MB chunks
|
||
$chunk_size = 1024 * 1024; // 1 MB
|
||
$handle = fopen($path, 'rb');
|
||
if ($handle === false) {
|
||
wp_die('Failed to open file for reading.');
|
||
}
|
||
|
||
while (!feof($handle)) {
|
||
echo fread($handle, $chunk_size);
|
||
flush(); // Send chunk immediately
|
||
}
|
||
|
||
fclose($handle);
|
||
exit;
|
||
}
|
||
|
||
// Delete entire dated backup folder
|
||
add_action('admin_post_dapper_delete_backup', 'dapper_handle_delete_backup');
|
||
|
||
function dapper_handle_delete_backup() {
|
||
if (!current_user_can('manage_options')) wp_die('Access denied.');
|
||
|
||
$folder = isset($_GET['folder']) ? basename($_GET['folder']) : '';
|
||
$nonce = $_GET['_wpnonce'] ?? '';
|
||
|
||
if (!wp_verify_nonce($nonce, 'dapper_delete_' . md5($folder))) {
|
||
wp_die('Security check failed.');
|
||
}
|
||
|
||
$path = DAPPER_BACKUP_BASE_DIR . '/' . $folder;
|
||
if (!file_exists($path) || !is_dir($path)) {
|
||
wp_die('Backup not found.');
|
||
}
|
||
|
||
// Delete all files in folder
|
||
array_map('unlink', glob("$path/*"));
|
||
@rmdir($path);
|
||
|
||
dapper_debug_log('Deleted backup folder: ' . $folder);
|
||
|
||
$redirect = add_query_arg(
|
||
['page' => 'dapper-settings', 'backup_deleted' => '1'],
|
||
admin_url('admin.php')
|
||
);
|
||
wp_safe_redirect($redirect);
|
||
exit;
|
||
}
|