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-backup-manager.php

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

483 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Backup Manager Class
4 *
5 * Handles backup and restoration of critical files. Backups are stored in
6 * private database options (autoload off), never as files under the web root,
7 * so a copy of wp-config.php or .htaccess can never be served over HTTP.
8 *
9 * @package Vigilante
10 */
11
12 // Prevent direct access
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Class Vigilante_Backup_Manager
19 *
20 * Manages file backups for security modifications.
21 */
22 class Vigilante_Backup_Manager {
23
24 /**
25 * Legacy on-disk backup directory (kept only to clean it up on upgrade).
26 *
27 * @var string
28 */
29 private $backup_dir;
30
31 /**
32 * Maximum number of backups to keep
33 *
34 * @var int
35 */
36 private $max_backups = 5;
37
38 /**
39 * Files to backup
40 *
41 * @var array
42 */
43 private $backup_files = array();
44
45 /**
46 * Constructor
47 */
48 public function __construct() {
49 $this->backup_dir = VIGILANTE_BACKUP_DIR;
50 $this->setup_backup_files();
51 }
52
53 /**
54 * Setup list of files to backup
55 */
56 private function setup_backup_files() {
57 $this->backup_files = array(
58 'htaccess' => array(
59 'source' => ABSPATH . '.htaccess',
60 'name' => 'htaccess',
61 ),
62 'wpconfig' => array(
63 'source' => ABSPATH . 'wp-config.php',
64 'name' => 'wpconfig',
65 ),
66 'robots' => array(
67 'source' => ABSPATH . 'robots.txt',
68 'name' => 'robots',
69 ),
70 );
71 }
72
73 /**
74 * Create backups of all important files
75 *
76 * The content is stored in the database, never copied to a file under the
77 * web root.
78 *
79 * @return true|WP_Error True on success, WP_Error on failure.
80 */
81 public function create_backups() {
82 $timestamp = gmdate( 'Y-m-d_H-i-s' );
83 $backup_info = array();
84 $errors = array();
85
86 foreach ( $this->backup_files as $key => $file ) {
87 if ( file_exists( $file['source'] ) ) {
88 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents -- reading a known local config file to store it in the DB, not a filesystem op on user input.
89 $content = file_get_contents( $file['source'] );
90
91 if ( false !== $content ) {
92 $backup_info[ $key ] = array(
93 'content' => $content,
94 'hash' => md5( $content ),
95 'size' => strlen( $content ),
96 'exists' => true,
97 'time' => time(),
98 );
99 } else {
100 $errors[] = sprintf(
101 /* translators: %s: File name */
102 __( 'Failed to backup %s', 'vigilante' ),
103 basename( $file['source'] )
104 );
105 }
106 } else {
107 // Mark as non-existent (important for restoration).
108 $backup_info[ $key ] = array(
109 'content' => '',
110 'exists' => false,
111 'time' => time(),
112 );
113 }
114 }
115
116 if ( ! empty( $errors ) ) {
117 return new WP_Error( 'backup_partial', implode( ', ', $errors ) );
118 }
119
120 // Store metadata + content in non-autoloaded options (may be large and
121 // is only needed on demand).
122 $backup_info['timestamp'] = $timestamp;
123 update_option( 'vigilante_backup_timestamp', $timestamp, false );
124 update_option( 'vigilante_backup_info_' . $timestamp, $backup_info, false );
125
126 $this->cleanup_old_backups();
127
128 return true;
129 }
130
131 /**
132 * Restore files from backup
133 *
134 * @param string $timestamp Optional specific timestamp to restore.
135 * @return true|WP_Error
136 */
137 public function restore_backups( $timestamp = '' ) {
138 if ( empty( $timestamp ) ) {
139 $timestamp = get_option( 'vigilante_backup_timestamp' );
140 }
141
142 if ( empty( $timestamp ) ) {
143 return new WP_Error(
144 'no_backup',
145 __( 'No backup found to restore.', 'vigilante' )
146 );
147 }
148
149 $backup_info = get_option( 'vigilante_backup_info_' . $timestamp );
150
151 if ( empty( $backup_info ) ) {
152 return new WP_Error(
153 'backup_info_missing',
154 __( 'Backup information not found.', 'vigilante' )
155 );
156 }
157
158 $errors = array();
159
160 foreach ( $this->backup_files as $key => $file ) {
161 if ( ! isset( $backup_info[ $key ] ) ) {
162 continue;
163 }
164
165 $info = $backup_info[ $key ];
166
167 // If the file did not exist originally, delete it.
168 if ( isset( $info['exists'] ) && false === $info['exists'] ) {
169 if ( file_exists( $file['source'] ) ) {
170 wp_delete_file( $file['source'] );
171 }
172 continue;
173 }
174
175 if ( ! isset( $info['content'] ) || '' === $info['content'] ) {
176 continue;
177 }
178
179 // Verify integrity against the stored hash.
180 if ( isset( $info['hash'] ) && md5( $info['content'] ) !== $info['hash'] ) {
181 $errors[] = sprintf(
182 /* translators: %s: File name */
183 __( 'Backup integrity check failed for %s', 'vigilante' ),
184 $file['name']
185 );
186 continue;
187 }
188
189 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- restoring a known local config file from the DB backup.
190 if ( false === file_put_contents( $file['source'], $info['content'] ) ) {
191 $errors[] = sprintf(
192 /* translators: %s: File name */
193 __( 'Failed to restore %s', 'vigilante' ),
194 basename( $file['source'] )
195 );
196 }
197 }
198
199 if ( ! empty( $errors ) ) {
200 return new WP_Error( 'restore_partial', implode( ', ', $errors ) );
201 }
202
203 return true;
204 }
205
206 /**
207 * Cleanup old backups keeping only the most recent
208 */
209 private function cleanup_old_backups() {
210 $settings = new Vigilante_Settings();
211 $backup_settings = $settings->get_section( 'backup' );
212 // The default is stored as keep_backups. Until 2.9.9 this read max_backups,
213 // a key nothing ever wrote, so the configured value was ignored and the
214 // hardcoded 5 always won.
215 $this->max_backups = isset( $backup_settings['keep_backups'] ) ? absint( $backup_settings['keep_backups'] ) : 5;
216
217 global $wpdb;
218
219 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off maintenance scan of our own option names.
220 $backup_options = $wpdb->get_col(
221 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'vigilante_backup_info_%' ORDER BY option_name DESC"
222 );
223
224 if ( count( $backup_options ) > $this->max_backups ) {
225 $to_delete = array_slice( $backup_options, $this->max_backups );
226
227 foreach ( $to_delete as $option_name ) {
228 $timestamp = str_replace( 'vigilante_backup_info_', '', $option_name );
229 $this->delete_backup( $timestamp );
230 }
231 }
232 }
233
234 /**
235 * Delete a specific backup
236 *
237 * @param string $timestamp Backup timestamp.
238 * @return bool
239 */
240 public function delete_backup( $timestamp ) {
241 delete_option( 'vigilante_backup_info_' . $timestamp );
242
243 $current_timestamp = get_option( 'vigilante_backup_timestamp' );
244 if ( $current_timestamp === $timestamp ) {
245 delete_option( 'vigilante_backup_timestamp' );
246 }
247
248 return true;
249 }
250
251 /**
252 * Get list of available backups
253 *
254 * @return array
255 */
256 public function get_available_backups() {
257 global $wpdb;
258
259 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- listing our own option names.
260 $backup_options = $wpdb->get_col(
261 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'vigilante_backup_info_%' ORDER BY option_name DESC"
262 );
263
264 $backups = array();
265
266 foreach ( $backup_options as $option_name ) {
267 $timestamp = str_replace( 'vigilante_backup_info_', '', $option_name );
268 $info = get_option( $option_name );
269
270 if ( ! empty( $info ) ) {
271 $backups[] = array(
272 'timestamp' => $timestamp,
273 'date' => date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( str_replace( '_', ' ', $timestamp ) ) ),
274 'files' => count(
275 array_filter(
276 $info,
277 function ( $item ) {
278 return is_array( $item ) && ! empty( $item['exists'] );
279 }
280 )
281 ),
282 );
283 }
284 }
285
286 return $backups;
287 }
288
289 /**
290 * Get the legacy on-disk backup directory path.
291 *
292 * Backups no longer live there; this is used to clean up files left by
293 * older versions.
294 *
295 * @return string
296 */
297 public function get_backup_dir() {
298 return $this->backup_dir;
299 }
300
301 /**
302 * Check if backups exist
303 *
304 * @return bool
305 */
306 public function has_backups() {
307 $timestamp = get_option( 'vigilante_backup_timestamp' );
308 return ! empty( $timestamp );
309 }
310
311 /**
312 * Get last backup timestamp
313 *
314 * @return string|false
315 */
316 public function get_last_backup_timestamp() {
317 return get_option( 'vigilante_backup_timestamp' );
318 }
319
320 /**
321 * Build a ZIP with the current config files and stream it to the browser.
322 *
323 * Used by the "Create Backup" tool: instead of leaving files under the web
324 * root, it hands the admin a downloadable archive of wp-config.php and
325 * .htaccess (and robots.txt if present). The temp ZIP is removed right after.
326 *
327 * @return void|WP_Error WP_Error on failure; on success it streams and exits.
328 */
329 public function stream_files_zip() {
330 if ( ! class_exists( 'ZipArchive' ) ) {
331 return new WP_Error( 'zip_unavailable', __( 'ZipArchive extension is not available on this server.', 'vigilante' ) );
332 }
333
334 $upload_dir = wp_upload_dir();
335 $temp_dir = trailingslashit( $upload_dir['basedir'] ) . 'vigilante-temp/';
336 if ( ! wp_mkdir_p( $temp_dir ) ) {
337 return new WP_Error( 'dir_error', __( 'Cannot create temporary directory.', 'vigilante' ) );
338 }
339 if ( ! file_exists( $temp_dir . '.htaccess' ) ) {
340 file_put_contents( $temp_dir . '.htaccess', "Deny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- protective deny rule, not user input.
341 }
342 if ( ! file_exists( $temp_dir . 'index.php' ) ) {
343 file_put_contents( $temp_dir . 'index.php', "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- silence-is-golden index, not user input.
344 }
345
346 $token = wp_generate_password( 20, false );
347 $zip_name = 'vigilant-config-backup-' . gmdate( 'Y-m-d-His' ) . '-' . $token . '.zip';
348 $zip_path = $temp_dir . $zip_name;
349
350 $zip = new ZipArchive();
351 if ( true !== $zip->open( $zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
352 return new WP_Error( 'zip_create_error', __( 'Failed to create ZIP file.', 'vigilante' ) );
353 }
354
355 $added = 0;
356 foreach ( $this->backup_files as $file ) {
357 if ( file_exists( $file['source'] ) ) {
358 $zip->addFile( $file['source'], basename( $file['source'] ) );
359 $added++;
360 }
361 }
362 $zip->close();
363
364 if ( 0 === $added || ! file_exists( $zip_path ) ) {
365 return new WP_Error( 'zip_empty', __( 'No configuration files were found to back up.', 'vigilante' ) );
366 }
367
368 while ( ob_get_level() ) {
369 ob_end_clean();
370 }
371 nocache_headers();
372 header( 'Content-Type: application/zip' );
373 header( 'Content-Disposition: attachment; filename="' . $zip_name . '"' );
374 header( 'Content-Length: ' . filesize( $zip_path ) );
375 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a freshly built archive to the browser.
376 readfile( $zip_path );
377 wp_delete_file( $zip_path );
378 exit;
379 }
380
381 /**
382 * Verify backup integrity
383 *
384 * @param string $timestamp Backup timestamp.
385 * @return array Verification results.
386 */
387 public function verify_backup( $timestamp ) {
388 $backup_info = get_option( 'vigilante_backup_info_' . $timestamp );
389
390 if ( empty( $backup_info ) ) {
391 return array(
392 'valid' => false,
393 'errors' => array( __( 'Backup information not found.', 'vigilante' ) ),
394 );
395 }
396
397 $results = array(
398 'valid' => true,
399 'errors' => array(),
400 'files' => array(),
401 );
402
403 foreach ( $this->backup_files as $key => $file ) {
404 if ( ! isset( $backup_info[ $key ] ) ) {
405 continue;
406 }
407
408 $info = $backup_info[ $key ];
409
410 // Skip non-existent files.
411 if ( isset( $info['exists'] ) && false === $info['exists'] ) {
412 $results['files'][ $key ] = array(
413 'status' => 'skipped',
414 'reason' => __( 'File did not exist', 'vigilante' ),
415 );
416 continue;
417 }
418
419 if ( ! isset( $info['content'] ) ) {
420 $results['valid'] = false;
421 $results['errors'][] = sprintf(
422 /* translators: %s: File name */
423 __( 'Backup content missing: %s', 'vigilante' ),
424 $file['name']
425 );
426 $results['files'][ $key ] = array( 'status' => 'missing' );
427 continue;
428 }
429
430 // Verify hash.
431 if ( isset( $info['hash'] ) && md5( $info['content'] ) !== $info['hash'] ) {
432 $results['valid'] = false;
433 $results['errors'][] = sprintf(
434 /* translators: %s: File name */
435 __( 'Backup corrupted: %s', 'vigilante' ),
436 $file['name']
437 );
438 $results['files'][ $key ] = array( 'status' => 'corrupted' );
439 continue;
440 }
441
442 $results['files'][ $key ] = array(
443 'status' => 'valid',
444 'size' => isset( $info['size'] ) ? (int) $info['size'] : strlen( $info['content'] ),
445 );
446 }
447
448 return $results;
449 }
450
451 /**
452 * Remove backup files written under the web root by versions before 2.7.0.
453 *
454 * Config backups now live in the database, so the legacy on-disk directory
455 * and any leftover database dumps are deleted. Best-effort.
456 *
457 * @return void
458 */
459 public static function cleanup_legacy_files() {
460 global $wp_filesystem;
461 if ( ! function_exists( 'WP_Filesystem' ) ) {
462 require_once ABSPATH . 'wp-admin/includes/file.php';
463 }
464 WP_Filesystem();
465 if ( ! $wp_filesystem ) {
466 return;
467 }
468
469 $dir = defined( 'VIGILANTE_BACKUP_DIR' ) ? VIGILANTE_BACKUP_DIR : WP_CONTENT_DIR . '/vigilante-backups/';
470 if ( $wp_filesystem->is_dir( $dir ) ) {
471 $wp_filesystem->rmdir( $dir, true );
472 }
473
474 $upload_dir = wp_upload_dir();
475 if ( ! empty( $upload_dir['basedir'] ) ) {
476 $temp = trailingslashit( $upload_dir['basedir'] ) . 'vigilante-temp/';
477 if ( $wp_filesystem->is_dir( $temp ) ) {
478 $wp_filesystem->rmdir( $temp, true );
479 }
480 }
481 }
482 }
483