Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions.
292 lines
11 KiB
PHP
292 lines
11 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_Backup {
|
|
|
|
const TYPE_MANUAL = 'manual';
|
|
const TYPE_SCHEDULED = 'scheduled';
|
|
|
|
const STATUS_COMPLETE = 'complete';
|
|
const STATUS_FAILED = 'failed';
|
|
|
|
const LOCKDOWN_STATUS_OPTION = 'argus_wpd_backup_lockdown_status';
|
|
const HTACCESS_MARKER = 'ARGUS WordPress Defence -- backup store, deny all direct access';
|
|
const LAST_SUCCESS_OPTION = 'argus_wpd_backup_last_success';
|
|
|
|
const PER_PAGE = 10;
|
|
|
|
const DB_DUMP_BATCH_SIZE = 500;
|
|
|
|
public static function backup_dir() {
|
|
$uploads = wp_get_upload_dir();
|
|
return trailingslashit( $uploads['basedir'] ?? '' ) . 'argus-wpd-data/backups';
|
|
}
|
|
|
|
public static function ensure_lockdown() {
|
|
$dir = self::backup_dir();
|
|
|
|
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_mkdir', false );
|
|
return false;
|
|
}
|
|
if ( ! is_writable( $dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'not_writable', false );
|
|
return false;
|
|
}
|
|
|
|
$htaccess = trailingslashit( $dir ) . '.htaccess';
|
|
$rule = "# " . self::HTACCESS_MARKER . "\nRequire all denied\n";
|
|
$existing = file_exists( $htaccess ) ? file_get_contents( $htaccess ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
if ( false === $existing || false === strpos( $existing, 'Require all denied' ) ) {
|
|
$written = file_put_contents( $htaccess, $rule ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( false === $written ) {
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_write', false );
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$index = trailingslashit( $dir ) . 'index.php';
|
|
if ( ! file_exists( $index ) ) {
|
|
file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
}
|
|
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'active', false );
|
|
return true;
|
|
}
|
|
|
|
public static function lockdown_status() {
|
|
return get_option( self::LOCKDOWN_STATUS_OPTION, 'unknown' );
|
|
}
|
|
|
|
public static function create( $type = self::TYPE_MANUAL ) {
|
|
if ( ! self::ensure_lockdown() ) {
|
|
return self::record_failure( $type, __( 'Could not prepare the protected backup storage location.', 'argus-wordpress-defence' ) );
|
|
}
|
|
if ( ! class_exists( 'ZipArchive' ) ) {
|
|
return self::record_failure( $type, __( 'The PHP zip extension is not available on this server -- ARGUS cannot create a backup archive without it. Ask your hosting provider to enable it.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$filename = sprintf( 'argus-backup-%s-%s.zip', gmdate( 'Y-m-d-His' ), bin2hex( random_bytes( 4 ) ) );
|
|
$path = trailingslashit( self::backup_dir() ) . $filename;
|
|
|
|
$sql_tmp = trailingslashit( self::backup_dir() ) . 'tmp-' . bin2hex( random_bytes( 4 ) ) . '.sql';
|
|
$dumped = self::dump_database( $sql_tmp );
|
|
if ( ! $dumped ) {
|
|
@unlink( $sql_tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions
|
|
return self::record_failure( $type, __( 'Could not export the database.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$zip = new ZipArchive();
|
|
if ( true !== $zip->open( $path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
|
|
@unlink( $sql_tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions
|
|
return self::record_failure( $type, __( 'Could not create the backup archive.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$zip->addFile( $sql_tmp, 'database.sql' );
|
|
self::add_directory_to_zip( $zip, WP_CONTENT_DIR, 'wp-content', array(
|
|
wp_normalize_path( self::backup_dir() ),
|
|
wp_normalize_path( WP_CONTENT_DIR . '/uploads/argus-wpd-data/html-cache' ),
|
|
) );
|
|
$zip->close();
|
|
@unlink( $sql_tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions
|
|
|
|
if ( ! file_exists( $path ) ) {
|
|
return self::record_failure( $type, __( 'The backup archive could not be written to disk.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
global $wpdb;
|
|
$wpdb->insert(
|
|
Argus_DB::table( 'backups' ),
|
|
array(
|
|
'filename' => $filename,
|
|
'type' => $type,
|
|
'size_bytes' => filesize( $path ),
|
|
'status' => self::STATUS_COMPLETE,
|
|
'created_at' => current_time( 'mysql', true ),
|
|
)
|
|
);
|
|
|
|
update_option( self::LAST_SUCCESS_OPTION, current_time( 'mysql', true ), false );
|
|
Argus_Events::record( 'backup_created', 'info', sprintf( 'Backup created (%s).', $type ), array( 'filename' => $filename, 'type' => $type ) );
|
|
|
|
self::enforce_retention();
|
|
|
|
return array( 'success' => true, 'message' => __( 'Backup created successfully.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
protected static function record_failure( $type, $message ) {
|
|
global $wpdb;
|
|
$wpdb->insert(
|
|
Argus_DB::table( 'backups' ),
|
|
array(
|
|
'filename' => 'failed-' . gmdate( 'Y-m-d-His' ) . '-' . bin2hex( random_bytes( 4 ) ),
|
|
'type' => $type,
|
|
'size_bytes' => 0,
|
|
'status' => self::STATUS_FAILED,
|
|
'error_message' => $message,
|
|
'created_at' => current_time( 'mysql', true ),
|
|
)
|
|
);
|
|
Argus_Events::record( 'backup_failed', 'medium', $message, array( 'type' => $type ) );
|
|
return array( 'success' => false, 'message' => $message );
|
|
}
|
|
|
|
protected static function dump_database( $dest_path ) {
|
|
global $wpdb;
|
|
$handle = fopen( $dest_path, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( ! $handle ) {
|
|
return false;
|
|
}
|
|
|
|
fwrite( $handle, "-- ARGUS Defence database backup\n-- Generated: " . current_time( 'mysql', true ) . " UTC\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
fwrite( $handle, "SET FOREIGN_KEY_CHECKS=0;\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
$tables = $wpdb->get_col( 'SHOW TABLES' ); // phpcs:ignore
|
|
foreach ( $tables as $table ) {
|
|
$create = $wpdb->get_row( "SHOW CREATE TABLE `{$table}`", ARRAY_N ); // phpcs:ignore
|
|
if ( ! $create ) {
|
|
continue;
|
|
}
|
|
fwrite( $handle, "DROP TABLE IF EXISTS `{$table}`;\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
fwrite( $handle, $create[1] . ";\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore
|
|
$offset = 0;
|
|
while ( $offset < $total ) {
|
|
$rows = $wpdb->get_results( "SELECT * FROM `{$table}` LIMIT " . self::DB_DUMP_BATCH_SIZE . " OFFSET {$offset}", ARRAY_A ); // phpcs:ignore
|
|
foreach ( $rows as $row ) {
|
|
$columns = array_map( function ( $c ) { return '`' . $c . '`'; }, array_keys( $row ) );
|
|
$values = array_map( function ( $v ) use ( $wpdb ) {
|
|
return null === $v ? 'NULL' : "'" . esc_sql( $v ) . "'";
|
|
}, array_values( $row ) );
|
|
fwrite( $handle, "INSERT INTO `{$table}` (" . implode( ',', $columns ) . ') VALUES (' . implode( ',', $values ) . ");\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
}
|
|
$offset += self::DB_DUMP_BATCH_SIZE;
|
|
}
|
|
fwrite( $handle, "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
}
|
|
|
|
fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
return true;
|
|
}
|
|
|
|
protected static function add_directory_to_zip( ZipArchive $zip, $source_dir, $zip_root, array $exclude_prefixes ) {
|
|
$source_dir = wp_normalize_path( $source_dir );
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator( $source_dir, FilesystemIterator::SKIP_DOTS ),
|
|
RecursiveIteratorIterator::SELF_FIRST
|
|
);
|
|
|
|
foreach ( $iterator as $item ) {
|
|
$item_path = wp_normalize_path( $item->getPathname() );
|
|
foreach ( $exclude_prefixes as $prefix ) {
|
|
if ( 0 === strpos( $item_path, $prefix ) ) {
|
|
continue 2;
|
|
}
|
|
}
|
|
$relative = $zip_root . '/' . ltrim( str_replace( $source_dir, '', $item_path ), '/' );
|
|
if ( $item->isDir() ) {
|
|
$zip->addEmptyDir( $relative );
|
|
} else {
|
|
$zip->addFile( $item_path, $relative );
|
|
}
|
|
}
|
|
}
|
|
|
|
public static function paginated( $page = 1, $per_page = self::PER_PAGE ) {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'backups' );
|
|
$page = max( 1, (int) $page );
|
|
$offset = ( $page - 1 ) * $per_page;
|
|
|
|
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore
|
|
);
|
|
|
|
return array(
|
|
'rows' => $rows,
|
|
'total' => $total,
|
|
'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
|
|
'page' => $page,
|
|
);
|
|
}
|
|
|
|
public static function get( $id ) {
|
|
global $wpdb;
|
|
return $wpdb->get_row(
|
|
$wpdb->prepare( 'SELECT * FROM ' . Argus_DB::table( 'backups' ) . ' WHERE id = %d', $id ) // phpcs:ignore
|
|
);
|
|
}
|
|
|
|
public static function delete( $id ) {
|
|
$row = self::get( $id );
|
|
if ( ! $row ) {
|
|
return array( 'success' => false, 'message' => __( 'Backup not found.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
$path = trailingslashit( self::backup_dir() ) . $row->filename;
|
|
if ( file_exists( $path ) ) {
|
|
wp_delete_file( $path );
|
|
}
|
|
|
|
global $wpdb;
|
|
$wpdb->delete( Argus_DB::table( 'backups' ), array( 'id' => $id ), array( '%d' ) );
|
|
|
|
Argus_Events::record( 'backup_deleted', 'info', 'Backup deleted.', array( 'filename' => $row->filename ) );
|
|
|
|
return array( 'success' => true, 'message' => __( 'Backup deleted.', 'argus-wordpress-defence' ) );
|
|
}
|
|
|
|
protected static function enforce_retention() {
|
|
$keep = max( 1, (int) Argus_Settings::get( 'backup_retention_count', 5 ) );
|
|
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'backups' );
|
|
$ids = $wpdb->get_col(
|
|
$wpdb->prepare( "SELECT id FROM {$table} WHERE status = %s ORDER BY created_at DESC", self::STATUS_COMPLETE ) // phpcs:ignore
|
|
);
|
|
|
|
foreach ( array_slice( $ids, $keep ) as $id ) {
|
|
self::delete( (int) $id );
|
|
}
|
|
}
|
|
|
|
public static function status() {
|
|
$last_success = get_option( self::LAST_SUCCESS_OPTION, '' );
|
|
$interval_secs = max( 1, (int) Argus_Settings::get( 'backup_interval_hours', 24 ) ) * HOUR_IN_SECONDS;
|
|
|
|
global $wpdb;
|
|
$available = (int) $wpdb->get_var(
|
|
$wpdb->prepare( 'SELECT COUNT(*) FROM ' . Argus_DB::table( 'backups' ) . ' WHERE status = %s', self::STATUS_COMPLETE ) // phpcs:ignore
|
|
);
|
|
|
|
return array(
|
|
'last_backup' => $last_success,
|
|
'next_backup' => ( 'manual' !== Argus_Settings::get( 'backup_schedule_mode', 'automatic' ) && $last_success )
|
|
? gmdate( 'Y-m-d H:i:s', strtotime( $last_success . ' UTC' ) + $interval_secs )
|
|
: '',
|
|
'available' => $available,
|
|
);
|
|
}
|
|
|
|
public static function maybe_run_scheduled() {
|
|
if ( 'manual' === Argus_Settings::get( 'backup_schedule_mode', 'automatic' ) ) {
|
|
return;
|
|
}
|
|
|
|
$last_success = get_option( self::LAST_SUCCESS_OPTION, '' );
|
|
$interval_secs = max( 1, (int) Argus_Settings::get( 'backup_interval_hours', 24 ) ) * HOUR_IN_SECONDS;
|
|
|
|
if ( $last_success && ( strtotime( $last_success . ' UTC' ) + $interval_secs ) > time() ) {
|
|
return;
|
|
}
|
|
|
|
self::create( self::TYPE_SCHEDULED );
|
|
}
|
|
}
|