PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.5
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.9.5, at includes/class-backup-manager.php

480 lines 16.0 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 $this->max_backups = isset( $backup_settings['max_backups'] ) ? absint( $backup_settings['max_backups'] ) : 5;
213
214 global $wpdb;
215
216 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off maintenance scan of our own option names.
217 $backup_options = $wpdb->get_col(
218 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'vigilante_backup_info_%' ORDER BY option_name DESC"
219 );
220
221 if ( count( $backup_options ) > $this->max_backups ) {
222 $to_delete = array_slice( $backup_options, $this->max_backups );
223
224 foreach ( $to_delete as $option_name ) {
225 $timestamp = str_replace( 'vigilante_backup_info_', '', $option_name );
226 $this->delete_backup( $timestamp );
227 }
228 }
229 }
230
231 /**
232 * Delete a specific backup
233 *
234 * @param string $timestamp Backup timestamp.
235 * @return bool
236 */
237 public function delete_backup( $timestamp ) {
238 delete_option( 'vigilante_backup_info_' . $timestamp );
239
240 $current_timestamp = get_option( 'vigilante_backup_timestamp' );
241 if ( $current_timestamp === $timestamp ) {
242 delete_option( 'vigilante_backup_timestamp' );
243 }
244
245 return true;
246 }
247
248 /**
249 * Get list of available backups
250 *
251 * @return array
252 */
253 public function get_available_backups() {
254 global $wpdb;
255
256 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- listing our own option names.
257 $backup_options = $wpdb->get_col(
258 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'vigilante_backup_info_%' ORDER BY option_name DESC"
259 );
260
261 $backups = array();
262
263 foreach ( $backup_options as $option_name ) {
264 $timestamp = str_replace( 'vigilante_backup_info_', '', $option_name );
265 $info = get_option( $option_name );
266
267 if ( ! empty( $info ) ) {
268 $backups[] = array(
269 'timestamp' => $timestamp,
270 'date' => date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( str_replace( '_', ' ', $timestamp ) ) ),
271 'files' => count(
272 array_filter(
273 $info,
274 function ( $item ) {
275 return is_array( $item ) && ! empty( $item['exists'] );
276 }
277 )
278 ),
279 );
280 }
281 }
282
283 return $backups;
284 }
285
286 /**
287 * Get the legacy on-disk backup directory path.
288 *
289 * Backups no longer live there; this is used to clean up files left by
290 * older versions.
291 *
292 * @return string
293 */
294 public function get_backup_dir() {
295 return $this->backup_dir;
296 }
297
298 /**
299 * Check if backups exist
300 *
301 * @return bool
302 */
303 public function has_backups() {
304 $timestamp = get_option( 'vigilante_backup_timestamp' );
305 return ! empty( $timestamp );
306 }
307
308 /**
309 * Get last backup timestamp
310 *
311 * @return string|false
312 */
313 public function get_last_backup_timestamp() {
314 return get_option( 'vigilante_backup_timestamp' );
315 }
316
317 /**
318 * Build a ZIP with the current config files and stream it to the browser.
319 *
320 * Used by the "Create Backup" tool: instead of leaving files under the web
321 * root, it hands the admin a downloadable archive of wp-config.php and
322 * .htaccess (and robots.txt if present). The temp ZIP is removed right after.
323 *
324 * @return void|WP_Error WP_Error on failure; on success it streams and exits.
325 */
326 public function stream_files_zip() {
327 if ( ! class_exists( 'ZipArchive' ) ) {
328 return new WP_Error( 'zip_unavailable', __( 'ZipArchive extension is not available on this server.', 'vigilante' ) );
329 }
330
331 $upload_dir = wp_upload_dir();
332 $temp_dir = trailingslashit( $upload_dir['basedir'] ) . 'vigilante-temp/';
333 if ( ! wp_mkdir_p( $temp_dir ) ) {
334 return new WP_Error( 'dir_error', __( 'Cannot create temporary directory.', 'vigilante' ) );
335 }
336 if ( ! file_exists( $temp_dir . '.htaccess' ) ) {
337 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.
338 }
339 if ( ! file_exists( $temp_dir . 'index.php' ) ) {
340 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.
341 }
342
343 $token = wp_generate_password( 20, false );
344 $zip_name = 'vigilant-config-backup-' . gmdate( 'Y-m-d-His' ) . '-' . $token . '.zip';
345 $zip_path = $temp_dir . $zip_name;
346
347 $zip = new ZipArchive();
348 if ( true !== $zip->open( $zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
349 return new WP_Error( 'zip_create_error', __( 'Failed to create ZIP file.', 'vigilante' ) );
350 }
351
352 $added = 0;
353 foreach ( $this->backup_files as $file ) {
354 if ( file_exists( $file['source'] ) ) {
355 $zip->addFile( $file['source'], basename( $file['source'] ) );
356 $added++;
357 }
358 }
359 $zip->close();
360
361 if ( 0 === $added || ! file_exists( $zip_path ) ) {
362 return new WP_Error( 'zip_empty', __( 'No configuration files were found to back up.', 'vigilante' ) );
363 }
364
365 while ( ob_get_level() ) {
366 ob_end_clean();
367 }
368 nocache_headers();
369 header( 'Content-Type: application/zip' );
370 header( 'Content-Disposition: attachment; filename="' . $zip_name . '"' );
371 header( 'Content-Length: ' . filesize( $zip_path ) );
372 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a freshly built archive to the browser.
373 readfile( $zip_path );
374 wp_delete_file( $zip_path );
375 exit;
376 }
377
378 /**
379 * Verify backup integrity
380 *
381 * @param string $timestamp Backup timestamp.
382 * @return array Verification results.
383 */
384 public function verify_backup( $timestamp ) {
385 $backup_info = get_option( 'vigilante_backup_info_' . $timestamp );
386
387 if ( empty( $backup_info ) ) {
388 return array(
389 'valid' => false,
390 'errors' => array( __( 'Backup information not found.', 'vigilante' ) ),
391 );
392 }
393
394 $results = array(
395 'valid' => true,
396 'errors' => array(),
397 'files' => array(),
398 );
399
400 foreach ( $this->backup_files as $key => $file ) {
401 if ( ! isset( $backup_info[ $key ] ) ) {
402 continue;
403 }
404
405 $info = $backup_info[ $key ];
406
407 // Skip non-existent files.
408 if ( isset( $info['exists'] ) && false === $info['exists'] ) {
409 $results['files'][ $key ] = array(
410 'status' => 'skipped',
411 'reason' => __( 'File did not exist', 'vigilante' ),
412 );
413 continue;
414 }
415
416 if ( ! isset( $info['content'] ) ) {
417 $results['valid'] = false;
418 $results['errors'][] = sprintf(
419 /* translators: %s: File name */
420 __( 'Backup content missing: %s', 'vigilante' ),
421 $file['name']
422 );
423 $results['files'][ $key ] = array( 'status' => 'missing' );
424 continue;
425 }
426
427 // Verify hash.
428 if ( isset( $info['hash'] ) && md5( $info['content'] ) !== $info['hash'] ) {
429 $results['valid'] = false;
430 $results['errors'][] = sprintf(
431 /* translators: %s: File name */
432 __( 'Backup corrupted: %s', 'vigilante' ),
433 $file['name']
434 );
435 $results['files'][ $key ] = array( 'status' => 'corrupted' );
436 continue;
437 }
438
439 $results['files'][ $key ] = array(
440 'status' => 'valid',
441 'size' => isset( $info['size'] ) ? (int) $info['size'] : strlen( $info['content'] ),
442 );
443 }
444
445 return $results;
446 }
447
448 /**
449 * Remove backup files written under the web root by versions before 2.7.0.
450 *
451 * Config backups now live in the database, so the legacy on-disk directory
452 * and any leftover database dumps are deleted. Best-effort.
453 *
454 * @return void
455 */
456 public static function cleanup_legacy_files() {
457 global $wp_filesystem;
458 if ( ! function_exists( 'WP_Filesystem' ) ) {
459 require_once ABSPATH . 'wp-admin/includes/file.php';
460 }
461 WP_Filesystem();
462 if ( ! $wp_filesystem ) {
463 return;
464 }
465
466 $dir = defined( 'VIGILANTE_BACKUP_DIR' ) ? VIGILANTE_BACKUP_DIR : WP_CONTENT_DIR . '/vigilante-backups/';
467 if ( $wp_filesystem->is_dir( $dir ) ) {
468 $wp_filesystem->rmdir( $dir, true );
469 }
470
471 $upload_dir = wp_upload_dir();
472 if ( ! empty( $upload_dir['basedir'] ) ) {
473 $temp = trailingslashit( $upload_dir['basedir'] ) . 'vigilante-temp/';
474 if ( $wp_filesystem->is_dir( $temp ) ) {
475 $wp_filesystem->rmdir( $temp, true );
476 }
477 }
478 }
479 }
480