robotstxt-2fa/includes/class-geoip-updater.php
2026-08-07 14:15:58 +00:00

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.
}
}
}