Files
argus-wp-defence/includes/class-argus-backup.php
T
root df0f2fccb8 ARGUS WordPress Defence 1.0.0 — first production release
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.
2026-08-09 13:40:16 +00:00

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 );
}
}