PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.7
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 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-database-backup.php

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

433 lines 13.9 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 /**
16 * Class Vigilante_Database_Backup
17 *
18 * Creates downloadable database backups
19 */
20 class Vigilante_Database_Backup {
21
22 /**
23 * WordPress database instance
24 *
25 * @var wpdb
26 */
27 private $wpdb;
28
29 /**
30 * Default WordPress core tables (without prefix)
31 *
32 * @var array
33 */
34 private $core_tables = array(
35 'commentmeta',
36 'comments',
37 'links',
38 'options',
39 'postmeta',
40 'posts',
41 'term_relationships',
42 'term_taxonomy',
43 'termmeta',
44 'terms',
45 'usermeta',
46 'users',
47 );
48
49 /**
50 * Constructor
51 */
52 public function __construct() {
53 global $wpdb;
54 $this->wpdb = $wpdb;
55 }
56
57 /**
58 * Get all database tables grouped by type
59 *
60 * Returns tables organized as 'core' (WP default) and 'other' (plugins, etc.)
61 *
62 * @return array {
63 * @type array $core WordPress core tables.
64 * @type array $other Plugin and custom tables.
65 * }
66 */
67 public function get_tables() {
68 $prefix = $this->wpdb->prefix;
69 $tables = array(
70 'core' => array(),
71 'other' => array(),
72 );
73
74 // Get all tables that match the current prefix
75 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare().
76 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
77 $all_tables = $this->wpdb->get_results(
78 $this->wpdb->prepare(
79 'SHOW TABLE STATUS LIKE %s',
80 $this->wpdb->esc_like( $prefix ) . '%'
81 )
82 );
83 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
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 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare().
295 $sql = '';
296 $sql .= "-- ----------------------------------------------------------\n";
297 $sql .= '-- Table: ' . $table_name . "\n";
298 $sql .= "-- ----------------------------------------------------------\n\n";
299
300 // Get CREATE TABLE statement (%i identifier placeholder, WP 6.2+)
301 $create = $this->wpdb->get_row(
302 $this->wpdb->prepare( 'SHOW CREATE TABLE %i', $table_name ),
303 ARRAY_N
304 );
305 if ( ! $create ) {
306 return new WP_Error(
307 'table_error',
308 sprintf(
309 /* translators: %s: Table name */
310 __( 'Cannot read table structure: %s', 'vigilante' ),
311 $table_name
312 )
313 );
314 }
315
316 $sql .= "DROP TABLE IF EXISTS `{$table_name}`;\n";
317 $sql .= $create[1] . ";\n\n";
318
319 // Get row count first
320 $row_count = (int) $this->wpdb->get_var(
321 $this->wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table_name )
322 );
323
324 if ( $row_count > 0 ) {
325 // Export in batches to avoid memory issues
326 $batch_size = 500;
327 $offset = 0;
328
329 // Get column names for INSERT statement
330 $columns = $this->wpdb->get_results(
331 $this->wpdb->prepare( 'SHOW COLUMNS FROM %i', $table_name ),
332 ARRAY_A
333 );
334 $col_names = array_map(
335 function ( $col ) {
336 return '`' . $col['Field'] . '`';
337 },
338 $columns
339 );
340
341 $sql .= 'LOCK TABLES `' . $table_name . "` WRITE;\n";
342
343 while ( $offset < $row_count ) {
344 $rows = $this->wpdb->get_results(
345 $this->wpdb->prepare(
346 'SELECT * FROM %i LIMIT %d OFFSET %d',
347 $table_name,
348 $batch_size,
349 $offset
350 ),
351 ARRAY_N
352 );
353
354 if ( ! empty( $rows ) ) {
355 $sql .= 'INSERT INTO `' . $table_name . '` (' . implode( ', ', $col_names ) . ") VALUES\n";
356
357 $values = array();
358 foreach ( $rows as $row ) {
359 $escaped = array_map(
360 function ( $value ) {
361 if ( null === $value ) {
362 return 'NULL';
363 }
364 return "'" . esc_sql( $value ) . "'";
365 },
366 $row
367 );
368 $values[] = '(' . implode( ', ', $escaped ) . ')';
369 }
370
371 $sql .= implode( ",\n", $values ) . ";\n";
372 }
373
374 $offset += $batch_size;
375 }
376
377 $sql .= "UNLOCK TABLES;\n";
378 }
379 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
380
381 $sql .= "\n";
382
383 return $sql;
384 }
385
386 /**
387 * Get list of valid table names for this site
388 *
389 * @return array Full table names.
390 */
391 private function get_valid_table_names() {
392 $prefix = $this->wpdb->prefix;
393
394 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare().
395 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
396 $results = $this->wpdb->get_col(
397 $this->wpdb->prepare(
398 'SHOW TABLES LIKE %s',
399 $this->wpdb->esc_like( $prefix ) . '%'
400 )
401 );
402 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
403
404 return $results ? $results : array();
405 }
406
407 /**
408 * Format byte size to human readable
409 *
410 * @param int $bytes Size in bytes.
411 * @return string Formatted size.
412 */
413 private function format_size( $bytes ) {
414 $units = array( 'B', 'KB', 'MB', 'GB' );
415 $bytes = max( $bytes, 0 );
416 $pow = floor( ( $bytes ? log( $bytes ) : 0 ) / log( 1024 ) );
417 $pow = min( $pow, count( $units ) - 1 );
418 $bytes /= pow( 1024, $pow );
419
420 return round( $bytes, 2 ) . ' ' . $units[ $pow ];
421 }
422
423 /**
424 * Sort callback by table name
425 *
426 * @param array $a First table.
427 * @param array $b Second table.
428 * @return int
429 */
430 private function sort_by_name( $a, $b ) {
431 return strcmp( $a['name'], $b['name'] );
432 }
433 }