diff --git a/changelog.txt b/changelog.txt index 9958a04..2cf76c1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,34 @@ == Changelog == += 1.5.0 = + +_Release date: 2026-06-05_ + +**Added** + +* Full audit user table (WP_List_Table) with sortable columns, role/status filters, and CSV export. +* GeoIP country restrictions via optional MaxMind GeoLite2 database: per-country allow list, deny list, always-challenge list. +* Require recent 2FA verification before creating Application Passwords (REST endpoint, 15-min window, filterable). +* WP-CLI `wp 2fa export` — CSV report via stdout. +* `robotstxt_2fa_force_challenge` filter — override frequency skip; used by GeoIP always-challenge. +* `maxmind-db/reader` added as optional production Composer dependency. + +**Fixed** + +* Email digest cron now reschedules correctly when frequency changes (weekly ↔ monthly). + +**Compatibility** + +* WordPress: 6.4 – 7.1 +* PHP: 8.0 – 8.5 + +**Tests** + +* PHP Coding Standards: PHP_CodeSniffer 3.13.5 / WPCS 3.3.0 +* PHPStan: level 9 — 0 errors +* PHPCompatibility: 8.0–8.5 — 0 issues +* PHPUnit: 9.6.34 — 42 tests, 109 assertions + = 1.4.0 = _Release date: 2026-06-05_ diff --git a/includes/admin/class-dashboard-page.php b/includes/admin/class-dashboard-page.php index e6c3732..eb97e11 100644 --- a/includes/admin/class-dashboard-page.php +++ b/includes/admin/class-dashboard-page.php @@ -8,6 +8,8 @@ namespace Robotstxt\TwoFA\Admin; use Robotstxt\TwoFA\Attempts_Log; +use Robotstxt\TwoFA\User\Two_Factor_Config; +use Robotstxt\TwoFA\User\User_Settings_Repository; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -29,13 +31,39 @@ class Dashboard_Page { */ private Attempts_Log $attempts_log; + /** + * Global two-factor configuration. + * + * @var Two_Factor_Config + */ + private Two_Factor_Config $config; + + /** + * User settings repository. + * + * @var User_Settings_Repository + */ + private User_Settings_Repository $repository; + + /** + * Users list table. + * + * @var Users_List_Table + */ + private Users_List_Table $users_table; + /** * Constructor. * - * @param Attempts_Log $attempts_log Failed attempts log instance. + * @param Attempts_Log $attempts_log Failed attempts log instance. + * @param Two_Factor_Config $config Global two-factor configuration. + * @param User_Settings_Repository $repository User settings repository. */ - public function __construct( Attempts_Log $attempts_log ) { + public function __construct( Attempts_Log $attempts_log, Two_Factor_Config $config, User_Settings_Repository $repository ) { $this->attempts_log = $attempts_log; + $this->config = $config; + $this->repository = $repository; + $this->users_table = new Users_List_Table( $config, $repository ); } /** @@ -49,6 +77,7 @@ class Dashboard_Page { add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_filter( 'manage_users_columns', array( $this, 'add_users_column' ) ); add_filter( 'manage_users_custom_column', array( $this, 'render_users_column' ), 10, 3 ); + add_action( 'admin_post_robotstxt_2fa_export_users', array( $this, 'handle_csv_export' ) ); } /** @@ -136,6 +165,22 @@ class Dashboard_Page { +

+ +
+ + users_table->prepare_items(); + $this->users_table->display(); + ?> +
+ +
+ + + +
+

@@ -170,6 +215,100 @@ class Dashboard_Page { -1, + 'fields' => 'all', + ) + ); + $rows = array(); + + foreach ( $users as $user ) { + if ( ! $user instanceof \WP_User ) { + continue; + } + + $settings = $this->repository->get_user_settings( $user->ID ); + $is_enabled = $settings['enabled']; + + $required_methods = $this->config->get_required_methods_for_user( $user ); + $is_forced = ! empty( $required_methods ); + + if ( $is_enabled ) { + $status = 'active'; + } elseif ( $is_forced ) { + $status = 'required'; + } else { + $status = 'optional'; + } + + $last_verified_ts = 0; + $raw_verifications = get_user_meta( $user->ID, 'robotstxt_2fa_last_verifications', true ); + + if ( is_array( $raw_verifications ) ) { + foreach ( $raw_verifications as $method_data ) { + if ( is_array( $method_data ) ) { + foreach ( $method_data as $context_data ) { + if ( is_array( $context_data ) && isset( $context_data['verified_at'] ) && is_int( $context_data['verified_at'] ) ) { + $last_verified_ts = max( $last_verified_ts, $context_data['verified_at'] ); + } + } + } + } + } + + $last_verified_str = $last_verified_ts > 0 ? (string) wp_date( 'Y-m-d H:i', $last_verified_ts ) : ''; + + $unused_codes = 0; + $raw_codes = get_user_meta( $user->ID, 'robotstxt_2fa_recovery_codes', true ); + + if ( is_array( $raw_codes ) && isset( $raw_codes['codes'] ) && is_array( $raw_codes['codes'] ) ) { + foreach ( $raw_codes['codes'] as $code_entry ) { + if ( is_array( $code_entry ) && empty( $code_entry['used_at'] ) ) { + ++$unused_codes; + } + } + } + + $rows[] = array( + 'ID' => (string) $user->ID, + 'user_login' => $user->user_login, + 'user_email' => $user->user_email, + 'roles' => implode( '|', $user->roles ), + 'status' => $status, + 'methods' => implode( '|', $settings['methods'] ), + 'last_verified' => $last_verified_str, + 'unused_codes' => (string) $unused_codes, + ); + } + + header( 'Content-Type: text/csv' ); + header( 'Content-Disposition: attachment; filename="2fa-users-' . gmdate( 'Y-m-d' ) . '.csv"' ); + + $headers = array( 'ID', 'Login', 'Email', 'Roles', 'Status', 'Methods', 'Last Verified', 'Unused Codes' ); + echo implode( ',', array_map( 'addslashes', $headers ) ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV output to a file download. + + foreach ( $rows as $row ) { + echo implode( ',', array_map( 'addslashes', array_values( $row ) ) ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV output to a file download. + } + + exit; + } + /** * Add a 2FA status column to the users list table. * diff --git a/includes/admin/class-settings-page.php b/includes/admin/class-settings-page.php index 9544f2d..6c24c6e 100644 --- a/includes/admin/class-settings-page.php +++ b/includes/admin/class-settings-page.php @@ -247,6 +247,14 @@ class Settings_Page { 'robotstxt_2fa_access' ); + add_settings_field( + 'robotstxt_2fa_app_passwords_require_2fa_creation', + __( 'Require 2FA to create Application Passwords', 'robotstxt-2fa' ), + array( $this, 'render_app_passwords_require_2fa_creation_field' ), + self::PAGE_SLUG, + 'robotstxt_2fa_access' + ); + add_settings_field( 'robotstxt_2fa_ip_allow', __( 'IP allow list', 'robotstxt-2fa' ), @@ -301,6 +309,45 @@ class Settings_Page { self::PAGE_SLUG, 'robotstxt_2fa_notifications' ); + + add_settings_section( + 'robotstxt_2fa_geoip', + __( 'GeoIP / Country restrictions', 'robotstxt-2fa' ), + array( $this, 'render_geoip_section' ), + self::PAGE_SLUG + ); + + add_settings_field( + 'robotstxt_2fa_geoip_database_path', + __( 'Database path', 'robotstxt-2fa' ), + array( $this, 'render_geoip_database_path_field' ), + self::PAGE_SLUG, + 'robotstxt_2fa_geoip' + ); + + add_settings_field( + 'robotstxt_2fa_geoip_country_allow', + __( 'Country allow list (bypass 2FA)', 'robotstxt-2fa' ), + array( $this, 'render_geoip_country_allow_field' ), + self::PAGE_SLUG, + 'robotstxt_2fa_geoip' + ); + + add_settings_field( + 'robotstxt_2fa_geoip_country_deny', + __( 'Country deny list (block login)', 'robotstxt-2fa' ), + array( $this, 'render_geoip_country_deny_field' ), + self::PAGE_SLUG, + 'robotstxt_2fa_geoip' + ); + + add_settings_field( + 'robotstxt_2fa_geoip_always_challenge', + __( 'Always challenge countries', 'robotstxt-2fa' ), + array( $this, 'render_geoip_always_challenge_field' ), + self::PAGE_SLUG, + 'robotstxt_2fa_geoip' + ); } /** @@ -504,7 +551,7 @@ class Settings_Page { * * @param mixed $raw_settings Raw settings submitted by the user. * - * @return array{role_methods: array>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string, app_passwords_exempt: bool, ip_allow: string, ip_deny: string, notifications: array{on_enable: bool, on_new_ip: bool, on_recovery_used: bool, on_recovery_used_admin: bool, digest_enabled: bool, digest_frequency: string}} + * @return array{role_methods: array>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string, app_passwords_exempt: bool, ip_allow: string, ip_deny: string, notifications: array{on_enable: bool, on_new_ip: bool, on_recovery_used: bool, on_recovery_used_admin: bool, digest_enabled: bool, digest_frequency: string}, app_passwords_require_2fa_creation: bool, geoip_database_path: string, geoip_country_allow: string, geoip_country_deny: string, geoip_always_challenge: string} */ public function sanitize_settings( $raw_settings ): array { $capability = $this->get_required_capability(); @@ -616,13 +663,33 @@ class Settings_Page { 'digest_frequency' => isset( $raw_notif['digest_frequency'] ) && 'monthly' === $raw_notif['digest_frequency'] ? 'monthly' : 'weekly', ); + if ( array_key_exists( 'app_passwords_require_2fa_creation', $raw_settings ) ) { + $sanitized['app_passwords_require_2fa_creation'] = '1' === ( is_scalar( $raw_settings['app_passwords_require_2fa_creation'] ) ? (string) $raw_settings['app_passwords_require_2fa_creation'] : '' ); + } + + if ( isset( $raw_settings['geoip_database_path'] ) && is_string( $raw_settings['geoip_database_path'] ) ) { + $sanitized['geoip_database_path'] = sanitize_text_field( $raw_settings['geoip_database_path'] ); + } + + if ( isset( $raw_settings['geoip_country_allow'] ) && is_string( $raw_settings['geoip_country_allow'] ) ) { + $sanitized['geoip_country_allow'] = strtoupper( sanitize_textarea_field( $raw_settings['geoip_country_allow'] ) ); + } + + if ( isset( $raw_settings['geoip_country_deny'] ) && is_string( $raw_settings['geoip_country_deny'] ) ) { + $sanitized['geoip_country_deny'] = strtoupper( sanitize_textarea_field( $raw_settings['geoip_country_deny'] ) ); + } + + if ( isset( $raw_settings['geoip_always_challenge'] ) && is_string( $raw_settings['geoip_always_challenge'] ) ) { + $sanitized['geoip_always_challenge'] = strtoupper( sanitize_textarea_field( $raw_settings['geoip_always_challenge'] ) ); + } + return $sanitized; } /** * Retrieve plugin settings, migrating from legacy format when necessary. * - * @return array{role_methods: array>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string, app_passwords_exempt: bool, ip_allow: string, ip_deny: string, notifications: array{on_enable: bool, on_new_ip: bool, on_recovery_used: bool, on_recovery_used_admin: bool, digest_enabled: bool, digest_frequency: string}} + * @return array{role_methods: array>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string, app_passwords_exempt: bool, ip_allow: string, ip_deny: string, notifications: array{on_enable: bool, on_new_ip: bool, on_recovery_used: bool, on_recovery_used_admin: bool, digest_enabled: bool, digest_frequency: string}, app_passwords_require_2fa_creation: bool, geoip_database_path: string, geoip_country_allow: string, geoip_country_deny: string, geoip_always_challenge: string} */ public function get_settings(): array { $options = array(); @@ -647,19 +714,19 @@ class Settings_Page { $def_notif = $defaults['notifications']; return array( - 'role_methods' => $this->resolve_role_methods( $options ), - 'frequency' => Frequency_Options::sanitize( isset( $options['frequency'] ) && is_string( $options['frequency'] ) ? $options['frequency'] : '' ), - 'force_frequency' => ! empty( $options['force_frequency'] ), - 'delete_on_uninstall' => ! empty( $options['delete_on_uninstall'] ), - 'max_attempts' => isset( $options['max_attempts'] ) && is_numeric( $options['max_attempts'] ) ? max( 0, absint( $options['max_attempts'] ) ) : $defaults['max_attempts'], - 'lockout_duration' => isset( $options['lockout_duration'] ) && is_numeric( $options['lockout_duration'] ) ? max( 1, absint( $options['lockout_duration'] ) ) : $defaults['lockout_duration'], - 'trust_device_days' => isset( $options['trust_device_days'] ) && is_numeric( $options['trust_device_days'] ) ? max( 0, absint( $options['trust_device_days'] ) ) : $defaults['trust_device_days'], - 'grace_period_days' => isset( $options['grace_period_days'] ) && is_numeric( $options['grace_period_days'] ) ? max( 0, absint( $options['grace_period_days'] ) ) : $defaults['grace_period_days'], - 'grace_period_action' => isset( $options['grace_period_action'] ) && in_array( $options['grace_period_action'], array( 'block', 'wizard' ), true ) ? $options['grace_period_action'] : $defaults['grace_period_action'], - 'app_passwords_exempt' => isset( $options['app_passwords_exempt'] ) ? (bool) $options['app_passwords_exempt'] : $defaults['app_passwords_exempt'], - 'ip_allow' => isset( $options['ip_allow'] ) && is_string( $options['ip_allow'] ) ? $options['ip_allow'] : $defaults['ip_allow'], - 'ip_deny' => isset( $options['ip_deny'] ) && is_string( $options['ip_deny'] ) ? $options['ip_deny'] : $defaults['ip_deny'], - 'notifications' => array( + 'role_methods' => $this->resolve_role_methods( $options ), + 'frequency' => Frequency_Options::sanitize( isset( $options['frequency'] ) && is_string( $options['frequency'] ) ? $options['frequency'] : '' ), + 'force_frequency' => ! empty( $options['force_frequency'] ), + 'delete_on_uninstall' => ! empty( $options['delete_on_uninstall'] ), + 'max_attempts' => isset( $options['max_attempts'] ) && is_numeric( $options['max_attempts'] ) ? max( 0, absint( $options['max_attempts'] ) ) : $defaults['max_attempts'], + 'lockout_duration' => isset( $options['lockout_duration'] ) && is_numeric( $options['lockout_duration'] ) ? max( 1, absint( $options['lockout_duration'] ) ) : $defaults['lockout_duration'], + 'trust_device_days' => isset( $options['trust_device_days'] ) && is_numeric( $options['trust_device_days'] ) ? max( 0, absint( $options['trust_device_days'] ) ) : $defaults['trust_device_days'], + 'grace_period_days' => isset( $options['grace_period_days'] ) && is_numeric( $options['grace_period_days'] ) ? max( 0, absint( $options['grace_period_days'] ) ) : $defaults['grace_period_days'], + 'grace_period_action' => isset( $options['grace_period_action'] ) && in_array( $options['grace_period_action'], array( 'block', 'wizard' ), true ) ? $options['grace_period_action'] : $defaults['grace_period_action'], + 'app_passwords_exempt' => isset( $options['app_passwords_exempt'] ) ? (bool) $options['app_passwords_exempt'] : $defaults['app_passwords_exempt'], + 'ip_allow' => isset( $options['ip_allow'] ) && is_string( $options['ip_allow'] ) ? $options['ip_allow'] : $defaults['ip_allow'], + 'ip_deny' => isset( $options['ip_deny'] ) && is_string( $options['ip_deny'] ) ? $options['ip_deny'] : $defaults['ip_deny'], + 'notifications' => array( 'on_enable' => isset( $raw_notif['on_enable'] ) ? (bool) $raw_notif['on_enable'] : $def_notif['on_enable'], 'on_new_ip' => isset( $raw_notif['on_new_ip'] ) ? (bool) $raw_notif['on_new_ip'] : $def_notif['on_new_ip'], 'on_recovery_used' => isset( $raw_notif['on_recovery_used'] ) ? (bool) $raw_notif['on_recovery_used'] : $def_notif['on_recovery_used'], @@ -667,29 +734,34 @@ class Settings_Page { 'digest_enabled' => isset( $raw_notif['digest_enabled'] ) ? (bool) $raw_notif['digest_enabled'] : $def_notif['digest_enabled'], 'digest_frequency' => isset( $raw_notif['digest_frequency'] ) && 'monthly' === $raw_notif['digest_frequency'] ? 'monthly' : $def_notif['digest_frequency'], ), + 'app_passwords_require_2fa_creation' => isset( $options['app_passwords_require_2fa_creation'] ) ? (bool) $options['app_passwords_require_2fa_creation'] : $defaults['app_passwords_require_2fa_creation'], + 'geoip_database_path' => isset( $options['geoip_database_path'] ) && is_string( $options['geoip_database_path'] ) ? $options['geoip_database_path'] : $defaults['geoip_database_path'], + 'geoip_country_allow' => isset( $options['geoip_country_allow'] ) && is_string( $options['geoip_country_allow'] ) ? $options['geoip_country_allow'] : $defaults['geoip_country_allow'], + 'geoip_country_deny' => isset( $options['geoip_country_deny'] ) && is_string( $options['geoip_country_deny'] ) ? $options['geoip_country_deny'] : $defaults['geoip_country_deny'], + 'geoip_always_challenge' => isset( $options['geoip_always_challenge'] ) && is_string( $options['geoip_always_challenge'] ) ? $options['geoip_always_challenge'] : $defaults['geoip_always_challenge'], ); } /** * Retrieve default settings. * - * @return array{role_methods: array>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string, app_passwords_exempt: bool, ip_allow: string, ip_deny: string, notifications: array{on_enable: bool, on_new_ip: bool, on_recovery_used: bool, on_recovery_used_admin: bool, digest_enabled: bool, digest_frequency: string}} + * @return array{role_methods: array>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string, app_passwords_exempt: bool, ip_allow: string, ip_deny: string, notifications: array{on_enable: bool, on_new_ip: bool, on_recovery_used: bool, on_recovery_used_admin: bool, digest_enabled: bool, digest_frequency: string}, app_passwords_require_2fa_creation: bool, geoip_database_path: string, geoip_country_allow: string, geoip_country_deny: string, geoip_always_challenge: string} */ private function get_default_settings(): array { return array( - 'role_methods' => array(), - 'frequency' => Frequency_Options::FREQUENCY_SESSION, - 'force_frequency' => false, - 'delete_on_uninstall' => false, - 'max_attempts' => 3, - 'lockout_duration' => 15, - 'trust_device_days' => 30, - 'grace_period_days' => 7, - 'grace_period_action' => 'wizard', - 'app_passwords_exempt' => true, - 'ip_allow' => '', - 'ip_deny' => '', - 'notifications' => array( + 'role_methods' => array(), + 'frequency' => Frequency_Options::FREQUENCY_SESSION, + 'force_frequency' => false, + 'delete_on_uninstall' => false, + 'max_attempts' => 3, + 'lockout_duration' => 15, + 'trust_device_days' => 30, + 'grace_period_days' => 7, + 'grace_period_action' => 'wizard', + 'app_passwords_exempt' => true, + 'ip_allow' => '', + 'ip_deny' => '', + 'notifications' => array( 'on_enable' => false, 'on_new_ip' => false, 'on_recovery_used' => false, @@ -697,6 +769,11 @@ class Settings_Page { 'digest_enabled' => false, 'digest_frequency' => 'weekly', ), + 'app_passwords_require_2fa_creation' => false, + 'geoip_database_path' => '', + 'geoip_country_allow' => '', + 'geoip_country_deny' => '', + 'geoip_always_challenge' => '', ); } @@ -1257,6 +1334,107 @@ class Settings_Page { echo ''; } + /** + * Render the Application Passwords require 2FA creation checkbox. + * + * @return void + */ + public function render_app_passwords_require_2fa_creation_field(): void { + $settings = $this->get_settings(); + $is_checked = $settings['app_passwords_require_2fa_creation']; + + printf( + '', + esc_attr( self::OPTION_NAME ) + ); + + printf( + '', + esc_attr( self::OPTION_NAME ), + checked( $is_checked, true, false ), + esc_html__( 'Require a recent 2FA verification before creating a new Application Password.', 'robotstxt-2fa' ) + ); + + echo '

' . esc_html__( 'When enabled, users must have completed a 2FA challenge within the last 15 minutes before they can generate a new Application Password via the REST API.', 'robotstxt-2fa' ) . '

'; + } + + /** + * Render the GeoIP section description. + * + * @return void + */ + public function render_geoip_section(): void { + echo '

' . esc_html__( 'Requires a MaxMind GeoLite2 Country database file (.mmdb). Leave empty to disable country-based rules. Download the free GeoLite2 Country database from maxmind.com.', 'robotstxt-2fa' ) . '

'; + } + + /** + * Render the GeoIP database path field. + * + * @return void + */ + public function render_geoip_database_path_field(): void { + $settings = $this->get_settings(); + + printf( + '', + esc_attr( self::OPTION_NAME ), + esc_attr( $settings['geoip_database_path'] ) + ); + + echo '

' . esc_html__( 'Absolute path to the GeoLite2-Country.mmdb file. Leave empty to disable GeoIP features.', 'robotstxt-2fa' ) . '

'; + } + + /** + * Render the GeoIP country allow list textarea. + * + * @return void + */ + public function render_geoip_country_allow_field(): void { + $settings = $this->get_settings(); + + printf( + '', + esc_attr( self::OPTION_NAME ), + esc_textarea( $settings['geoip_country_allow'] ) + ); + + echo '

' . esc_html__( 'ISO country codes (one per line, e.g. ES, US) whose logins bypass the 2FA challenge.', 'robotstxt-2fa' ) . '

'; + } + + /** + * Render the GeoIP country deny list textarea. + * + * @return void + */ + public function render_geoip_country_deny_field(): void { + $settings = $this->get_settings(); + + printf( + '', + esc_attr( self::OPTION_NAME ), + esc_textarea( $settings['geoip_country_deny'] ) + ); + + echo '

' . esc_html__( 'ISO country codes blocked from logging in altogether.', 'robotstxt-2fa' ) . '

'; + } + + /** + * Render the GeoIP always-challenge countries textarea. + * + * @return void + */ + public function render_geoip_always_challenge_field(): void { + $settings = $this->get_settings(); + + printf( + '', + esc_attr( self::OPTION_NAME ), + esc_textarea( $settings['geoip_always_challenge'] ) + ); + + echo '

' . esc_html__( 'ISO country codes that always trigger a 2FA challenge, regardless of the verification frequency setting.', 'robotstxt-2fa' ) . '

'; + } + /** * Render the Documentation page. * diff --git a/includes/admin/class-users-list-table.php b/includes/admin/class-users-list-table.php new file mode 100644 index 0000000..41a0f80 --- /dev/null +++ b/includes/admin/class-users-list-table.php @@ -0,0 +1,357 @@ + 'user', + 'plural' => 'users', + 'ajax' => false, + ) + ); + + $this->config = $config; + $this->repository = $repository; + } + + /** + * Return the list of columns. + * + * @since 1.5.0 + * + * @return array + */ + public function get_columns(): array { + return array( + 'cb' => '', + 'user' => __( 'User', 'robotstxt-2fa' ), + 'roles' => __( 'Role', 'robotstxt-2fa' ), + 'status' => __( '2FA Status', 'robotstxt-2fa' ), + 'methods' => __( 'Methods', 'robotstxt-2fa' ), + 'last_verified' => __( 'Last verified', 'robotstxt-2fa' ), + 'unused_codes' => __( 'Unused codes', 'robotstxt-2fa' ), + ); + } + + /** + * Return the list of sortable columns. + * + * @since 1.5.0 + * + * @return array + */ + public function get_sortable_columns(): array { + return array( + 'user' => array( 'user_login', false ), + 'last_verified' => array( 'last_verified', true ), + ); + } + + /** + * Render the checkbox column. + * + * @since 1.5.0 + * + * @param mixed $item Current row data. + * + * @return string + */ + public function column_cb( mixed $item ): string { + if ( ! is_array( $item ) ) { + return ''; + } + + $raw_id = $item['ID'] ?? 0; + $item_id = is_numeric( $raw_id ) ? (int) $raw_id : 0; + + return sprintf( + '', + absint( $item_id ) + ); + } + + /** + * Render the user column with login, email, and edit link. + * + * @since 1.5.0 + * + * @param mixed $item Current row data. + * + * @return string + */ + public function column_user( mixed $item ): string { + if ( ! is_array( $item ) ) { + return ''; + } + + $raw_id = $item['ID'] ?? 0; + $item_id = is_numeric( $raw_id ) ? (int) $raw_id : 0; + $edit_link = get_edit_user_link( $item_id ); + $raw_login = $item['user_login'] ?? ''; + $login = esc_html( is_string( $raw_login ) ? $raw_login : '' ); + $raw_email = $item['user_email'] ?? ''; + $email = esc_html( is_string( $raw_email ) ? $raw_email : '' ); + + $output = ''; + + if ( '' !== $edit_link ) { + $output .= '' . $login . ''; + } else { + $output .= $login; + } + + $output .= '
' . $email . ''; + + return $output; + } + + /** + * Render a default column value. + * + * @since 1.5.0 + * + * @param mixed $item Current row data. + * @param mixed $column_name Column identifier. + * + * @return string + */ + public function column_default( mixed $item, mixed $column_name ): string { + if ( ! is_array( $item ) || ! is_string( $column_name ) ) { + return ''; + } + + $raw_value = $item[ $column_name ] ?? ''; + + return esc_html( is_string( $raw_value ) ? $raw_value : '' ); + } + + /** + * Return the available bulk actions. + * + * @since 1.5.0 + * + * @return array + */ + public function get_bulk_actions(): array { + return array( + 'export_csv' => __( 'Export CSV', 'robotstxt-2fa' ), + ); + } + + /** + * Prepare the items for display: query users, compute 2FA status, sort, paginate. + * + * @since 1.5.0 + * + * @return void + */ + public function prepare_items(): void { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- GET display params only; no state changes. + $role = isset( $_REQUEST['role_filter'] ) && is_string( $_REQUEST['role_filter'] ) ? sanitize_key( $_REQUEST['role_filter'] ) : ''; + $search = isset( $_REQUEST['s'] ) && is_string( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : ''; + $status_filter = isset( $_REQUEST['status_filter'] ) && is_string( $_REQUEST['status_filter'] ) ? sanitize_key( $_REQUEST['status_filter'] ) : ''; + $orderby = isset( $_REQUEST['orderby'] ) && is_string( $_REQUEST['orderby'] ) ? sanitize_key( $_REQUEST['orderby'] ) : 'user_login'; + $raw_order = isset( $_REQUEST['order'] ) && is_string( $_REQUEST['order'] ) ? strtolower( sanitize_key( $_REQUEST['order'] ) ) : 'asc'; + $order = in_array( $raw_order, array( 'asc', 'desc' ), true ) ? $raw_order : 'asc'; + $paged = isset( $_REQUEST['paged'] ) && is_numeric( $_REQUEST['paged'] ) ? max( 1, (int) $_REQUEST['paged'] ) : 1; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $query_args = array( + 'number' => -1, + 'fields' => 'all', + ); + + if ( '' !== $role ) { + $query_args['role'] = $role; + } + + if ( '' !== $search ) { + $query_args['search'] = '*' . $search . '*'; + } + + $users = get_users( $query_args ); + $rows = array(); + + foreach ( $users as $user ) { + if ( ! $user instanceof \WP_User ) { + continue; + } + + $settings = $this->repository->get_user_settings( $user->ID ); + $is_enabled = $settings['enabled']; + + $required_methods = $this->config->get_required_methods_for_user( $user ); + $is_forced = ! empty( $required_methods ); + + if ( $is_enabled ) { + $status = 'active'; + } elseif ( $is_forced ) { + $status = 'required'; + } else { + $status = 'optional'; + } + + // Compute last verified timestamp across all methods/contexts. + $last_verified_ts = 0; + $raw_verifications = get_user_meta( $user->ID, 'robotstxt_2fa_last_verifications', true ); + + if ( is_array( $raw_verifications ) ) { + foreach ( $raw_verifications as $method_data ) { + if ( is_array( $method_data ) ) { + foreach ( $method_data as $context_data ) { + if ( is_array( $context_data ) && isset( $context_data['verified_at'] ) && is_int( $context_data['verified_at'] ) ) { + $last_verified_ts = max( $last_verified_ts, $context_data['verified_at'] ); + } + } + } + } + } + + $last_verified_str = $last_verified_ts > 0 ? (string) wp_date( 'Y-m-d H:i', $last_verified_ts ) : '—'; + + // Count unused recovery codes. + $unused_codes = 0; + $raw_codes = get_user_meta( $user->ID, 'robotstxt_2fa_recovery_codes', true ); + + if ( is_array( $raw_codes ) && isset( $raw_codes['codes'] ) && is_array( $raw_codes['codes'] ) ) { + foreach ( $raw_codes['codes'] as $code_entry ) { + if ( is_array( $code_entry ) && empty( $code_entry['used_at'] ) ) { + ++$unused_codes; + } + } + } + + $rows[] = array( + 'ID' => $user->ID, + 'user_login' => $user->user_login, + 'user_email' => $user->user_email, + 'roles' => implode( ', ', $user->roles ), + 'status' => $status, + 'methods' => implode( ', ', $settings['methods'] ), + 'last_verified' => $last_verified_str, + 'unused_codes' => (string) $unused_codes, + ); + } + + // Filter by status. + if ( '' !== $status_filter ) { + $rows = array_values( + array_filter( + $rows, + static function ( array $row ) use ( $status_filter ): bool { + return $row['status'] === $status_filter; + } + ) + ); + } + + // Sort. + usort( + $rows, + static function ( array $a, array $b ) use ( $orderby, $order ): int { + $val_a = (string) ( $a[ $orderby ] ?? '' ); + $val_b = (string) ( $b[ $orderby ] ?? '' ); + $cmp = strcmp( $val_a, $val_b ); + + return 'desc' === $order ? -$cmp : $cmp; + } + ); + + $total_items = count( $rows ); + $per_page = 20; + $offset = ( $paged - 1 ) * $per_page; + + $this->items = array_slice( $rows, $offset, $per_page ); + + $this->set_pagination_args( + array( + 'total_items' => $total_items, + 'per_page' => $per_page, + ) + ); + } + + /** + * Render extra table navigation filters. + * + * @since 1.5.0 + * + * @param mixed $which Which tablenav ('top' or 'bottom'). + * + * @return void + */ + public function extra_tablenav( mixed $which ): void { + if ( 'top' !== $which ) { + return; + } + + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- GET display params only; no state changes. + $current_role = isset( $_REQUEST['role_filter'] ) && is_string( $_REQUEST['role_filter'] ) ? sanitize_key( $_REQUEST['role_filter'] ) : ''; + $current_status = isset( $_REQUEST['status_filter'] ) && is_string( $_REQUEST['status_filter'] ) ? sanitize_key( $_REQUEST['status_filter'] ) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $roles = get_editable_roles(); + ?> +
+ + + +
+ config->get_notifications(); - $digest_enabled = $notifications['digest_enabled']; - $digest_frequency = $notifications['digest_frequency']; - $next_scheduled = wp_next_scheduled( self::DIGEST_CRON_HOOK ); + $notifications = $this->config->get_notifications(); + $digest_enabled = $notifications['digest_enabled']; if ( $digest_enabled ) { - if ( false === $next_scheduled ) { - $interval = 'monthly' === $digest_frequency ? 'robotstxt_2fa_monthly' : 'weekly'; + $interval = 'monthly' === $notifications['digest_frequency'] ? 'robotstxt_2fa_monthly' : 'weekly'; + $scheduled_event = wp_get_scheduled_event( self::DIGEST_CRON_HOOK ); + + if ( false === $scheduled_event ) { + wp_schedule_event( time() + DAY_IN_SECONDS, $interval, self::DIGEST_CRON_HOOK ); + } elseif ( isset( $scheduled_event->schedule ) && is_string( $scheduled_event->schedule ) && $scheduled_event->schedule !== $interval ) { + wp_clear_scheduled_hook( self::DIGEST_CRON_HOOK ); wp_schedule_event( time() + DAY_IN_SECONDS, $interval, self::DIGEST_CRON_HOOK ); } - } elseif ( false !== $next_scheduled ) { - wp_clear_scheduled_hook( self::DIGEST_CRON_HOOK ); + } elseif ( (bool) wp_next_scheduled( self::DIGEST_CRON_HOOK ) ) { + wp_clear_scheduled_hook( self::DIGEST_CRON_HOOK ); } } diff --git a/includes/class-geo-restrictions.php b/includes/class-geo-restrictions.php new file mode 100644 index 0000000..46a335f --- /dev/null +++ b/includes/class-geo-restrictions.php @@ -0,0 +1,222 @@ +config = $config; + } + + /** + * Register WordPress hooks. + * + * @since 1.5.0 + * + * @return void + */ + public function register_hooks(): void { + add_filter( 'robotstxt_2fa_skip_challenge', array( $this, 'maybe_skip_for_allowed_country' ), 6, 2 ); + add_filter( 'authenticate', array( $this, 'maybe_block_denied_country' ), 2, 3 ); + add_filter( 'robotstxt_2fa_force_challenge', array( $this, 'maybe_force_challenge_for_country' ), 10, 2 ); + } + + /** + * Skip the 2FA challenge for users logging in from an allowed country. + * + * @since 1.5.0 + * + * @param bool $skip Whether the challenge is already being skipped. + * @param \WP_User $user Authenticated user about to be challenged. + * + * @return bool + */ + public function maybe_skip_for_allowed_country( bool $skip, \WP_User $user ): bool { + unset( $user ); + + if ( $skip ) { + return true; + } + + $allow_list = $this->config->get_geoip_country_allow(); + + if ( '' === $allow_list ) { + return false; + } + + $country = $this->get_country_for_ip(); + + if ( '' === $country ) { + return false; + } + + return $this->country_in_list( $country, $allow_list ); + } + + /** + * Block logins from denied countries before credentials are checked. + * + * @since 1.5.0 + * + * @param mixed $user Previously authenticated user or null. + * @param string $username Submitted username. + * @param string $password Submitted password. + * + * @return mixed + */ + public function maybe_block_denied_country( mixed $user, string $username, string $password ): mixed { + unset( $username, $password ); + + $deny_list = $this->config->get_geoip_country_deny(); + + if ( '' === $deny_list ) { + return $user; + } + + $country = $this->get_country_for_ip(); + + if ( '' === $country ) { + return $user; + } + + if ( $this->country_in_list( $country, $deny_list ) ) { + return new \WP_Error( + 'robotstxt_2fa_country_blocked', + __( 'Login is not allowed from your country.', 'robotstxt-2fa' ) + ); + } + + return $user; + } + + /** + * Force a fresh 2FA challenge for users logging in from a high-risk country. + * + * @since 1.5.0 + * + * @param bool $force Whether the challenge is already being forced. + * @param \WP_User $user User about to be challenged. + * + * @return bool + */ + public function maybe_force_challenge_for_country( bool $force, \WP_User $user ): bool { + unset( $user ); + + if ( $force ) { + return true; + } + + $always_challenge = $this->config->get_geoip_always_challenge(); + + if ( '' === $always_challenge ) { + return false; + } + + $country = $this->get_country_for_ip(); + + if ( '' === $country ) { + return false; + } + + return $this->country_in_list( $country, $always_challenge ); + } + + /** + * Resolve the ISO 3166-1 alpha-2 country code for the current request IP. + * + * Returns an empty string when GeoIP is unavailable or the lookup fails. + * + * @since 1.5.0 + * + * @return string + */ + private function get_country_for_ip(): string { + $db_path = $this->config->get_geoip_database_path(); + + if ( '' === $db_path || ! file_exists( $db_path ) ) { + return ''; + } + + if ( ! class_exists( '\MaxMind\Db\Reader' ) ) { + return ''; + } + + $ip = ''; + + if ( isset( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ) { + $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); + } + + if ( '' === $ip ) { + return ''; + } + + try { + $reader = new \MaxMind\Db\Reader( $db_path ); + $record = $reader->get( $ip ); + $reader->close(); + + if ( ! is_array( $record ) ) { + return ''; + } + + $country = $record['country'] ?? null; + + if ( ! is_array( $country ) || ! isset( $country['iso_code'] ) || ! is_string( $country['iso_code'] ) ) { + return ''; + } + + return strtoupper( $country['iso_code'] ); + } catch ( \Throwable $e ) { + return ''; + } + } + + /** + * Check whether a country code appears in a newline/comma-separated list. + * + * @since 1.5.0 + * + * @param string $country ISO 3166-1 alpha-2 country code (uppercase). + * @param string $codelist Newline- or comma-separated country codes. + * + * @return bool + */ + private function country_in_list( string $country, string $codelist ): bool { + if ( '' === $country || '' === $codelist ) { + return false; + } + + $split_result = preg_split( '/[\s,]+/', strtoupper( $codelist ) ); + $codes = array_filter( array_map( 'trim', is_array( $split_result ) ? $split_result : array() ) ); + + return in_array( $country, $codes, true ); + } +} diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 287f915..884f7dc 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -17,6 +17,7 @@ use Robotstxt\TwoFA\User\Profile_Settings; use Robotstxt\TwoFA\User\Recovery_Codes; use Robotstxt\TwoFA\User\Trusted_Devices; use Robotstxt\TwoFA\User\Two_Factor_Config; +use Robotstxt\TwoFA\User\User_Settings_Repository; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -103,6 +104,13 @@ class Plugin { */ private IP_Restrictions $ip_restrictions; + /** + * GeoIP country restrictions handler. + * + * @var Geo_Restrictions + */ + private Geo_Restrictions $geo_restrictions; + /** * Audit dashboard page. * @@ -128,6 +136,7 @@ class Plugin { */ public function __construct() { $config = new Two_Factor_Config(); + $repository = new User_Settings_Repository(); $this->attempts_log = new Attempts_Log(); $this->login_manager = new Login_Form_Manager(); $this->profile_settings = new Profile_Settings(); @@ -138,7 +147,8 @@ class Plugin { $this->grace_period = new Grace_Period(); $this->email_notifications = new Email_Notifications( $config ); $this->ip_restrictions = new IP_Restrictions( $config ); - $this->dashboard_page = new Dashboard_Page( $this->attempts_log ); + $this->geo_restrictions = new Geo_Restrictions( $config ); + $this->dashboard_page = new Dashboard_Page( $this->attempts_log, $config, $repository ); } /** @@ -151,6 +161,7 @@ class Plugin { $this->attempts_log->register_hooks(); $this->email_notifications->register_hooks(); $this->ip_restrictions->register_hooks(); + $this->geo_restrictions->register_hooks(); $this->dashboard_page->register_hooks(); $this->login_manager->register_hooks(); $this->profile_settings->register_hooks(); @@ -160,11 +171,86 @@ class Plugin { $this->trusted_devices->register_hooks(); $this->grace_period->register_hooks(); + add_filter( 'rest_pre_dispatch', array( $this, 'maybe_block_app_password_creation' ), 10, 3 ); + if ( defined( 'WP_CLI' ) && WP_CLI ) { add_action( 'cli_init', array( $this, 'register_cli_commands' ) ); } } + /** + * Block Application Password creation when the user has not recently verified 2FA. + * + * @since 1.5.0 + * + * @param mixed $result Current result (null = not yet dispatched). + * @param \WP_REST_Server $server REST server instance. + * @param \WP_REST_Request $request Current request. + * + * @return mixed + */ + public function maybe_block_app_password_creation( mixed $result, \WP_REST_Server $server, \WP_REST_Request $request ): mixed { + unset( $server ); + + if ( null !== $result ) { + return $result; + } + + if ( 'POST' !== $request->get_method() ) { + return $result; + } + + $config = new Two_Factor_Config(); + + if ( ! $config->is_app_passwords_require_2fa_creation() ) { + return $result; + } + + if ( ! preg_match( '#^/wp/v2/users/\d+/application-passwords$#', $request->get_route() ) ) { + return $result; + } + + $user = wp_get_current_user(); + + if ( ! $user->exists() ) { + return $result; + } + + $verified_at = 0; + $raw = get_user_meta( $user->ID, 'robotstxt_2fa_last_verifications', true ); + + if ( is_array( $raw ) ) { + foreach ( $raw as $method_data ) { + if ( is_array( $method_data ) ) { + foreach ( $method_data as $context_data ) { + if ( is_array( $context_data ) && isset( $context_data['verified_at'] ) && is_int( $context_data['verified_at'] ) ) { + $verified_at = max( $verified_at, $context_data['verified_at'] ); + } + } + } + } + } + + /** + * Filter the 2FA recency window (in seconds) required before creating an Application Password. + * + * @since 1.5.0 + * + * @param int $window Seconds since last 2FA verification. Default 900 (15 minutes). + */ + $window = (int) apply_filters( 'robotstxt_2fa_app_password_verification_window', 900 ); + + if ( $verified_at > 0 && ( time() - $verified_at ) <= $window ) { + return $result; + } + + return new \WP_Error( + 'robotstxt_2fa_required', + __( 'A recent two-factor authentication verification is required to create an Application Password. Please log in and complete 2FA, then try again.', 'robotstxt-2fa' ), + array( 'status' => 401 ) + ); + } + /** * Register WP-CLI commands. * diff --git a/includes/cli/class-cli-command.php b/includes/cli/class-cli-command.php index f9c2f6b..633a09f 100644 --- a/includes/cli/class-cli-command.php +++ b/includes/cli/class-cli-command.php @@ -310,6 +310,37 @@ class Cli_Command extends WP_CLI_Command { \WP_CLI\Utils\format_items( 'table', $rows, array( 'ID', 'Login', 'Email', '2FA', 'Frequency' ) ); } + /** + * Export a full 2FA status report for all users. + * + * Equivalent to `wp 2fa list --format=csv` but outputs to stdout for easy + * redirection to a file. + * + * ## OPTIONS + * + * [--role=] + * : Restrict export to a specific WordPress role. + * + * [--without-2fa] + * : Include only users who do not have 2FA enabled. + * + * ## EXAMPLES + * + * wp 2fa export > 2fa-audit.csv + * wp 2fa export --role=editor > editors.csv + * + * @since 1.5.0 + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @return void + */ + public function export( array $args, array $assoc_args ): void { + $assoc_args['format'] = 'csv'; + $this->list_users( $args, $assoc_args ); + } + /** * Enforce 2FA for a specific user or all users of a role. * diff --git a/includes/login/class-login-form-manager.php b/includes/login/class-login-form-manager.php index 356eee5..9c01cce 100644 --- a/includes/login/class-login-form-manager.php +++ b/includes/login/class-login-form-manager.php @@ -1043,7 +1043,20 @@ class Login_Form_Manager { $frequency = $this->get_effective_frequency( $user, $user_settings ); if ( $this->has_recent_verification( $user, $methods, $frequency ) ) { - return ''; + /** + * Filter whether to force a fresh 2FA challenge regardless of the frequency setting. + * + * Return true to always challenge the user, overriding the frequency-based skip. + * Used by GeoIP restrictions to mandate verification from new countries. + * + * @since 1.5.0 + * + * @param bool $force Whether to force the challenge. Default false. + * @param \WP_User $user User about to be challenged. + */ + if ( ! (bool) apply_filters( 'robotstxt_2fa_force_challenge', false, $user ) ) { + return ''; + } } return $this->select_preferred_method( $methods ); diff --git a/includes/user/class-two-factor-config.php b/includes/user/class-two-factor-config.php index 3b5d235..aa99b1e 100644 --- a/includes/user/class-two-factor-config.php +++ b/includes/user/class-two-factor-config.php @@ -21,7 +21,7 @@ class Two_Factor_Config { /** * Retrieve global two-factor settings with multisite support. * - * @return array{role_methods: array>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string, app_passwords_exempt: bool, ip_allow: string, ip_deny: string, notifications: array{on_enable: bool, on_new_ip: bool, on_recovery_used: bool, on_recovery_used_admin: bool, digest_enabled: bool, digest_frequency: string}} + * @return array{role_methods: array>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string, app_passwords_exempt: bool, ip_allow: string, ip_deny: string, notifications: array{on_enable: bool, on_new_ip: bool, on_recovery_used: bool, on_recovery_used_admin: bool, digest_enabled: bool, digest_frequency: string}, app_passwords_require_2fa_creation: bool, geoip_database_path: string, geoip_country_allow: string, geoip_country_deny: string, geoip_always_challenge: string} */ public function get_settings(): array { $settings_page = new Settings_Page(); @@ -225,4 +225,59 @@ class Two_Factor_Config { public function get_notifications(): array { return $this->get_settings()['notifications']; } + + /** + * Determine whether a recent 2FA verification is required before creating an Application Password. + * + * @since 1.5.0 + * + * @return bool + */ + public function is_app_passwords_require_2fa_creation(): bool { + return $this->get_settings()['app_passwords_require_2fa_creation']; + } + + /** + * Retrieve the GeoIP database file path. + * + * @since 1.5.0 + * + * @return string + */ + public function get_geoip_database_path(): string { + return $this->get_settings()['geoip_database_path']; + } + + /** + * Retrieve the GeoIP country allow list string (newline-separated ISO codes). + * + * @since 1.5.0 + * + * @return string + */ + public function get_geoip_country_allow(): string { + return $this->get_settings()['geoip_country_allow']; + } + + /** + * Retrieve the GeoIP country deny list string (newline-separated ISO codes). + * + * @since 1.5.0 + * + * @return string + */ + public function get_geoip_country_deny(): string { + return $this->get_settings()['geoip_country_deny']; + } + + /** + * Retrieve the GeoIP always-challenge country list string (newline-separated ISO codes). + * + * @since 1.5.0 + * + * @return string + */ + public function get_geoip_always_challenge(): string { + return $this->get_settings()['geoip_always_challenge']; + } } diff --git a/readme.txt b/readme.txt index 5b0f588..6d60d5f 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: security, two-factor authentication, login, otp Requires at least: 6.4 Tested up to: 7.0 Requires PHP: 8.0 -Stable tag: 1.4.0 +Stable tag: 1.5.0 License: GPLv3 or later License URI: https://www.gnu.org/licenses/gpl-3.0.html @@ -59,6 +59,22 @@ Yes. Activate the plugin at the network level. Network administrators can set an == Changelog == += 1.5.0 = + +_Release date: 2026-06-05_ + +**Added** + +* Full audit user table — WP_List_Table with sortable columns (user, last verified), role and status filters, and one-click CSV export. +* GeoIP country restrictions — optional MaxMind GeoLite2 integration: per-country allow list (bypass 2FA), deny list (block login), always-challenge list (override frequency). Disabled when no database file is configured. +* Require 2FA before creating Application Passwords — REST endpoint rejects requests unless 2FA was completed within the last 15 minutes (configurable via `robotstxt_2fa_app_password_verification_window` filter). +* WP-CLI `wp 2fa export` — outputs a full CSV report. +* New developer filter `robotstxt_2fa_force_challenge` — override frequency-based skip and always require a fresh challenge. + +**Fixed** + +* Email digest cron now correctly reschedules when the frequency is changed between weekly and monthly. + = 1.4.0 = _Release date: 2026-06-05_ @@ -93,22 +109,6 @@ _Release date: 2026-06-05_ * `wp 2fa bypass [--days=]` — grant a temporary bypass for a locked-out user. * WP-CLI commands are only loaded when the `WP_CLI` constant is defined and truthy. -= 1.2.1 = - -_Release date: 2026-06-05_ - -**Fixed** - -* Recovery code confirmation without checking the method checkbox now correctly activates the method and enables 2FA. -* Fatal error on admin profile pages in multisite — `maybe_handle_frontend_save()` now returns early when `is_admin()` is true. -* OTP and recovery confirmation text inputs were disabled by JS when the Enable toggle was off, preventing values from reaching the server. -* "Regenerate codes" now removes recovery codes from active methods and requires re-confirmation before reactivating. -* Entering a valid OTP code or recovery confirmation code activates the method even without checking the Enable checkbox. - -**Security** - -* `RECOVERY_REGENERATE_FIELD` POST value now validated with strict `=== '1'` check. - = Previous versions = For the full changelog see the [changelog.txt](https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/raw/branch/main/changelog.txt) file. diff --git a/robotstxt-2fa.php b/robotstxt-2fa.php index 08dd2f6..708e8a4 100644 --- a/robotstxt-2fa.php +++ b/robotstxt-2fa.php @@ -3,7 +3,7 @@ * Plugin Name: 2FA (by ROBOTSTXT) * Plugin URI: https://www.robotstxt.es/plugins/robotstxt-2fa/ * Description: Adds two-factor authentication to the WordPress login flow. - * Version: 1.4.0 + * Version: 1.5.0 * Author: ROBOTSTXT * Author URI: https://www.robotstxt.es/ * Text Domain: robotstxt-2fa @@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) { } if ( ! defined( 'ROBOTSTXT_2FA_VERSION' ) ) { - define( 'ROBOTSTXT_2FA_VERSION', '1.4.0' ); + define( 'ROBOTSTXT_2FA_VERSION', '1.5.0' ); } if ( ! defined( 'ROBOTSTXT_2FA_FILE' ) ) { diff --git a/update.json b/update.json index 7a2b1b7..245e915 100644 --- a/update.json +++ b/update.json @@ -1,8 +1,8 @@ { "name": "2FA (by ROBOTSTXT)", "slug": "robotstxt-2fa", - "version": "1.4.0", - "download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/releases/download/1.4.0/robotstxt-2fa-1.4.0.zip", + "version": "1.5.0", + "download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/releases/download/1.5.0/robotstxt-2fa-1.5.0.zip", "requires": "6.4", "requires_php": "8.0", "tested": "7.1", @@ -11,17 +11,11 @@ "author_profile": "https://www.robotstxt.es/", "homepage": "https://www.robotstxt.es/plugins/robotstxt-2fa/", "description": "Adds per-role two-factor authentication to the WordPress login flow. Supports email codes, authenticator apps (TOTP), and recovery codes.", - "changelog": "

1.4.0 - 2026-06-05

  • Added: Audit Dashboard with summary cards, failed attempts log, and 2FA status column in Users list
  • Added: Email notifications — admin-enabled 2FA, new location login, recovery code used, weekly/monthly digest
  • Added: Application Passwords exemption — REST API clients skip the 2FA challenge by default
  • Added: IP whitelist (bypass 2FA) and IP blacklist (block login) with CIDR support
", + "changelog": "

1.5.0 - 2026-06-05

  • Added: Full audit dashboard with WP_List_Table (sortable, filterable by role/status, CSV export)
  • Added: GeoIP country allow/deny/always-challenge rules via MaxMind GeoLite2 (optional)
  • Added: Require recent 2FA verification before creating Application Passwords
  • Added: WP-CLI export subcommand
  • Fixed: Email digest cron now correctly reschedules when frequency setting changes
", "sections": { "description": "Adds per-role two-factor authentication to the WordPress login flow. Supports email codes, authenticator apps (TOTP), and recovery codes.", - "changelog": "

1.4.0 - 2026-06-05

  • Added: Audit Dashboard with summary cards, failed attempts log, and 2FA status column in Users list
  • Added: Email notifications — admin-enabled 2FA, new location login, recovery code used, weekly/monthly digest
  • Added: Application Passwords exemption — REST API clients skip the 2FA challenge by default
  • Added: IP whitelist (bypass 2FA) and IP blacklist (block login) with CIDR support
" + "changelog": "

1.5.0 - 2026-06-05

  • Added: Full audit dashboard with WP_List_Table (sortable, filterable by role/status, CSV export)
  • Added: GeoIP country allow/deny/always-challenge rules via MaxMind GeoLite2 (optional)
  • Added: Require recent 2FA verification before creating Application Passwords
  • Added: WP-CLI export subcommand
  • Fixed: Email digest cron now correctly reschedules when frequency setting changes
" }, - "banners": { - "low": "", - "high": "" - }, - "icons": { - "1x": "", - "2x": "" - } + "banners": { "low": "", "high": "" }, + "icons": { "1x": "", "2x": "" } } diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php index ebd8ac5..f6a80c1 100644 --- a/vendor/composer/autoload_classmap.php +++ b/vendor/composer/autoload_classmap.php @@ -79,4 +79,9 @@ return array( 'DASPRiD\\Enum\\Exception\\SerializeNotSupportedException' => $vendorDir . '/dasprid/enum/src/Exception/SerializeNotSupportedException.php', 'DASPRiD\\Enum\\Exception\\UnserializeNotSupportedException' => $vendorDir . '/dasprid/enum/src/Exception/UnserializeNotSupportedException.php', 'DASPRiD\\Enum\\NullValue' => $vendorDir . '/dasprid/enum/src/NullValue.php', + 'MaxMind\\Db\\Reader' => $vendorDir . '/maxmind-db/reader/src/MaxMind/Db/Reader.php', + 'MaxMind\\Db\\Reader\\Decoder' => $vendorDir . '/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php', + 'MaxMind\\Db\\Reader\\InvalidDatabaseException' => $vendorDir . '/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php', + 'MaxMind\\Db\\Reader\\Metadata' => $vendorDir . '/maxmind-db/reader/src/MaxMind/Db/Reader/Metadata.php', + 'MaxMind\\Db\\Reader\\Util' => $vendorDir . '/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php', ); diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php index 3d29d13..0f09556 100644 --- a/vendor/composer/autoload_psr4.php +++ b/vendor/composer/autoload_psr4.php @@ -6,6 +6,7 @@ $vendorDir = dirname(__DIR__); $baseDir = dirname($vendorDir); return array( + 'MaxMind\\Db\\' => array($vendorDir . '/maxmind-db/reader/src/MaxMind/Db'), 'DASPRiD\\Enum\\' => array($vendorDir . '/dasprid/enum/src'), 'BaconQrCode\\' => array($vendorDir . '/bacon/bacon-qr-code/src'), ); diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 6c31766..eb33b90 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -7,6 +7,10 @@ namespace Composer\Autoload; class ComposerStaticInit3c8f2c10a8655d88b9115205b533c7e2 { public static $prefixLengthsPsr4 = array ( + 'M' => + array ( + 'MaxMind\\Db\\' => 11, + ), 'D' => array ( 'DASPRiD\\Enum\\' => 13, @@ -18,6 +22,10 @@ class ComposerStaticInit3c8f2c10a8655d88b9115205b533c7e2 ); public static $prefixDirsPsr4 = array ( + 'MaxMind\\Db\\' => + array ( + 0 => __DIR__ . '/..' . '/maxmind-db/reader/src/MaxMind/Db', + ), 'DASPRiD\\Enum\\' => array ( 0 => __DIR__ . '/..' . '/dasprid/enum/src', @@ -102,6 +110,11 @@ class ComposerStaticInit3c8f2c10a8655d88b9115205b533c7e2 'DASPRiD\\Enum\\Exception\\SerializeNotSupportedException' => __DIR__ . '/..' . '/dasprid/enum/src/Exception/SerializeNotSupportedException.php', 'DASPRiD\\Enum\\Exception\\UnserializeNotSupportedException' => __DIR__ . '/..' . '/dasprid/enum/src/Exception/UnserializeNotSupportedException.php', 'DASPRiD\\Enum\\NullValue' => __DIR__ . '/..' . '/dasprid/enum/src/NullValue.php', + 'MaxMind\\Db\\Reader' => __DIR__ . '/..' . '/maxmind-db/reader/src/MaxMind/Db/Reader.php', + 'MaxMind\\Db\\Reader\\Decoder' => __DIR__ . '/..' . '/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php', + 'MaxMind\\Db\\Reader\\InvalidDatabaseException' => __DIR__ . '/..' . '/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php', + 'MaxMind\\Db\\Reader\\Metadata' => __DIR__ . '/..' . '/maxmind-db/reader/src/MaxMind/Db/Reader/Metadata.php', + 'MaxMind\\Db\\Reader\\Util' => __DIR__ . '/..' . '/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php', ); public static function getInitializer(ClassLoader $loader) diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index 96bab95..036e067 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -110,6 +110,73 @@ "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" }, "install-path": "../dasprid/enum" + }, + { + "name": "maxmind-db/reader", + "version": "v1.13.1", + "version_normalized": "1.13.1.0", + "source": { + "type": "git", + "url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git", + "reference": "2194f58d0f024ce923e685cdf92af3daf9951908" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/2194f58d0f024ce923e685cdf92af3daf9951908", + "reference": "2194f58d0f024ce923e685cdf92af3daf9951908", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "conflict": { + "ext-maxminddb": "<1.11.1 || >=2.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpstan/phpstan": "*", + "phpunit/phpunit": ">=8.0.0,<10.0.0", + "squizlabs/php_codesniffer": "4.*" + }, + "suggest": { + "ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-maxminddb": "A C-based database decoder that provides significantly faster lookups", + "maxmind-db/reader-ext": "C extension for significantly faster IP lookups (install via PIE: pie install maxmind-db/reader-ext)" + }, + "time": "2025-11-21T22:24:26+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "MaxMind\\Db\\": "src/MaxMind/Db" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Gregory J. Oschwald", + "email": "goschwald@maxmind.com", + "homepage": "https://www.maxmind.com/" + } + ], + "description": "MaxMind DB Reader API", + "homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php", + "keywords": [ + "database", + "geoip", + "geoip2", + "geolocation", + "maxmind" + ], + "support": { + "issues": "https://github.com/maxmind/MaxMind-DB-Reader-php/issues", + "source": "https://github.com/maxmind/MaxMind-DB-Reader-php/tree/v1.13.1" + }, + "install-path": "../maxmind-db/reader" } ], "dev": false, diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index 7bc281a..81eade8 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -28,6 +28,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'maxmind-db/reader' => array( + 'pretty_version' => 'v1.13.1', + 'version' => '1.13.1.0', + 'reference' => '2194f58d0f024ce923e685cdf92af3daf9951908', + 'type' => 'library', + 'install_path' => __DIR__ . '/../maxmind-db/reader', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'robotstxt/robotstxt-2fa' => array( 'pretty_version' => 'dev-main', 'version' => 'dev-main', diff --git a/vendor/maxmind-db/reader/CHANGELOG.md b/vendor/maxmind-db/reader/CHANGELOG.md new file mode 100644 index 0000000..cd63841 --- /dev/null +++ b/vendor/maxmind-db/reader/CHANGELOG.md @@ -0,0 +1,261 @@ +CHANGELOG +========= + +1.13.1 (2025-11-21) +------------------- + +* First PIE release. No other changes. + +1.13.0 (2025-11-20) +------------------- + +* A redundant `filesize()` call in the reader's constructor was removed. + Pull request by Pavel Djundik. GitHub #189. + +1.12.1 (2025-05-05) +------------------- + +* The C extension now checks that the database metadata lookup was + successful. + +1.12.0 (2024-11-14) +------------------- + +* Improve the error handling when the user tries to open a directory + with the pure PHP reader. +* Improve the typehints on arrays in the PHPDocs. + +1.11.1 (2023-12-01) +------------------- + +* Resolve warnings when compiling the C extension. +* Fix various type issues detected by PHPStan level. Pull request by + LauraTaylorUK. GitHub #160. + +1.11.0 (2021-10-18) +------------------- + +* Replace runtime define of a constant to facilitate opcache preloading. + Reported by vedadkajtaz. GitHub #134. +* Resolve minor issue found by the Clang static analyzer in the C + extension. + +1.10.1 (2021-04-14) +------------------- + +* Fix a `TypeError` exception in the pure PHP reader when using large + databases on 32-bit PHP builds with the `bcmath` extension. Reported + by dodo1708. GitHub #124. + +1.10.0 (2021-02-09) +------------------- + +* When using the pure PHP reader, unsigned integers up to PHP_MAX_INT + will now be integers in PHP rather than strings. Previously integers + greater than 2^24 on 32-bit platforms and 2^56 on 64-bit platforms + would be strings due to the use of `gmp` or `bcmath` to decode them. + Reported by Alejandro Celaya. GitHub #119. + +1.9.0 (2021-01-07) +------------------ + +* The `maxminddb` extension is now buildable on Windows. Pull request + by Jan Ehrhardt. GitHub #115. + +1.8.0 (2020-10-01) +------------------ + +* Fixes for PHP 8.0. Pull Request by Remi Collet. GitHub #108. + +1.7.0 (2020-08-07) +------------------ + +* IMPORTANT: PHP 7.2 or greater is now required. +* The extension no longer depends on the pure PHP classes in + `maxmind-db/reader`. You can use it independently. +* Type hints have been added to both the pure PHP implementation + and the extension. +* The `metadata` method on the reader now returns a new copy of the + metadata object rather than the actual object used by the reader. +* Work around PHP `is_readable()` bug. Reported by Ben Roberts. GitHub + #92. +* This is the first release of the extension as a PECL package. + GitHub #34. + +1.6.0 (2019-12-19) +------------------ + +* 1.5.0 and 1.5.1 contained a possible memory corruptions when using + `getWithPrefixLen`. This has been fixed. Reported by proton-ab. + GitHub #96. +* The `composer.json` file now conflicts with all versions of the + `maxminddb` C extension less than the Composer version. This is to + reduce the chance of having an older, conflicting version of the + extension installed. You will need to upgrade the extension before + running `composer update`. Pull request by Benoît Burnichon. GitHub + #97. + +1.5.1 (2019-12-12) +------------------ + +* Minor performance improvements. +* Make tests pass with older versions of libmaxminddb. PR by Remi + Collet. GitHub #90. +* Test enhancements. PR by Chun-Sheng, Li. GitHub #91. + +1.5.0 (2019-09-30) +------------------ + +* PHP 5.6 or greater is now required. +* The C extension now supports PHP 8. Pull request by John Boehr. + GitHub #87. +* A new method, `getWithPrefixLen`, was added to the `Reader` class. + This method returns an array containing the record and the prefix + length for that record. GitHub #89. + +1.4.1 (2019-01-04) +------------------ + +* The `maxminddb` extension now returns a string when a `uint32` + value is greater than `LONG_MAX`. Previously, the value would + overflow. This generally only affects 32-bit machines. Reported + by Remi Collet. GitHub #79. +* For `uint64` values, the `maxminddb` extension now returns an + integer rather than a string when the value is less than or equal + to `LONG_MAX`. This more closely matches the behavior of the pure + PHP reader. + +1.4.0 (2018-11-20) +------------------ + +* The `maxminddb` extension now has the arginfo when using reflection. + PR by Remi Collet. GitHub #75. +* The `maxminddb` extension now provides `MINFO()` function that + displays the extension version and the libmaxminddb version. PR by + Remi Collet. GitHub #74. +* The `maxminddb` `configure` script now uses `pkg-config` when + available to get libmaxmindb build info. PR by Remi Collet. + GitHub #73. +* The pure PHP reader now correctly decodes integers on 32-bit platforms. + Previously, large integers would overflow. Reported by Remi Collet. + GitHub #77. +* There are small performance improvements for the pure PHP reader. + +1.3.0 (2018-02-21) +------------------ + +* IMPORTANT: The `maxminddb` extension now obeys `open_basedir`. If + `open_basedir` is set, you _must_ store the database within the + specified directory. Placing the file outside of this directory + will result in an exception. Please test your integration before + upgrading the extension. This does not affect the pure PHP + implementation, which has always had this restriction. Reported + by Benoît Burnichon. GitHub #61. +* A custom `autoload.php` file is provided for installations without + Composer. GitHub #56. + +1.2.0 (2017-10-27) +------------------ + +* PHP 5.4 or greater is now required. +* The `Reader` class for the `maxminddb` extension is no longer final. + This was change to match the behavior of the pure PHP class. + Reported and fixed by venyii. GitHub #52 & #54. + +1.1.3 (2017-01-19) +------------------ + +* Fix incorrect version in `ext/php_maxminddb.h`. GitHub #48. + +1.1.2 (2016-11-22) +------------------ + +* Searching for database metadata only occurs within the last 128KB + (128 * 1024 bytes) of the file, speeding detection of corrupt + datafiles. Reported by Eric Teubert. GitHub #42. +* Suggest relevant extensions when installing with Composer. GitHub #37. + +1.1.1 (2016-09-15) +------------------ + +* Development files were added to the `.gitattributes` as `export-ignore` so + that they are not part of the Composer release. Pull request by Michele + Locati. GitHub #39. + +1.1.0 (2016-01-04) +------------------ + +* The MaxMind DB extension now supports PHP 7. Pull request by John Boehr. + GitHub #27. + +1.0.3 (2015-03-13) +------------------ + +* All uses of `strlen` were removed. This should prevent issues in situations + where the function is overloaded or otherwise broken. + +1.0.2 (2015-01-19) +------------------ + +* Previously the MaxMind DB extension would cause a segfault if the Reader + object's destructor was called without first having called the constructor. + (Reported by Matthias Saou & Juan Peri. GitHub #20.) + +1.0.1 (2015-01-12) +------------------ + +* In the last several releases, the version number in the extension was + incorrect. This release is being done to correct it. No other code changes + are included. + +1.0.0 (2014-09-22) +------------------ + +* First production release. +* In the pure PHP reader, a string length test after `fread()` was replaced + with the difference between the start pointer and the end pointer. This + provided a 15% speed increase. + +0.3.3 (2014-09-15) +------------------ + +* Clarified behavior of 128-bit type in documentation. +* Updated phpunit and fixed some test breakage from the newer version. + +0.3.2 (2014-09-10) +------------------ + +* Fixed invalid reference to global class RuntimeException from namespaced + code. Fixed by Steven Don. GitHub issue #15. +* Additional documentation of `Metadata` class as well as misc. documentation + cleanup. + +0.3.1 (2014-05-01) +------------------ + +* The API now works when `mbstring.func_overload` is set. +* BCMath is no longer required. If the decoder encounters a big integer, + it will try to use GMP and then BCMath. If both of those fail, it will + throw an exception. No databases released by MaxMind currently use big + integers. +* The API now officially supports HHVM when using the pure PHP reader. + +0.3.0 (2014-02-19) +------------------ + +* This API is now licensed under the Apache License, Version 2.0. +* The code for the C extension was cleaned up, fixing several potential + issues. + +0.2.0 (2013-10-21) +------------------ + +* Added optional C extension for using libmaxminddb in place of the pure PHP + reader. +* Significantly improved error handling in pure PHP reader. +* Improved performance for IPv4 lookups in an IPv6 database. + +0.1.0 (2013-07-16) +------------------ + +* Initial release diff --git a/vendor/maxmind-db/reader/LICENSE b/vendor/maxmind-db/reader/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/vendor/maxmind-db/reader/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/maxmind-db/reader/README.md b/vendor/maxmind-db/reader/README.md new file mode 100644 index 0000000..9843cf5 --- /dev/null +++ b/vendor/maxmind-db/reader/README.md @@ -0,0 +1,214 @@ +# MaxMind DB Reader PHP API # + +## Description ## + +This is the PHP API for reading MaxMind DB files. MaxMind DB is a binary file +format that stores data indexed by IP address subnets (IPv4 or IPv6). + +## Installation ## + +### C Extension (Recommended for Performance) ### + +For significantly faster IP lookups, we recommend installing the C extension via +[PIE](https://github.com/php/pie): + +```bash +pie install maxmind-db/reader-ext +``` + +The C extension requires the [libmaxminddb](https://github.com/maxmind/libmaxminddb) +C library. See the [installation instructions](https://github.com/maxmind/MaxMind-DB-Reader-php-ext#prerequisites) +for your platform. + +### Pure PHP (No Compilation Required) ### + +If you prefer not to compile a C extension or need maximum portability, you can +install the pure PHP implementation with [Composer](https://getcomposer.org/). + +### Download Composer ### + +To download Composer, run in the root directory of your project: + +```bash +curl -sS https://getcomposer.org/installer | php +``` + +You should now have the file `composer.phar` in your project directory. + +### Install Dependencies ### + +Run in your project root: + +``` +php composer.phar require maxmind-db/reader:^1.13.1 +``` + +You should now have the files `composer.json` and `composer.lock` as well as +the directory `vendor` in your project directory. If you use a version control +system, `composer.json` should be added to it. + +### Require Autoloader ### + +After installing the dependencies, you need to require the Composer autoloader +from your code: + +```php +require 'vendor/autoload.php'; +``` + +## Installation (Standalone) ## + +If you don't want to use Composer for some reason, a custom +`autoload.php` is provided for you in the project root. To use the +library, simply include that file, + +```php +require('/path/to/MaxMind-DB-Reader-php/autoload.php'); +``` + +and then instantiate the reader class normally: + +```php +use MaxMind\Db\Reader; +$reader = new Reader('example.mmdb'); +``` + +## Installation (RPM) + +RPMs are available in the [official Fedora repository](https://apps.fedoraproject.org/packages/php-maxminddb). + +To install on Fedora, run: + +```bash +dnf install php-maxminddb +``` + +To install on CentOS or RHEL 7, first [enable the EPEL repository](https://fedoraproject.org/wiki/EPEL) +and then run: + +```bash +yum install php-maxminddb +``` + +Please note that these packages are *not* maintained by MaxMind. + +## Usage ## + +## Example ## + +```php +get($ipAddress)); + +// getWithPrefixLen returns an array containing the record and the +// associated prefix length for that record. +print_r($reader->getWithPrefixLen($ipAddress)); + +$reader->close(); +``` + +## Optional PHP C Extension ## + +MaxMind provides an optional C extension that is a drop-in replacement for +`MaxMind\Db\Reader`. In order to use this extension, you must install the +Reader API as described above and install the extension as described below. If +you are using an autoloader, no changes to your code should be necessary. + +### Installing Extension via PIE (Recommended) ### + +We recommend installing the extension via [PIE](https://github.com/php/pie): + +```bash +pie install maxmind-db/reader-ext +``` + +See the [extension repository](https://github.com/maxmind/MaxMind-DB-Reader-php-ext#prerequisites) +for prerequisites including libmaxminddb installation instructions. + +### Installing Extension via PECL (Legacy) ### + +First install [libmaxminddb](https://github.com/maxmind/libmaxminddb) as +described in its [README.md +file](https://github.com/maxmind/libmaxminddb/blob/main/README.md#installing-from-a-tarball). +After successfully installing libmaxmindb, you may install the extension +from [PECL](https://pecl.php.net/package/maxminddb): + +``` +pecl install maxminddb +``` + +### Installing Extension from Source ### + +Alternatively, you may install it from the source. To do so, run the following +commands from the top-level directory of this distribution: + +``` +cd ext +phpize +./configure +make +make test +sudo make install +``` + +You then must load your extension. The recommended method is to add the +following to your `php.ini` file: + +``` +extension=maxminddb.so +``` + +Note: You may need to install the PHP development package on your OS such as +php5-dev for Debian-based systems or php-devel for RedHat/Fedora-based ones. + +## 128-bit Integer Support ## + +The MaxMind DB format includes 128-bit unsigned integer as a type. Although +no MaxMind-distributed database currently makes use of this type, both the +pure PHP reader and the C extension support this type. The pure PHP reader +requires gmp or bcmath to read databases with 128-bit unsigned integers. + +The integer is currently returned as a hexadecimal string (prefixed with "0x") +by the C extension and a decimal string (no prefix) by the pure PHP reader. +Any change to make the reader implementations always return either a +hexadecimal or decimal representation of the integer will NOT be considered a +breaking change. + +## Support ## + +Please report all issues with this code using the [GitHub issue tracker](https://github.com/maxmind/MaxMind-DB-Reader-php/issues). + +If you are having an issue with a MaxMind service that is not specific to the +client API, please see [our support page](https://www.maxmind.com/en/support). + +## Requirements ## + +This library requires PHP 7.2 or greater. + +The GMP or BCMath extension may be required to read some databases +using the pure PHP API. + +## Contributing ## + +Patches and pull requests are encouraged. All code should follow the PSR-1 and +PSR-2 style guidelines. Please include unit tests whenever possible. + +## Versioning ## + +The MaxMind DB Reader PHP API uses [Semantic Versioning](https://semver.org/). + +## Copyright and License ## + +This software is Copyright (c) 2014-2025 by MaxMind, Inc. + +This is free software, licensed under the Apache License, Version 2.0. diff --git a/vendor/maxmind-db/reader/autoload.php b/vendor/maxmind-db/reader/autoload.php new file mode 100644 index 0000000..fdd2f1c --- /dev/null +++ b/vendor/maxmind-db/reader/autoload.php @@ -0,0 +1,47 @@ +class. + * + * @param string $class + * the name of the class to load + */ +function mmdb_autoload($class): void +{ + /* + * A project-specific mapping between the namespaces and where + * they're located. By convention, we include the trailing + * slashes. The one-element array here simply makes things easy + * to extend in the future if (for example) the test classes + * begin to use one another. + */ + $namespace_map = ['MaxMind\Db\\' => __DIR__ . '/src/MaxMind/Db/']; + + foreach ($namespace_map as $prefix => $dir) { + // First swap out the namespace prefix with a directory... + $path = str_replace($prefix, $dir, $class); + + // replace the namespace separator with a directory separator... + $path = str_replace('\\', '/', $path); + + // and finally, add the PHP file extension to the result. + $path .= '.php'; + + // $path should now contain the path to a PHP file defining $class + if (file_exists($path)) { + include $path; + } + } +} + +spl_autoload_register('mmdb_autoload'); diff --git a/vendor/maxmind-db/reader/composer.json b/vendor/maxmind-db/reader/composer.json new file mode 100644 index 0000000..f33af98 --- /dev/null +++ b/vendor/maxmind-db/reader/composer.json @@ -0,0 +1,43 @@ +{ + "name": "maxmind-db/reader", + "description": "MaxMind DB Reader API", + "keywords": ["database", "geoip", "geoip2", "geolocation", "maxmind"], + "homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php", + "type": "library", + "license": "Apache-2.0", + "authors": [ + { + "name": "Gregory J. Oschwald", + "email": "goschwald@maxmind.com", + "homepage": "https://www.maxmind.com/" + } + ], + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-maxminddb": "A C-based database decoder that provides significantly faster lookups", + "maxmind-db/reader-ext": "C extension for significantly faster IP lookups (install via PIE: pie install maxmind-db/reader-ext)" + }, + "conflict": { + "ext-maxminddb": "<1.11.1 || >=2.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpunit/phpunit": ">=8.0.0,<10.0.0", + "squizlabs/php_codesniffer": "4.*", + "phpstan/phpstan": "*" + }, + "autoload": { + "psr-4": { + "MaxMind\\Db\\": "src/MaxMind/Db" + } + }, + "autoload-dev": { + "psr-4": { + "MaxMind\\Db\\Test\\Reader\\": "tests/MaxMind/Db/Test/Reader" + } + } +} diff --git a/vendor/maxmind-db/reader/ext/config.m4 b/vendor/maxmind-db/reader/ext/config.m4 new file mode 100644 index 0000000..c09151e --- /dev/null +++ b/vendor/maxmind-db/reader/ext/config.m4 @@ -0,0 +1,40 @@ +PHP_ARG_WITH(maxminddb, + [Whether to enable the MaxMind DB Reader extension], + [ --with-maxminddb Enable MaxMind DB Reader extension support]) + +PHP_ARG_ENABLE(maxminddb-debug, for MaxMind DB debug support, + [ --enable-maxminddb-debug Enable MaxMind DB debug support], no, no) + +if test $PHP_MAXMINDDB != "no"; then + + AC_PATH_PROG(PKG_CONFIG, pkg-config, no) + + AC_MSG_CHECKING(for libmaxminddb) + if test -x "$PKG_CONFIG" && $PKG_CONFIG --exists libmaxminddb; then + dnl retrieve build options from pkg-config + if $PKG_CONFIG libmaxminddb --atleast-version 1.0.0; then + LIBMAXMINDDB_INC=`$PKG_CONFIG libmaxminddb --cflags` + LIBMAXMINDDB_LIB=`$PKG_CONFIG libmaxminddb --libs` + LIBMAXMINDDB_VER=`$PKG_CONFIG libmaxminddb --modversion` + AC_MSG_RESULT(found version $LIBMAXMINDDB_VER) + else + AC_MSG_ERROR(system libmaxminddb must be upgraded to version >= 1.0.0) + fi + PHP_EVAL_LIBLINE($LIBMAXMINDDB_LIB, MAXMINDDB_SHARED_LIBADD) + PHP_EVAL_INCLINE($LIBMAXMINDDB_INC) + else + AC_MSG_RESULT(pkg-config information missing) + AC_MSG_WARN(will use libmaxmxinddb from compiler default path) + + PHP_CHECK_LIBRARY(maxminddb, MMDB_open) + PHP_ADD_LIBRARY(maxminddb, 1, MAXMINDDB_SHARED_LIBADD) + fi + + if test $PHP_MAXMINDDB_DEBUG != "no"; then + CFLAGS="$CFLAGS -Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Werror" + fi + + PHP_SUBST(MAXMINDDB_SHARED_LIBADD) + + PHP_NEW_EXTENSION(maxminddb, maxminddb.c, $ext_shared) +fi diff --git a/vendor/maxmind-db/reader/ext/config.w32 b/vendor/maxmind-db/reader/ext/config.w32 new file mode 100644 index 0000000..4eb18f8 --- /dev/null +++ b/vendor/maxmind-db/reader/ext/config.w32 @@ -0,0 +1,10 @@ +ARG_WITH("maxminddb", "Enable MaxMind DB Reader extension support", "no"); + +if (PHP_MAXMINDDB == "yes") { + if (CHECK_HEADER_ADD_INCLUDE("maxminddb.h", "CFLAGS_MAXMINDDB", PHP_MAXMINDDB + ";" + PHP_PHP_BUILD + "\\include\\maxminddb") && + CHECK_LIB("libmaxminddb.lib", "maxminddb", PHP_MAXMINDDB)) { + EXTENSION("maxminddb", "maxminddb.c"); + } else { + WARNING('Could not find maxminddb.h or libmaxminddb.lib; skipping'); + } +} diff --git a/vendor/maxmind-db/reader/ext/maxminddb.c b/vendor/maxmind-db/reader/ext/maxminddb.c new file mode 100644 index 0000000..b4e078b --- /dev/null +++ b/vendor/maxmind-db/reader/ext/maxminddb.c @@ -0,0 +1,819 @@ +/* MaxMind, Inc., licenses this file to you under the Apache License, Version + * 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +#include "php_maxminddb.h" + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include + +#include "Zend/zend_exceptions.h" +#include "Zend/zend_types.h" +#include "ext/spl/spl_exceptions.h" +#include "ext/standard/info.h" +#include + +#ifdef ZTS +#include +#endif + +#define __STDC_FORMAT_MACROS +#include + +#define PHP_MAXMINDDB_NS ZEND_NS_NAME("MaxMind", "Db") +#define PHP_MAXMINDDB_READER_NS ZEND_NS_NAME(PHP_MAXMINDDB_NS, "Reader") +#define PHP_MAXMINDDB_METADATA_NS \ + ZEND_NS_NAME(PHP_MAXMINDDB_READER_NS, "Metadata") +#define PHP_MAXMINDDB_READER_EX_NS \ + ZEND_NS_NAME(PHP_MAXMINDDB_READER_NS, "InvalidDatabaseException") + +#define Z_MAXMINDDB_P(zv) php_maxminddb_fetch_object(Z_OBJ_P(zv)) +typedef size_t strsize_t; +typedef zend_object free_obj_t; + +/* For PHP 8 compatibility */ +#if PHP_VERSION_ID < 80000 + +#define PROP_OBJ(zv) (zv) + +#else + +#define PROP_OBJ(zv) Z_OBJ_P(zv) + +#define TSRMLS_C +#define TSRMLS_CC +#define TSRMLS_DC + +/* End PHP 8 compatibility */ +#endif + +#ifndef ZEND_ACC_CTOR +#define ZEND_ACC_CTOR 0 +#endif + +/* IS_MIXED was added in 2020 */ +#ifndef IS_MIXED +#define IS_MIXED IS_UNDEF +#endif + +/* ZEND_THIS was added in 7.4 */ +#ifndef ZEND_THIS +#define ZEND_THIS (&EX(This)) +#endif + +typedef struct _maxminddb_obj { + MMDB_s *mmdb; + zend_object std; +} maxminddb_obj; + +PHP_FUNCTION(maxminddb); + +static int +get_record(INTERNAL_FUNCTION_PARAMETERS, zval *record, int *prefix_len); +static const MMDB_entry_data_list_s * +handle_entry_data_list(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static const MMDB_entry_data_list_s * +handle_array(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static const MMDB_entry_data_list_s * +handle_map(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static void handle_uint128(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static void handle_uint64(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); +static void handle_uint32(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC); + +#define CHECK_ALLOCATED(val) \ + if (!val) { \ + zend_error(E_ERROR, "Out of memory"); \ + return; \ + } + +static zend_object_handlers maxminddb_obj_handlers; +static zend_class_entry *maxminddb_ce, *maxminddb_exception_ce, *metadata_ce; + +static inline maxminddb_obj * +php_maxminddb_fetch_object(zend_object *obj TSRMLS_DC) { + return (maxminddb_obj *)((char *)(obj)-XtOffsetOf(maxminddb_obj, std)); +} + +ZEND_BEGIN_ARG_INFO_EX(arginfo_maxminddbreader_construct, 0, 0, 1) +ZEND_ARG_TYPE_INFO(0, db_file, IS_STRING, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader, __construct) { + char *db_file = NULL; + strsize_t name_len; + zval *_this_zval = NULL; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "Os", + &_this_zval, + maxminddb_ce, + &db_file, + &name_len) == FAILURE) { + return; + } + + if (0 != php_check_open_basedir(db_file TSRMLS_CC) || + 0 != access(db_file, R_OK)) { + zend_throw_exception_ex( + spl_ce_InvalidArgumentException, + 0 TSRMLS_CC, + "The file \"%s\" does not exist or is not readable.", + db_file); + return; + } + + MMDB_s *mmdb = (MMDB_s *)ecalloc(1, sizeof(MMDB_s)); + int const status = MMDB_open(db_file, MMDB_MODE_MMAP, mmdb); + + if (MMDB_SUCCESS != status) { + zend_throw_exception_ex( + maxminddb_exception_ce, + 0 TSRMLS_CC, + "Error opening database file (%s). Is this a valid " + "MaxMind DB file?", + db_file); + efree(mmdb); + return; + } + + maxminddb_obj *mmdb_obj = Z_MAXMINDDB_P(ZEND_THIS); + mmdb_obj->mmdb = mmdb; +} + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX( + arginfo_maxminddbreader_get, 0, 1, IS_MIXED, 1) +ZEND_ARG_TYPE_INFO(0, ip_address, IS_STRING, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader, get) { + int prefix_len = 0; + get_record(INTERNAL_FUNCTION_PARAM_PASSTHRU, return_value, &prefix_len); +} + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX( + arginfo_maxminddbreader_getWithPrefixLen, 0, 1, IS_ARRAY, 1) +ZEND_ARG_TYPE_INFO(0, ip_address, IS_STRING, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader, getWithPrefixLen) { + zval record, z_prefix_len; + + int prefix_len = 0; + if (get_record(INTERNAL_FUNCTION_PARAM_PASSTHRU, &record, &prefix_len) == + FAILURE) { + return; + } + + array_init(return_value); + add_next_index_zval(return_value, &record); + + ZVAL_LONG(&z_prefix_len, prefix_len); + add_next_index_zval(return_value, &z_prefix_len); +} + +static int +get_record(INTERNAL_FUNCTION_PARAMETERS, zval *record, int *prefix_len) { + char *ip_address = NULL; + strsize_t name_len; + zval *this_zval = NULL; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "Os", + &this_zval, + maxminddb_ce, + &ip_address, + &name_len) == FAILURE) { + return FAILURE; + } + + const maxminddb_obj *mmdb_obj = (maxminddb_obj *)Z_MAXMINDDB_P(ZEND_THIS); + + MMDB_s *mmdb = mmdb_obj->mmdb; + + if (NULL == mmdb) { + zend_throw_exception_ex(spl_ce_BadMethodCallException, + 0 TSRMLS_CC, + "Attempt to read from a closed MaxMind DB."); + return FAILURE; + } + + struct addrinfo hints = { + .ai_family = AF_UNSPEC, + .ai_flags = AI_NUMERICHOST, + /* We set ai_socktype so that we only get one result back */ + .ai_socktype = SOCK_STREAM}; + + struct addrinfo *addresses = NULL; + int gai_status = getaddrinfo(ip_address, NULL, &hints, &addresses); + if (gai_status) { + zend_throw_exception_ex(spl_ce_InvalidArgumentException, + 0 TSRMLS_CC, + "The value \"%s\" is not a valid IP address.", + ip_address); + return FAILURE; + } + if (!addresses || !addresses->ai_addr) { + zend_throw_exception_ex( + spl_ce_InvalidArgumentException, + 0 TSRMLS_CC, + "getaddrinfo was successful but failed to set the addrinfo"); + return FAILURE; + } + + int sa_family = addresses->ai_addr->sa_family; + + int mmdb_error = MMDB_SUCCESS; + MMDB_lookup_result_s result = + MMDB_lookup_sockaddr(mmdb, addresses->ai_addr, &mmdb_error); + + freeaddrinfo(addresses); + + if (MMDB_SUCCESS != mmdb_error) { + zend_class_entry *ex; + if (MMDB_IPV6_LOOKUP_IN_IPV4_DATABASE_ERROR == mmdb_error) { + ex = spl_ce_InvalidArgumentException; + } else { + ex = maxminddb_exception_ce; + } + zend_throw_exception_ex(ex, + 0 TSRMLS_CC, + "Error looking up %s. %s", + ip_address, + MMDB_strerror(mmdb_error)); + return FAILURE; + } + + *prefix_len = result.netmask; + + if (sa_family == AF_INET && mmdb->metadata.ip_version == 6) { + /* We return the prefix length given the IPv4 address. If there is + no IPv4 subtree, we return a prefix length of 0. */ + *prefix_len = *prefix_len >= 96 ? *prefix_len - 96 : 0; + } + + if (!result.found_entry) { + ZVAL_NULL(record); + return SUCCESS; + } + + MMDB_entry_data_list_s *entry_data_list = NULL; + int status = MMDB_get_entry_data_list(&result.entry, &entry_data_list); + + if (MMDB_SUCCESS != status) { + zend_throw_exception_ex(maxminddb_exception_ce, + 0 TSRMLS_CC, + "Error while looking up data for %s. %s", + ip_address, + MMDB_strerror(status)); + MMDB_free_entry_data_list(entry_data_list); + return FAILURE; + } else if (NULL == entry_data_list) { + zend_throw_exception_ex( + maxminddb_exception_ce, + 0 TSRMLS_CC, + "Error while looking up data for %s. Your database may " + "be corrupt or you have found a bug in libmaxminddb.", + ip_address); + return FAILURE; + } + + const MMDB_entry_data_list_s *rv = + handle_entry_data_list(entry_data_list, record TSRMLS_CC); + if (rv == NULL) { + /* We should have already thrown the exception in handle_entry_data_list + */ + return FAILURE; + } + MMDB_free_entry_data_list(entry_data_list); + return SUCCESS; +} + +ZEND_BEGIN_ARG_INFO_EX(arginfo_maxminddbreader_void, 0, 0, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader, metadata) { + zval *this_zval = NULL; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "O", + &this_zval, + maxminddb_ce) == FAILURE) { + return; + } + + const maxminddb_obj *const mmdb_obj = + (maxminddb_obj *)Z_MAXMINDDB_P(this_zval); + + if (NULL == mmdb_obj->mmdb) { + zend_throw_exception_ex(spl_ce_BadMethodCallException, + 0 TSRMLS_CC, + "Attempt to read from a closed MaxMind DB."); + return; + } + + object_init_ex(return_value, metadata_ce); + + MMDB_entry_data_list_s *entry_data_list; + int status = + MMDB_get_metadata_as_entry_data_list(mmdb_obj->mmdb, &entry_data_list); + if (status != MMDB_SUCCESS) { + zend_throw_exception_ex(maxminddb_exception_ce, + 0 TSRMLS_CC, + "Error while decoding metadata. %s", + MMDB_strerror(status)); + return; + } + + zval metadata_array; + const MMDB_entry_data_list_s *rv = + handle_entry_data_list(entry_data_list, &metadata_array TSRMLS_CC); + if (rv == NULL) { + return; + } + MMDB_free_entry_data_list(entry_data_list); + zend_call_method_with_1_params(PROP_OBJ(return_value), + metadata_ce, + &metadata_ce->constructor, + ZEND_CONSTRUCTOR_FUNC_NAME, + NULL, + &metadata_array); + zval_ptr_dtor(&metadata_array); +} + +PHP_METHOD(MaxMind_Db_Reader, close) { + zval *this_zval = NULL; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "O", + &this_zval, + maxminddb_ce) == FAILURE) { + return; + } + + maxminddb_obj *mmdb_obj = (maxminddb_obj *)Z_MAXMINDDB_P(this_zval); + + if (NULL == mmdb_obj->mmdb) { + zend_throw_exception_ex(spl_ce_BadMethodCallException, + 0 TSRMLS_CC, + "Attempt to close a closed MaxMind DB."); + return; + } + MMDB_close(mmdb_obj->mmdb); + efree(mmdb_obj->mmdb); + mmdb_obj->mmdb = NULL; +} + +static const MMDB_entry_data_list_s * +handle_entry_data_list(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + switch (entry_data_list->entry_data.type) { + case MMDB_DATA_TYPE_MAP: + return handle_map(entry_data_list, z_value TSRMLS_CC); + case MMDB_DATA_TYPE_ARRAY: + return handle_array(entry_data_list, z_value TSRMLS_CC); + case MMDB_DATA_TYPE_UTF8_STRING: + ZVAL_STRINGL(z_value, + entry_data_list->entry_data.utf8_string, + entry_data_list->entry_data.data_size); + break; + case MMDB_DATA_TYPE_BYTES: + ZVAL_STRINGL(z_value, + (char const *)entry_data_list->entry_data.bytes, + entry_data_list->entry_data.data_size); + break; + case MMDB_DATA_TYPE_DOUBLE: + ZVAL_DOUBLE(z_value, entry_data_list->entry_data.double_value); + break; + case MMDB_DATA_TYPE_FLOAT: + ZVAL_DOUBLE(z_value, entry_data_list->entry_data.float_value); + break; + case MMDB_DATA_TYPE_UINT16: + ZVAL_LONG(z_value, entry_data_list->entry_data.uint16); + break; + case MMDB_DATA_TYPE_UINT32: + handle_uint32(entry_data_list, z_value TSRMLS_CC); + break; + case MMDB_DATA_TYPE_BOOLEAN: + ZVAL_BOOL(z_value, entry_data_list->entry_data.boolean); + break; + case MMDB_DATA_TYPE_UINT64: + handle_uint64(entry_data_list, z_value TSRMLS_CC); + break; + case MMDB_DATA_TYPE_UINT128: + handle_uint128(entry_data_list, z_value TSRMLS_CC); + break; + case MMDB_DATA_TYPE_INT32: + ZVAL_LONG(z_value, entry_data_list->entry_data.int32); + break; + default: + zend_throw_exception_ex(maxminddb_exception_ce, + 0 TSRMLS_CC, + "Invalid data type arguments: %d", + entry_data_list->entry_data.type); + return NULL; + } + return entry_data_list; +} + +static const MMDB_entry_data_list_s * +handle_map(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + array_init(z_value); + const uint32_t map_size = entry_data_list->entry_data.data_size; + + uint32_t i; + for (i = 0; i < map_size && entry_data_list; i++) { + entry_data_list = entry_data_list->next; + + char *key = estrndup(entry_data_list->entry_data.utf8_string, + entry_data_list->entry_data.data_size); + if (NULL == key) { + zend_throw_exception_ex(maxminddb_exception_ce, + 0 TSRMLS_CC, + "Invalid data type arguments"); + return NULL; + } + + entry_data_list = entry_data_list->next; + zval new_value; + entry_data_list = + handle_entry_data_list(entry_data_list, &new_value TSRMLS_CC); + if (entry_data_list != NULL) { + add_assoc_zval(z_value, key, &new_value); + } + efree(key); + } + return entry_data_list; +} + +static const MMDB_entry_data_list_s * +handle_array(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + const uint32_t size = entry_data_list->entry_data.data_size; + + array_init(z_value); + + uint32_t i; + for (i = 0; i < size && entry_data_list; i++) { + entry_data_list = entry_data_list->next; + zval new_value; + entry_data_list = + handle_entry_data_list(entry_data_list, &new_value TSRMLS_CC); + if (entry_data_list != NULL) { + add_next_index_zval(z_value, &new_value); + } + } + return entry_data_list; +} + +static void handle_uint128(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + uint64_t high = 0; + uint64_t low = 0; +#if MMDB_UINT128_IS_BYTE_ARRAY + int i; + for (i = 0; i < 8; i++) { + high = (high << 8) | entry_data_list->entry_data.uint128[i]; + } + + for (i = 8; i < 16; i++) { + low = (low << 8) | entry_data_list->entry_data.uint128[i]; + } +#else + high = entry_data_list->entry_data.uint128 >> 64; + low = (uint64_t)entry_data_list->entry_data.uint128; +#endif + + char *num_str; + spprintf(&num_str, 0, "0x%016" PRIX64 "%016" PRIX64, high, low); + CHECK_ALLOCATED(num_str); + + ZVAL_STRING(z_value, num_str); + efree(num_str); +} + +static void handle_uint32(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + uint32_t val = entry_data_list->entry_data.uint32; + +#if LONG_MAX >= UINT32_MAX + ZVAL_LONG(z_value, val); + return; +#else + if (val <= LONG_MAX) { + ZVAL_LONG(z_value, val); + return; + } + + char *int_str; + spprintf(&int_str, 0, "%" PRIu32, val); + CHECK_ALLOCATED(int_str); + + ZVAL_STRING(z_value, int_str); + efree(int_str); +#endif +} + +static void handle_uint64(const MMDB_entry_data_list_s *entry_data_list, + zval *z_value TSRMLS_DC) { + uint64_t val = entry_data_list->entry_data.uint64; + +#if LONG_MAX >= UINT64_MAX + ZVAL_LONG(z_value, val); + return; +#else + if (val <= LONG_MAX) { + ZVAL_LONG(z_value, val); + return; + } + + char *int_str; + spprintf(&int_str, 0, "%" PRIu64, val); + CHECK_ALLOCATED(int_str); + + ZVAL_STRING(z_value, int_str); + efree(int_str); +#endif +} + +static void maxminddb_free_storage(free_obj_t *object TSRMLS_DC) { + maxminddb_obj *obj = + php_maxminddb_fetch_object((zend_object *)object TSRMLS_CC); + if (obj->mmdb != NULL) { + MMDB_close(obj->mmdb); + efree(obj->mmdb); + } + + zend_object_std_dtor(&obj->std TSRMLS_CC); +} + +static zend_object *maxminddb_create_handler(zend_class_entry *type TSRMLS_DC) { + maxminddb_obj *obj = (maxminddb_obj *)ecalloc(1, sizeof(maxminddb_obj)); + zend_object_std_init(&obj->std, type TSRMLS_CC); + object_properties_init(&(obj->std), type); + + obj->std.handlers = &maxminddb_obj_handlers; + + return &obj->std; +} + +/* clang-format off */ +static zend_function_entry maxminddb_methods[] = { + PHP_ME(MaxMind_Db_Reader, __construct, arginfo_maxminddbreader_construct, + ZEND_ACC_PUBLIC | ZEND_ACC_CTOR) + PHP_ME(MaxMind_Db_Reader, close, arginfo_maxminddbreader_void, ZEND_ACC_PUBLIC) + PHP_ME(MaxMind_Db_Reader, get, arginfo_maxminddbreader_get, ZEND_ACC_PUBLIC) + PHP_ME(MaxMind_Db_Reader, getWithPrefixLen, arginfo_maxminddbreader_getWithPrefixLen, ZEND_ACC_PUBLIC) + PHP_ME(MaxMind_Db_Reader, metadata, arginfo_maxminddbreader_void, ZEND_ACC_PUBLIC) + { NULL, NULL, NULL } +}; +/* clang-format on */ + +ZEND_BEGIN_ARG_INFO_EX(arginfo_metadata_construct, 0, 0, 1) +ZEND_ARG_TYPE_INFO(0, metadata, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +PHP_METHOD(MaxMind_Db_Reader_Metadata, __construct) { + zval *object = NULL; + zval *metadata_array = NULL; + zend_long node_count = 0; + zend_long record_size = 0; + + if (zend_parse_method_parameters(ZEND_NUM_ARGS() TSRMLS_CC, + getThis(), + "Oa", + &object, + metadata_ce, + &metadata_array) == FAILURE) { + return; + } + + zval *tmp = NULL; + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "binary_format_major_version", + sizeof("binary_format_major_version") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "binaryFormatMajorVersion", + sizeof("binaryFormatMajorVersion") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "binary_format_minor_version", + sizeof("binary_format_minor_version") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "binaryFormatMinorVersion", + sizeof("binaryFormatMinorVersion") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "build_epoch", + sizeof("build_epoch") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "buildEpoch", + sizeof("buildEpoch") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "database_type", + sizeof("database_type") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "databaseType", + sizeof("databaseType") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "description", + sizeof("description") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "description", + sizeof("description") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "ip_version", + sizeof("ip_version") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "ipVersion", + sizeof("ipVersion") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find( + HASH_OF(metadata_array), "languages", sizeof("languages") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "languages", + sizeof("languages") - 1, + tmp); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "record_size", + sizeof("record_size") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "recordSize", + sizeof("recordSize") - 1, + tmp); + if (Z_TYPE_P(tmp) == IS_LONG) { + record_size = Z_LVAL_P(tmp); + } + } + + if (record_size != 0) { + zend_update_property_long(metadata_ce, + PROP_OBJ(object), + "nodeByteSize", + sizeof("nodeByteSize") - 1, + record_size / 4); + } + + if ((tmp = zend_hash_str_find(HASH_OF(metadata_array), + "node_count", + sizeof("node_count") - 1))) { + zend_update_property(metadata_ce, + PROP_OBJ(object), + "nodeCount", + sizeof("nodeCount") - 1, + tmp); + if (Z_TYPE_P(tmp) == IS_LONG) { + node_count = Z_LVAL_P(tmp); + } + } + + if (record_size != 0) { + zend_update_property_long(metadata_ce, + PROP_OBJ(object), + "searchTreeSize", + sizeof("searchTreeSize") - 1, + record_size * node_count / 4); + } +} + +// clang-format off +static zend_function_entry metadata_methods[] = { + PHP_ME(MaxMind_Db_Reader_Metadata, __construct, arginfo_metadata_construct, ZEND_ACC_PUBLIC | ZEND_ACC_CTOR) + {NULL, NULL, NULL} +}; +// clang-format on + +PHP_MINIT_FUNCTION(maxminddb) { + zend_class_entry ce; + + INIT_CLASS_ENTRY(ce, PHP_MAXMINDDB_READER_EX_NS, NULL); + maxminddb_exception_ce = + zend_register_internal_class_ex(&ce, zend_ce_exception); + + INIT_CLASS_ENTRY(ce, PHP_MAXMINDDB_READER_NS, maxminddb_methods); + maxminddb_ce = zend_register_internal_class(&ce TSRMLS_CC); + maxminddb_ce->create_object = maxminddb_create_handler; + + INIT_CLASS_ENTRY(ce, PHP_MAXMINDDB_METADATA_NS, metadata_methods); + metadata_ce = zend_register_internal_class(&ce TSRMLS_CC); + zend_declare_property_null(metadata_ce, + "binaryFormatMajorVersion", + sizeof("binaryFormatMajorVersion") - 1, + ZEND_ACC_PUBLIC); + zend_declare_property_null(metadata_ce, + "binaryFormatMinorVersion", + sizeof("binaryFormatMinorVersion") - 1, + ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "buildEpoch", sizeof("buildEpoch") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null(metadata_ce, + "databaseType", + sizeof("databaseType") - 1, + ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "description", sizeof("description") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "ipVersion", sizeof("ipVersion") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "languages", sizeof("languages") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null(metadata_ce, + "nodeByteSize", + sizeof("nodeByteSize") - 1, + ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "nodeCount", sizeof("nodeCount") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null( + metadata_ce, "recordSize", sizeof("recordSize") - 1, ZEND_ACC_PUBLIC); + zend_declare_property_null(metadata_ce, + "searchTreeSize", + sizeof("searchTreeSize") - 1, + ZEND_ACC_PUBLIC); + + memcpy(&maxminddb_obj_handlers, + zend_get_std_object_handlers(), + sizeof(zend_object_handlers)); + maxminddb_obj_handlers.clone_obj = NULL; + maxminddb_obj_handlers.offset = XtOffsetOf(maxminddb_obj, std); + maxminddb_obj_handlers.free_obj = maxminddb_free_storage; + zend_declare_class_constant_string(maxminddb_ce, + "MMDB_LIB_VERSION", + sizeof("MMDB_LIB_VERSION") - 1, + MMDB_lib_version() TSRMLS_CC); + + return SUCCESS; +} + +static PHP_MINFO_FUNCTION(maxminddb) { + php_info_print_table_start(); + + php_info_print_table_row(2, "MaxMind DB Reader", "enabled"); + php_info_print_table_row( + 2, "maxminddb extension version", PHP_MAXMINDDB_VERSION); + php_info_print_table_row( + 2, "libmaxminddb library version", MMDB_lib_version()); + + php_info_print_table_end(); +} + +zend_module_entry maxminddb_module_entry = {STANDARD_MODULE_HEADER, + PHP_MAXMINDDB_EXTNAME, + NULL, + PHP_MINIT(maxminddb), + NULL, + NULL, + NULL, + PHP_MINFO(maxminddb), + PHP_MAXMINDDB_VERSION, + STANDARD_MODULE_PROPERTIES}; + +#ifdef COMPILE_DL_MAXMINDDB +ZEND_GET_MODULE(maxminddb) +#endif diff --git a/vendor/maxmind-db/reader/ext/php_maxminddb.h b/vendor/maxmind-db/reader/ext/php_maxminddb.h new file mode 100644 index 0000000..30e2461 --- /dev/null +++ b/vendor/maxmind-db/reader/ext/php_maxminddb.h @@ -0,0 +1,24 @@ +/* MaxMind, Inc., licenses this file to you under the Apache License, Version + * 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +#include + +#ifndef PHP_MAXMINDDB_H +#define PHP_MAXMINDDB_H 1 +#define PHP_MAXMINDDB_VERSION "1.13.1" +#define PHP_MAXMINDDB_EXTNAME "maxminddb" + +extern zend_module_entry maxminddb_module_entry; +#define phpext_maxminddb_ptr &maxminddb_module_entry + +#endif diff --git a/vendor/maxmind-db/reader/ext/tests/001-load.phpt b/vendor/maxmind-db/reader/ext/tests/001-load.phpt new file mode 100644 index 0000000..09810ee --- /dev/null +++ b/vendor/maxmind-db/reader/ext/tests/001-load.phpt @@ -0,0 +1,12 @@ +--TEST-- +Check for maxminddb presence +--SKIPIF-- + +--FILE-- + +--EXPECT-- +maxminddb extension is available diff --git a/vendor/maxmind-db/reader/ext/tests/002-final.phpt b/vendor/maxmind-db/reader/ext/tests/002-final.phpt new file mode 100644 index 0000000..d91b7d0 --- /dev/null +++ b/vendor/maxmind-db/reader/ext/tests/002-final.phpt @@ -0,0 +1,13 @@ +--TEST-- +Check that Reader class is not final +--SKIPIF-- + +--FILE-- +isFinal()); +?> +--EXPECT-- +bool(false) diff --git a/vendor/maxmind-db/reader/ext/tests/003-open-basedir.phpt b/vendor/maxmind-db/reader/ext/tests/003-open-basedir.phpt new file mode 100644 index 0000000..26e9781 --- /dev/null +++ b/vendor/maxmind-db/reader/ext/tests/003-open-basedir.phpt @@ -0,0 +1,12 @@ +--TEST-- +openbase_dir is followed +--INI-- +open_basedir=/--dne-- +--FILE-- + +--EXPECTREGEX-- +.*open_basedir restriction in effect.* diff --git a/vendor/maxmind-db/reader/package.xml b/vendor/maxmind-db/reader/package.xml new file mode 100644 index 0000000..15db600 --- /dev/null +++ b/vendor/maxmind-db/reader/package.xml @@ -0,0 +1,61 @@ + + + + maxminddb + pecl.php.net + Reader for the MaxMind DB file format + This is the PHP extension for reading MaxMind DB files. MaxMind DB is a binary file format that stores data indexed by IP address subnets (IPv4 or IPv6). + + Greg Oschwald + oschwald + goschwald@maxmind.com + yes + + 2025-11-21 + + 1.13.1 + 1.13.1 + + + stable + stable + + Apache License 2.0 + * First PIE release. No other changes. + + + + + + + + + + + + + + + + + + + + + + + + + 7.2.0 + + + 1.10.0 + + + + maxminddb + + diff --git a/vendor/maxmind-db/reader/src/MaxMind/Db/Reader.php b/vendor/maxmind-db/reader/src/MaxMind/Db/Reader.php new file mode 100644 index 0000000..a0b28b4 --- /dev/null +++ b/vendor/maxmind-db/reader/src/MaxMind/Db/Reader.php @@ -0,0 +1,404 @@ + + */ + private static $METADATA_START_MARKER_LENGTH = 14; + + /** + * @var int + */ + private static $METADATA_MAX_SIZE = 131072; // 128 * 1024 = 128KiB + + /** + * @var Decoder + */ + private $decoder; + + /** + * @var resource + */ + private $fileHandle; + + /** + * @var int + */ + private $fileSize; + + /** + * @var int + */ + private $ipV4Start; + + /** + * @var Metadata + */ + private $metadata; + + /** + * Constructs a Reader for the MaxMind DB format. The file passed to it must + * be a valid MaxMind DB file such as a GeoIp2 database file. + * + * @param string $database the MaxMind DB file to use + * + * @throws \InvalidArgumentException for invalid database path or unknown arguments + * @throws InvalidDatabaseException + * if the database is invalid or there is an error reading + * from it + */ + public function __construct(string $database) + { + if (\func_num_args() !== 1) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) + ); + } + + if (is_dir($database)) { + // This matches the error that the C extension throws. + throw new InvalidDatabaseException( + "Error opening database file ($database). Is this a valid MaxMind DB file?" + ); + } + + $fileHandle = @fopen($database, 'rb'); + if ($fileHandle === false) { + throw new \InvalidArgumentException( + "The file \"$database\" does not exist or is not readable." + ); + } + $this->fileHandle = $fileHandle; + + $fstat = fstat($fileHandle); + if ($fstat === false) { + throw new \UnexpectedValueException( + "Error determining the size of \"$database\"." + ); + } + $this->fileSize = $fstat['size']; + + $start = $this->findMetadataStart($database); + $metadataDecoder = new Decoder($this->fileHandle, $start); + [$metadataArray] = $metadataDecoder->decode($start); + $this->metadata = new Metadata($metadataArray); + $this->decoder = new Decoder( + $this->fileHandle, + $this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE + ); + $this->ipV4Start = $this->ipV4StartNode(); + } + + /** + * Retrieves the record for the IP address. + * + * @param string $ipAddress the IP address to look up + * + * @throws \BadMethodCallException if this method is called on a closed database + * @throws \InvalidArgumentException if something other than a single IP address is passed to the method + * @throws InvalidDatabaseException + * if the database is invalid or there is an error reading + * from it + * + * @return mixed the record for the IP address + */ + public function get(string $ipAddress) + { + if (\func_num_args() !== 1) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) + ); + } + [$record] = $this->getWithPrefixLen($ipAddress); + + return $record; + } + + /** + * Retrieves the record for the IP address and its associated network prefix length. + * + * @param string $ipAddress the IP address to look up + * + * @throws \BadMethodCallException if this method is called on a closed database + * @throws \InvalidArgumentException if something other than a single IP address is passed to the method + * @throws InvalidDatabaseException + * if the database is invalid or there is an error reading + * from it + * + * @return array{0:mixed, 1:int} an array where the first element is the record and the + * second the network prefix length for the record + */ + public function getWithPrefixLen(string $ipAddress): array + { + if (\func_num_args() !== 1) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) + ); + } + + if (!\is_resource($this->fileHandle)) { + throw new \BadMethodCallException( + 'Attempt to read from a closed MaxMind DB.' + ); + } + + [$pointer, $prefixLen] = $this->findAddressInTree($ipAddress); + if ($pointer === 0) { + return [null, $prefixLen]; + } + + return [$this->resolveDataPointer($pointer), $prefixLen]; + } + + /** + * @return array{0:int, 1:int} + */ + private function findAddressInTree(string $ipAddress): array + { + $packedAddr = @inet_pton($ipAddress); + if ($packedAddr === false) { + throw new \InvalidArgumentException( + "The value \"$ipAddress\" is not a valid IP address." + ); + } + + $rawAddress = unpack('C*', $packedAddr); + if ($rawAddress === false) { + throw new InvalidDatabaseException( + 'Could not unpack the unsigned char of the packed in_addr representation.' + ); + } + + $bitCount = \count($rawAddress) * 8; + + // The first node of the tree is always node 0, at the beginning of the + // value + $node = 0; + + $metadata = $this->metadata; + + // Check if we are looking up an IPv4 address in an IPv6 tree. If this + // is the case, we can skip over the first 96 nodes. + if ($metadata->ipVersion === 6) { + if ($bitCount === 32) { + $node = $this->ipV4Start; + } + } elseif ($metadata->ipVersion === 4 && $bitCount === 128) { + throw new \InvalidArgumentException( + "Error looking up $ipAddress. You attempted to look up an" + . ' IPv6 address in an IPv4-only database.' + ); + } + + $nodeCount = $metadata->nodeCount; + + for ($i = 0; $i < $bitCount && $node < $nodeCount; ++$i) { + $tempBit = 0xFF & $rawAddress[($i >> 3) + 1]; + $bit = 1 & ($tempBit >> 7 - ($i % 8)); + + $node = $this->readNode($node, $bit); + } + if ($node === $nodeCount) { + // Record is empty + return [0, $i]; + } + if ($node > $nodeCount) { + // Record is a data pointer + return [$node, $i]; + } + + throw new InvalidDatabaseException( + 'Invalid or corrupt database. Maximum search depth reached without finding a leaf node' + ); + } + + private function ipV4StartNode(): int + { + // If we have an IPv4 database, the start node is the first node + if ($this->metadata->ipVersion === 4) { + return 0; + } + + $node = 0; + + for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; ++$i) { + $node = $this->readNode($node, 0); + } + + return $node; + } + + private function readNode(int $nodeNumber, int $index): int + { + $baseOffset = $nodeNumber * $this->metadata->nodeByteSize; + + switch ($this->metadata->recordSize) { + case 24: + $bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3); + $rc = unpack('N', "\x00" . $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack the unsigned long of the node.' + ); + } + [, $node] = $rc; + + return $node; + + case 28: + $bytes = Util::read($this->fileHandle, $baseOffset + 3 * $index, 4); + if ($index === 0) { + $middle = (0xF0 & \ord($bytes[3])) >> 4; + } else { + $middle = 0x0F & \ord($bytes[0]); + } + $rc = unpack('N', \chr($middle) . substr($bytes, $index, 3)); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack the unsigned long of the node.' + ); + } + [, $node] = $rc; + + return $node; + + case 32: + $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4); + $rc = unpack('N', $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack the unsigned long of the node.' + ); + } + [, $node] = $rc; + + return $node; + + default: + throw new InvalidDatabaseException( + 'Unknown record size: ' + . $this->metadata->recordSize + ); + } + } + + /** + * @return mixed + */ + private function resolveDataPointer(int $pointer) + { + $resolved = $pointer - $this->metadata->nodeCount + + $this->metadata->searchTreeSize; + if ($resolved >= $this->fileSize) { + throw new InvalidDatabaseException( + "The MaxMind DB file's search tree is corrupt" + ); + } + + [$data] = $this->decoder->decode($resolved); + + return $data; + } + + /* + * This is an extremely naive but reasonably readable implementation. There + * are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever + * an issue, but I suspect it won't be. + */ + private function findMetadataStart(string $filename): int + { + $handle = $this->fileHandle; + $fileSize = $this->fileSize; + $marker = self::$METADATA_START_MARKER; + $markerLength = self::$METADATA_START_MARKER_LENGTH; + + $minStart = $fileSize - min(self::$METADATA_MAX_SIZE, $fileSize); + + for ($offset = $fileSize - $markerLength; $offset >= $minStart; --$offset) { + if (fseek($handle, $offset) !== 0) { + break; + } + + $value = fread($handle, $markerLength); + if ($value === $marker) { + return $offset + $markerLength; + } + } + + throw new InvalidDatabaseException( + "Error opening database file ($filename). " + . 'Is this a valid MaxMind DB file?' + ); + } + + /** + * @throws \InvalidArgumentException if arguments are passed to the method + * @throws \BadMethodCallException if the database has been closed + * + * @return Metadata object for the database + */ + public function metadata(): Metadata + { + if (\func_num_args()) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args()) + ); + } + + // Not technically required, but this makes it consistent with + // C extension and it allows us to change our implementation later. + if (!\is_resource($this->fileHandle)) { + throw new \BadMethodCallException( + 'Attempt to read from a closed MaxMind DB.' + ); + } + + return clone $this->metadata; + } + + /** + * Closes the MaxMind DB and returns resources to the system. + * + * @throws \Exception + * if an I/O error occurs + */ + public function close(): void + { + if (\func_num_args()) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args()) + ); + } + + if (!\is_resource($this->fileHandle)) { + throw new \BadMethodCallException( + 'Attempt to close a closed MaxMind DB.' + ); + } + fclose($this->fileHandle); + } +} diff --git a/vendor/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php b/vendor/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php new file mode 100644 index 0000000..1bb6731 --- /dev/null +++ b/vendor/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php @@ -0,0 +1,452 @@ +fileStream = $fileStream; + $this->pointerBase = $pointerBase; + + $this->pointerTestHack = $pointerTestHack; + + $this->switchByteOrder = $this->isPlatformLittleEndian(); + } + + /** + * @return array + */ + public function decode(int $offset): array + { + $ctrlByte = \ord(Util::read($this->fileStream, $offset, 1)); + ++$offset; + + $type = $ctrlByte >> 5; + + // Pointers are a special case, we don't read the next $size bytes, we + // use the size to determine the length of the pointer and then follow + // it. + if ($type === self::_POINTER) { + [$pointer, $offset] = $this->decodePointer($ctrlByte, $offset); + + // for unit testing + if ($this->pointerTestHack) { + return [$pointer]; + } + + [$result] = $this->decode($pointer); + + return [$result, $offset]; + } + + if ($type === self::_EXTENDED) { + $nextByte = \ord(Util::read($this->fileStream, $offset, 1)); + + $type = $nextByte + 7; + + if ($type < 8) { + throw new InvalidDatabaseException( + 'Something went horribly wrong in the decoder. An extended type ' + . 'resolved to a type number < 8 (' + . $type + . ')' + ); + } + + ++$offset; + } + + [$size, $offset] = $this->sizeFromCtrlByte($ctrlByte, $offset); + + return $this->decodeByType($type, $offset, $size); + } + + /** + * @param int<0, max> $size + * + * @return array{0:mixed, 1:int} + */ + private function decodeByType(int $type, int $offset, int $size): array + { + switch ($type) { + case self::_MAP: + return $this->decodeMap($size, $offset); + + case self::_ARRAY: + return $this->decodeArray($size, $offset); + + case self::_BOOLEAN: + return [$this->decodeBoolean($size), $offset]; + } + + $newOffset = $offset + $size; + $bytes = Util::read($this->fileStream, $offset, $size); + + switch ($type) { + case self::_BYTES: + case self::_UTF8_STRING: + return [$bytes, $newOffset]; + + case self::_DOUBLE: + $this->verifySize(8, $size); + + return [$this->decodeDouble($bytes), $newOffset]; + + case self::_FLOAT: + $this->verifySize(4, $size); + + return [$this->decodeFloat($bytes), $newOffset]; + + case self::_INT32: + return [$this->decodeInt32($bytes, $size), $newOffset]; + + case self::_UINT16: + case self::_UINT32: + case self::_UINT64: + case self::_UINT128: + return [$this->decodeUint($bytes, $size), $newOffset]; + + default: + throw new InvalidDatabaseException( + 'Unknown or unexpected type: ' . $type + ); + } + } + + private function verifySize(int $expected, int $actual): void + { + if ($expected !== $actual) { + throw new InvalidDatabaseException( + "The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)" + ); + } + } + + /** + * @return array{0:array, 1:int} + */ + private function decodeArray(int $size, int $offset): array + { + $array = []; + + for ($i = 0; $i < $size; ++$i) { + [$value, $offset] = $this->decode($offset); + $array[] = $value; + } + + return [$array, $offset]; + } + + private function decodeBoolean(int $size): bool + { + return $size !== 0; + } + + private function decodeDouble(string $bytes): float + { + // This assumes IEEE 754 doubles, but most (all?) modern platforms + // use them. + $rc = unpack('E', $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack a double value from the given bytes.' + ); + } + [, $double] = $rc; + + return $double; + } + + private function decodeFloat(string $bytes): float + { + // This assumes IEEE 754 floats, but most (all?) modern platforms + // use them. + $rc = unpack('G', $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack a float value from the given bytes.' + ); + } + [, $float] = $rc; + + return $float; + } + + private function decodeInt32(string $bytes, int $size): int + { + switch ($size) { + case 0: + return 0; + + case 1: + case 2: + case 3: + $bytes = str_pad($bytes, 4, "\x00", \STR_PAD_LEFT); + + break; + + case 4: + break; + + default: + throw new InvalidDatabaseException( + "The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)" + ); + } + + $rc = unpack('l', $this->maybeSwitchByteOrder($bytes)); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack a 32bit integer value from the given bytes.' + ); + } + [, $int] = $rc; + + return $int; + } + + /** + * @return array{0:array, 1:int} + */ + private function decodeMap(int $size, int $offset): array + { + $map = []; + + for ($i = 0; $i < $size; ++$i) { + [$key, $offset] = $this->decode($offset); + [$value, $offset] = $this->decode($offset); + $map[$key] = $value; + } + + return [$map, $offset]; + } + + /** + * @return array{0:int, 1:int} + */ + private function decodePointer(int $ctrlByte, int $offset): array + { + $pointerSize = (($ctrlByte >> 3) & 0x3) + 1; + + $buffer = Util::read($this->fileStream, $offset, $pointerSize); + $offset += $pointerSize; + + switch ($pointerSize) { + case 1: + $packed = \chr($ctrlByte & 0x7) . $buffer; + $rc = unpack('n', $packed); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned short value from the given bytes (pointerSize is 1).' + ); + } + [, $pointer] = $rc; + $pointer += $this->pointerBase; + + break; + + case 2: + $packed = "\x00" . \chr($ctrlByte & 0x7) . $buffer; + $rc = unpack('N', $packed); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned long value from the given bytes (pointerSize is 2).' + ); + } + [, $pointer] = $rc; + $pointer += $this->pointerBase + 2048; + + break; + + case 3: + $packed = \chr($ctrlByte & 0x7) . $buffer; + + // It is safe to use 'N' here, even on 32 bit machines as the + // first bit is 0. + $rc = unpack('N', $packed); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned long value from the given bytes (pointerSize is 3).' + ); + } + [, $pointer] = $rc; + $pointer += $this->pointerBase + 526336; + + break; + + case 4: + // We cannot use unpack here as we might overflow on 32 bit + // machines + $pointerOffset = $this->decodeUint($buffer, $pointerSize); + + $pointerBase = $this->pointerBase; + + if (\PHP_INT_MAX - $pointerBase >= $pointerOffset) { + $pointer = $pointerOffset + $pointerBase; + } else { + throw new \RuntimeException( + 'The database offset is too large to be represented on your platform.' + ); + } + + break; + + default: + throw new InvalidDatabaseException( + 'Unexpected pointer size ' . $pointerSize + ); + } + + return [$pointer, $offset]; + } + + // @phpstan-ignore-next-line + private function decodeUint(string $bytes, int $byteLength) + { + if ($byteLength === 0) { + return 0; + } + + // PHP integers are signed. PHP_INT_SIZE - 1 is the number of + // complete bytes that can be converted to an integer. However, + // we can convert another byte if the leading bit is zero. + $useRealInts = $byteLength <= \PHP_INT_SIZE - 1 + || ($byteLength === \PHP_INT_SIZE && (\ord($bytes[0]) & 0x80) === 0); + + if ($useRealInts) { + $integer = 0; + for ($i = 0; $i < $byteLength; ++$i) { + $part = \ord($bytes[$i]); + $integer = ($integer << 8) + $part; + } + + return $integer; + } + + // We only use gmp or bcmath if the final value is too big + $integerAsString = '0'; + for ($i = 0; $i < $byteLength; ++$i) { + $part = \ord($bytes[$i]); + + if (\extension_loaded('gmp')) { + $integerAsString = gmp_strval(gmp_add(gmp_mul($integerAsString, '256'), $part)); + } elseif (\extension_loaded('bcmath')) { + $integerAsString = bcadd(bcmul($integerAsString, '256'), (string) $part); + } else { + throw new \RuntimeException( + 'The gmp or bcmath extension must be installed to read this database.' + ); + } + } + + return $integerAsString; + } + + /** + * @return array{0:int, 1:int} + */ + private function sizeFromCtrlByte(int $ctrlByte, int $offset): array + { + $size = $ctrlByte & 0x1F; + + if ($size < 29) { + return [$size, $offset]; + } + + $bytesToRead = $size - 28; + $bytes = Util::read($this->fileStream, $offset, $bytesToRead); + + if ($size === 29) { + $size = 29 + \ord($bytes); + } elseif ($size === 30) { + $rc = unpack('n', $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned short value from the given bytes.' + ); + } + [, $adjust] = $rc; + $size = 285 + $adjust; + } else { + $rc = unpack('N', "\x00" . $bytes); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned long value from the given bytes.' + ); + } + [, $adjust] = $rc; + $size = $adjust + 65821; + } + + return [$size, $offset + $bytesToRead]; + } + + private function maybeSwitchByteOrder(string $bytes): string + { + return $this->switchByteOrder ? strrev($bytes) : $bytes; + } + + private function isPlatformLittleEndian(): bool + { + $testint = 0x00FF; + $packed = pack('S', $testint); + $rc = unpack('v', $packed); + if ($rc === false) { + throw new InvalidDatabaseException( + 'Could not unpack an unsigned short value from the given bytes.' + ); + } + + return $testint === current($rc); + } +} diff --git a/vendor/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php b/vendor/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php new file mode 100644 index 0000000..b1da1ed --- /dev/null +++ b/vendor/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php @@ -0,0 +1,11 @@ + + */ + public $description; + + /** + * This is an unsigned 16-bit integer which is always 4 or 6. It indicates + * whether the database contains IPv4 or IPv6 address data. + * + * @var int + */ + public $ipVersion; + + /** + * An array of strings, each of which is a language code. A given record + * may contain data items that have been localized to some or all of + * these languages. This may be undefined. + * + * @var array + */ + public $languages; + + /** + * @var int + */ + public $nodeByteSize; + + /** + * This is an unsigned 32-bit integer indicating the number of nodes in + * the search tree. + * + * @var int + */ + public $nodeCount; + + /** + * This is an unsigned 16-bit integer. It indicates the number of bits in a + * record in the search tree. Note that each node consists of two records. + * + * @var int + */ + public $recordSize; + + /** + * @var int + */ + public $searchTreeSize; + + /** + * @param array $metadata + */ + public function __construct(array $metadata) + { + if (\func_num_args() !== 1) { + throw new \ArgumentCountError( + \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) + ); + } + + $this->binaryFormatMajorVersion + = $metadata['binary_format_major_version']; + $this->binaryFormatMinorVersion + = $metadata['binary_format_minor_version']; + $this->buildEpoch = $metadata['build_epoch']; + $this->databaseType = $metadata['database_type']; + $this->languages = $metadata['languages']; + $this->description = $metadata['description']; + $this->ipVersion = $metadata['ip_version']; + $this->nodeCount = $metadata['node_count']; + $this->recordSize = $metadata['record_size']; + $this->nodeByteSize = $this->recordSize / 4; + $this->searchTreeSize = $this->nodeCount * $this->nodeByteSize; + } +} diff --git a/vendor/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php b/vendor/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php new file mode 100644 index 0000000..c2c3212 --- /dev/null +++ b/vendor/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php @@ -0,0 +1,33 @@ + $numberOfBytes + */ + public static function read($stream, int $offset, int $numberOfBytes): string + { + if ($numberOfBytes === 0) { + return ''; + } + if (fseek($stream, $offset) === 0) { + $value = fread($stream, $numberOfBytes); + + // We check that the number of bytes read is equal to the number + // asked for. We use ftell as getting the length of $value is + // much slower. + if ($value !== false && ftell($stream) - $offset === $numberOfBytes) { + return $value; + } + } + + throw new InvalidDatabaseException( + 'The MaxMind DB file contains bad data' + ); + } +}