287 lines
7.4 KiB
PHP
287 lines
7.4 KiB
PHP
<?php
|
|
/**
|
|
* Automatic GeoIP database downloader.
|
|
*
|
|
* @package Robotstxt_2FA
|
|
*/
|
|
|
|
namespace Robotstxt\TwoFA;
|
|
|
|
use Robotstxt\TwoFA\User\Two_Factor_Config;
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Downloads and refreshes the MaxMind country database from the free
|
|
* "robotstxt geo ip" service, driven by a daily WordPress cron event.
|
|
*
|
|
* @since 1.5.3
|
|
*/
|
|
class GeoIP_Updater {
|
|
|
|
/**
|
|
* Source URL for the free robotstxt geo ip country database.
|
|
*/
|
|
private const DOWNLOAD_URL = 'https://ip.robotstxt.es/download/country.mmdb';
|
|
|
|
/**
|
|
* Cron hook used to refresh the database daily.
|
|
*/
|
|
private const CRON_HOOK = 'robotstxt_2fa_geoip_update';
|
|
|
|
/**
|
|
* Option name storing the outcome of the last download attempt.
|
|
*/
|
|
private const STATUS_OPTION = 'robotstxt_2fa_geoip_status';
|
|
|
|
/**
|
|
* Nonce action for the manual "Update now" button.
|
|
*/
|
|
private const UPDATE_NONCE_ACTION = 'robotstxt_2fa_geoip_update';
|
|
|
|
/**
|
|
* Nonce field name for the manual "Update now" button.
|
|
*/
|
|
private const UPDATE_NONCE_FIELD = 'robotstxt_2fa_geoip_update_nonce';
|
|
|
|
/**
|
|
* Binary marker present in every MaxMind DB file metadata section.
|
|
*/
|
|
private const MAXMIND_MARKER = "\xab\xcd\xefMaxMind.com";
|
|
|
|
/**
|
|
* Global two-factor configuration.
|
|
*
|
|
* @var Two_Factor_Config
|
|
*/
|
|
private Two_Factor_Config $config;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param Two_Factor_Config $config Global two-factor configuration.
|
|
*/
|
|
public function __construct( Two_Factor_Config $config ) {
|
|
$this->config = $config;
|
|
}
|
|
|
|
/**
|
|
* Register WordPress hooks.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function register_hooks(): void {
|
|
add_action( self::CRON_HOOK, array( $this, 'run_cron_update' ) );
|
|
add_action( 'init', array( $this, 'maybe_schedule_update' ) );
|
|
add_action( 'admin_post_robotstxt_2fa_geoip_update_now', array( $this, 'handle_update_now' ) );
|
|
}
|
|
|
|
/**
|
|
* Resolve the absolute path where the auto-downloaded database is stored.
|
|
*
|
|
* @return string Empty string when the uploads directory is unavailable.
|
|
*/
|
|
public static function get_database_path(): string {
|
|
$uploads = wp_upload_dir();
|
|
|
|
if ( empty( $uploads['basedir'] ) ) {
|
|
return '';
|
|
}
|
|
|
|
return trailingslashit( $uploads['basedir'] ) . 'robotstxt-2fa/country.mmdb';
|
|
}
|
|
|
|
/**
|
|
* Schedule or clear the daily update cron based on the auto-download setting.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function maybe_schedule_update(): void {
|
|
if ( $this->config->get_geoip_auto_download() ) {
|
|
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
|
|
wp_schedule_event( time(), 'daily', self::CRON_HOOK );
|
|
}
|
|
} elseif ( wp_next_scheduled( self::CRON_HOOK ) ) {
|
|
wp_clear_scheduled_hook( self::CRON_HOOK );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cron callback: refresh the database.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function run_cron_update(): void {
|
|
$this->download_database();
|
|
}
|
|
|
|
/**
|
|
* Download the country database, validate it, and store it atomically.
|
|
*
|
|
* @return bool True on success, false on failure (status stored for UI).
|
|
*/
|
|
public function download_database(): bool {
|
|
$path = self::get_database_path();
|
|
|
|
if ( '' === $path ) {
|
|
$this->store_status( false, __( 'The uploads directory is not writable.', 'robotstxt-2fa' ) );
|
|
return false;
|
|
}
|
|
|
|
$directory = dirname( $path );
|
|
|
|
if ( ! wp_mkdir_p( $directory ) ) {
|
|
$this->store_status( false, __( 'Could not create the storage directory for the GeoIP database.', 'robotstxt-2fa' ) );
|
|
return false;
|
|
}
|
|
|
|
$response = wp_remote_get(
|
|
self::DOWNLOAD_URL,
|
|
array(
|
|
'timeout' => 60,
|
|
'redirection' => 5,
|
|
)
|
|
);
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
$this->store_status( false, $response->get_error_message() );
|
|
return false;
|
|
}
|
|
|
|
$code = (int) wp_remote_retrieve_response_code( $response );
|
|
$body = wp_remote_retrieve_body( $response );
|
|
|
|
if ( 200 !== $code || '' === $body ) {
|
|
/* translators: %d: HTTP response code. */
|
|
$this->store_status( false, sprintf( __( 'Download failed (HTTP %d).', 'robotstxt-2fa' ), $code ) );
|
|
return false;
|
|
}
|
|
|
|
if ( false === strpos( $body, self::MAXMIND_MARKER ) ) {
|
|
$this->store_status( false, __( 'The downloaded file is not a valid MaxMind database.', 'robotstxt-2fa' ) );
|
|
return false;
|
|
}
|
|
|
|
$temp = $path . '.tmp';
|
|
$written = file_put_contents( $temp, $body ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Cron download; WP_Filesystem cannot run unattended.
|
|
|
|
if ( false === $written || 0 === $written ) {
|
|
$this->store_status( false, __( 'Could not write the GeoIP database file.', 'robotstxt-2fa' ) );
|
|
return false;
|
|
}
|
|
|
|
if ( ! rename( $temp, $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replace within the uploads directory.
|
|
unlink( $temp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Cleanup of the temp file on failure.
|
|
$this->store_status( false, __( 'Could not finalize the GeoIP database file.', 'robotstxt-2fa' ) );
|
|
return false;
|
|
}
|
|
|
|
$this->store_status( true, '' );
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Persist the outcome of the latest download attempt.
|
|
*
|
|
* @param bool $success Whether the download succeeded.
|
|
* @param string $message Error or status message.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function store_status( bool $success, string $message ): void {
|
|
update_option(
|
|
self::STATUS_OPTION,
|
|
array(
|
|
'success' => $success,
|
|
'message' => $message,
|
|
'timestamp' => time(),
|
|
),
|
|
false
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Retrieve the last download status.
|
|
*
|
|
* @return array{success: bool, message: string, timestamp: int}
|
|
*/
|
|
public static function get_status(): array {
|
|
$raw = get_option( self::STATUS_OPTION, array() );
|
|
|
|
if ( ! is_array( $raw ) ) {
|
|
return array(
|
|
'success' => false,
|
|
'message' => '',
|
|
'timestamp' => 0,
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'success' => isset( $raw['success'] ) ? (bool) $raw['success'] : false,
|
|
'message' => isset( $raw['message'] ) && is_string( $raw['message'] ) ? $raw['message'] : '',
|
|
'timestamp' => isset( $raw['timestamp'] ) && is_numeric( $raw['timestamp'] ) ? (int) $raw['timestamp'] : 0,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build the URL for the manual "Update now" button.
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function get_update_now_url(): string {
|
|
return wp_nonce_url(
|
|
admin_url( 'admin-post.php?action=robotstxt_2fa_geoip_update_now' ),
|
|
self::UPDATE_NONCE_ACTION,
|
|
self::UPDATE_NONCE_FIELD
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle the manual "Update now" request from the settings page.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function handle_update_now(): void {
|
|
if ( ! current_user_can( 'manage_options' ) ) {
|
|
wp_die( esc_html__( 'Sorry, you are not allowed to do this.', 'robotstxt-2fa' ), 403 );
|
|
}
|
|
|
|
check_admin_referer( self::UPDATE_NONCE_ACTION, self::UPDATE_NONCE_FIELD );
|
|
|
|
$this->download_database();
|
|
|
|
$redirect = add_query_arg(
|
|
'geoip_updated',
|
|
'1',
|
|
admin_url( 'admin.php?page=robotstxt-2fa-settings' )
|
|
);
|
|
|
|
wp_safe_redirect( $redirect );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Remove the cron event and the downloaded database file.
|
|
*
|
|
* Called from uninstall.php.
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function cleanup(): void {
|
|
$timestamp = wp_next_scheduled( self::CRON_HOOK );
|
|
|
|
if ( $timestamp ) {
|
|
wp_clear_scheduled_hook( self::CRON_HOOK );
|
|
}
|
|
|
|
delete_option( self::STATUS_OPTION );
|
|
|
|
$path = self::get_database_path();
|
|
|
|
if ( '' !== $path && file_exists( $path ) && is_file( $path ) ) {
|
|
unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removing our own artifact on uninstall.
|
|
}
|
|
}
|
|
}
|