PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.10.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.10.0
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-database-backup.php

class-database-backup.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.10.0, at includes/class-database-backup.php

429 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Database Backup Class
4 *
5 * Handles database backup with table selection and ZIP download
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
16
17 /**
18 * Class Vigilante_Database_Backup
19 *
20 * Creates downloadable database backups
21 */
22 class Vigilante_Database_Backup {
23
24 /**
25 * WordPress database instance
26 *
27 * @var wpdb
28 */
29 private $wpdb;
30
31 /**
32 * Default WordPress core tables (without prefix)
33 *
34 * @var array
35 */
36 private $core_tables = array(
37 'commentmeta',
38 'comments',
39 'links',
40 'options',
41 'postmeta',
42 'posts',
43 'term_relationships',
44 'term_taxonomy',
45 'termmeta',
46 'terms',
47 'usermeta',
48 'users',
49 );
50
51 /**
52 * Constructor
53 */
54 public function __construct() {
55 global $wpdb;
56 $this->wpdb = $wpdb;
57 }
58
59 /**
60 * Get all database tables grouped by type
61 *
62 * Returns tables organized as 'core' (WP default) and 'other' (plugins, etc.)
63 *
64 * @return array {
65 * @type array $core WordPress core tables.
66 * @type array $other Plugin and custom tables.
67 * }
68 */
69 public function get_tables() {
70 $prefix = $this->wpdb->prefix;
71 $tables = array(
72 'core' => array(),
73 'other' => array(),
74 );
75
76 // Get all tables that match the current prefix
77 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
78 $all_tables = $this->wpdb->get_results(
79 $this->wpdb->prepare(
80 'SHOW TABLE STATUS LIKE %s',
81 $this->wpdb->esc_like( $prefix ) . '%'
82 )
83 );
84
85 if ( ! $all_tables ) {
86 return $tables;
87 }
88
89 foreach ( $all_tables as $table ) {
90 $name_without_prefix = substr( $table->Name, strlen( $prefix ) );
91 $table_info = array(
92 'name' => $table->Name,
93 'short' => $name_without_prefix,
94 'rows' => (int) $table->Rows,
95 'size' => $this->format_size( $table->Data_length + $table->Index_length ),
96 'bytes' => (int) ( $table->Data_length + $table->Index_length ),
97 );
98
99 if ( in_array( $name_without_prefix, $this->core_tables, true ) ) {
100 $tables['core'][] = $table_info;
101 } else {
102 $tables['other'][] = $table_info;
103 }
104 }
105
106 // Sort by name
107 usort( $tables['core'], array( $this, 'sort_by_name' ) );
108 usort( $tables['other'], array( $this, 'sort_by_name' ) );
109
110 return $tables;
111 }
112
113 /**
114 * Generate SQL dump for selected tables
115 *
116 * @param array $table_names List of full table names to export.
117 * @return string|WP_Error SQL content or error.
118 */
119 public function generate_sql_dump( $table_names ) {
120 if ( empty( $table_names ) ) {
121 return new WP_Error( 'no_tables', __( 'No tables selected for backup.', 'vigilante' ) );
122 }
123
124 // Validate all table names belong to this site
125 $valid_tables = $this->get_valid_table_names();
126 foreach ( $table_names as $table ) {
127 if ( ! in_array( $table, $valid_tables, true ) ) {
128 return new WP_Error(
129 'invalid_table',
130 sprintf(
131 /* translators: %s: Table name */
132 __( 'Invalid table name: %s', 'vigilante' ),
133 $table
134 )
135 );
136 }
137 }
138
139 $sql = '';
140
141 // File header
142 $sql .= "-- ==========================================================\n";
143 $sql .= "-- Vigilante Database Backup\n";
144 $sql .= '-- Generated: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n";
145 $sql .= '-- WordPress: ' . get_bloginfo( 'version' ) . "\n";
146 $sql .= '-- Site: ' . esc_url( home_url() ) . "\n";
147 $sql .= '-- Tables: ' . count( $table_names ) . "\n";
148 $sql .= "-- ==========================================================\n\n";
149
150 $sql .= "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n";
151 $sql .= "SET time_zone = \"+00:00\";\n";
152 $sql .= "SET NAMES utf8mb4;\n\n";
153
154 foreach ( $table_names as $table_name ) {
155 $table_sql = $this->dump_table( $table_name );
156 if ( is_wp_error( $table_sql ) ) {
157 return $table_sql;
158 }
159 $sql .= $table_sql;
160 }
161
162 $sql .= "-- End of Vigilante backup\n";
163
164 return $sql;
165 }
166
167 /**
168 * Create ZIP file with SQL dump
169 *
170 * @param string $sql_content SQL dump content.
171 * @return string|WP_Error Path to temporary ZIP file or error.
172 */
173 public function create_zip( $sql_content ) {
174 if ( ! class_exists( 'ZipArchive' ) ) {
175 return new WP_Error(
176 'zip_unavailable',
177 __( 'ZipArchive extension is not available on this server.', 'vigilante' )
178 );
179 }
180
181 $upload_dir = wp_upload_dir();
182 $temp_dir = trailingslashit( $upload_dir['basedir'] ) . 'vigilante-temp/';
183
184 // Create temp directory
185 if ( ! wp_mkdir_p( $temp_dir ) ) {
186 return new WP_Error( 'dir_error', __( 'Cannot create temporary directory.', 'vigilante' ) );
187 }
188
189 // Protect temp directory: deny rule (Apache/LiteSpeed) plus an index so
190 // it cannot be listed. The unguessable filename below is the real guard
191 // on servers that ignore .htaccess.
192 $htaccess_path = $temp_dir . '.htaccess';
193 if ( ! file_exists( $htaccess_path ) ) {
194 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- writing a protective deny rule, not user input.
195 file_put_contents( $htaccess_path, "Deny from all\n" );
196 }
197 $index_path = $temp_dir . 'index.php';
198 if ( ! file_exists( $index_path ) ) {
199 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- writing a silence-is-golden index, not user input.
200 file_put_contents( $index_path, "<?php\n// Silence is golden.\n" );
201 }
202
203 // Random suffix so the archive cannot be fetched by guessing the
204 // timestamp if it is ever left behind on a server that serves uploads.
205 $token = wp_generate_password( 20, false );
206 $sql_filename = 'vigilante-db-backup-' . gmdate( 'Y-m-d-His' ) . '.sql';
207 $zip_filename = 'vigilante-db-backup-' . gmdate( 'Y-m-d-His' ) . '-' . $token . '.zip';
208 $zip_path = $temp_dir . $zip_filename;
209
210 $zip = new ZipArchive();
211 $result = $zip->open( $zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE );
212
213 if ( true !== $result ) {
214 return new WP_Error( 'zip_create_error', __( 'Failed to create ZIP file.', 'vigilante' ) );
215 }
216
217 $zip->addFromString( $sql_filename, $sql_content );
218 $zip->close();
219
220 if ( ! file_exists( $zip_path ) ) {
221 return new WP_Error( 'zip_missing', __( 'ZIP file was not created.', 'vigilante' ) );
222 }
223
224 return $zip_path;
225 }
226
227 /**
228 * Stream ZIP file to browser and clean up
229 *
230 * @param string $zip_path Path to ZIP file.
231 * @return void|WP_Error
232 */
233 public function stream_download( $zip_path ) {
234 if ( ! file_exists( $zip_path ) ) {
235 return new WP_Error( 'file_not_found', __( 'Backup file not found.', 'vigilante' ) );
236 }
237
238 $filename = basename( $zip_path );
239
240 // Clear output buffers
241 while ( ob_get_level() ) {
242 ob_end_clean();
243 }
244
245 // Send headers
246 nocache_headers();
247 header( 'Content-Type: application/zip' );
248 header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
249 header( 'Content-Length: ' . filesize( $zip_path ) );
250 header( 'Content-Transfer-Encoding: binary' );
251
252 // Stream file
253 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
254 readfile( $zip_path );
255
256 // Clean up
257 $this->cleanup_temp_files();
258
259 exit;
260 }
261
262 /**
263 * Remove temporary backup files
264 */
265 public function cleanup_temp_files() {
266 $upload_dir = wp_upload_dir();
267 $temp_dir = trailingslashit( $upload_dir['basedir'] ) . 'vigilante-temp/';
268
269 if ( ! is_dir( $temp_dir ) ) {
270 return;
271 }
272
273 $files = glob( $temp_dir . '*' );
274 if ( $files ) {
275 foreach ( $files as $file ) {
276 if ( is_file( $file ) ) {
277 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
278 unlink( $file );
279 }
280 }
281 }
282
283 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
284 rmdir( $temp_dir );
285 }
286
287 /**
288 * Dump a single table to SQL
289 *
290 * @param string $table_name Full table name.
291 * @return string|WP_Error SQL for the table.
292 */
293 private function dump_table( $table_name ) {
294 $sql = '';
295 $sql .= "-- ----------------------------------------------------------\n";
296 $sql .= '-- Table: ' . $table_name . "\n";
297 $sql .= "-- ----------------------------------------------------------\n\n";
298
299 // Get CREATE TABLE statement (%i identifier placeholder, WP 6.2+)
300 $create = $this->wpdb->get_row(
301 $this->wpdb->prepare( 'SHOW CREATE TABLE %i', $table_name ),
302 ARRAY_N
303 );
304 if ( ! $create ) {
305 return new WP_Error(
306 'table_error',
307 sprintf(
308 /* translators: %s: Table name */
309 __( 'Cannot read table structure: %s', 'vigilante' ),
310 $table_name
311 )
312 );
313 }
314
315 $sql .= "DROP TABLE IF EXISTS `{$table_name}`;\n";
316 $sql .= $create[1] . ";\n\n";
317
318 // Get row count first
319 $row_count = (int) $this->wpdb->get_var(
320 $this->wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table_name )
321 );
322
323 if ( $row_count > 0 ) {
324 // Export in batches to avoid memory issues
325 $batch_size = 500;
326 $offset = 0;
327
328 // Get column names for INSERT statement
329 $columns = $this->wpdb->get_results(
330 $this->wpdb->prepare( 'SHOW COLUMNS FROM %i', $table_name ),
331 ARRAY_A
332 );
333 $col_names = array_map(
334 function ( $col ) {
335 return '`' . $col['Field'] . '`';
336 },
337 $columns
338 );
339
340 $sql .= 'LOCK TABLES `' . $table_name . "` WRITE;\n";
341
342 while ( $offset < $row_count ) {
343 $rows = $this->wpdb->get_results(
344 $this->wpdb->prepare(
345 'SELECT * FROM %i LIMIT %d OFFSET %d',
346 $table_name,
347 $batch_size,
348 $offset
349 ),
350 ARRAY_N
351 );
352
353 if ( ! empty( $rows ) ) {
354 $sql .= 'INSERT INTO `' . $table_name . '` (' . implode( ', ', $col_names ) . ") VALUES\n";
355
356 $values = array();
357 foreach ( $rows as $row ) {
358 $escaped = array_map(
359 function ( $value ) {
360 if ( null === $value ) {
361 return 'NULL';
362 }
363 return "'" . esc_sql( $value ) . "'";
364 },
365 $row
366 );
367 $values[] = '(' . implode( ', ', $escaped ) . ')';
368 }
369
370 $sql .= implode( ",\n", $values ) . ";\n";
371 }
372
373 $offset += $batch_size;
374 }
375
376 $sql .= "UNLOCK TABLES;\n";
377 }
378
379 $sql .= "\n";
380
381 return $sql;
382 }
383
384 /**
385 * Get list of valid table names for this site
386 *
387 * @return array Full table names.
388 */
389 private function get_valid_table_names() {
390 $prefix = $this->wpdb->prefix;
391
392 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
393 $results = $this->wpdb->get_col(
394 $this->wpdb->prepare(
395 'SHOW TABLES LIKE %s',
396 $this->wpdb->esc_like( $prefix ) . '%'
397 )
398 );
399
400 return $results ? $results : array();
401 }
402
403 /**
404 * Format byte size to human readable
405 *
406 * @param int $bytes Size in bytes.
407 * @return string Formatted size.
408 */
409 private function format_size( $bytes ) {
410 $units = array( 'B', 'KB', 'MB', 'GB' );
411 $bytes = max( $bytes, 0 );
412 $pow = floor( ( $bytes ? log( $bytes ) : 0 ) / log( 1024 ) );
413 $pow = min( $pow, count( $units ) - 1 );
414 $bytes /= pow( 1024, $pow );
415
416 return round( $bytes, 2 ) . ' ' . $units[ $pow ];
417 }
418
419 /**
420 * Sort callback by table name
421 *
422 * @param array $a First table.
423 * @param array $b Second table.
424 * @return int
425 */
426 private function sort_by_name( $a, $b ) {
427 return strcmp( $a['name'], $b['name'] );
428 }
429 }