*/ private static array $notices = array(); /** * Hook the save handler. */ public static function register(): void { add_action( 'admin_init', array( __CLASS__, 'save' ) ); } /** * admin_init: handle this form's POST, and only this form's. */ public static function save(): void { // phpcs:disable WordPress.Security.NonceVerification.Missing -- routed on the marker first; the nonce is verified before anything is read or written. if ( ! isset( $_POST[ self::MARKER ] ) && ! isset( $_POST[ self::UPDATE_NOW ] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), self::NONCE ) ) { wp_die( esc_html__( 'Security check failed.', '404ops-activity-log' ) ); } // phpcs:enable // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; handle() validates every field it reads, and the file is checked with is_uploaded_file(). self::$notices = self::handle( wp_unslash( $_POST ), $_FILES ); // phpcs:ignore WordPress.Security.NonceVerification.Missing } /** * Apply one submission. The decision without the request plumbing, so * tests drive it directly. * * @param array $post Unslashed POST fields. * @param array $files The request's $_FILES. * @return array What to tell the admin. */ public static function handle( array $post, array $files ): array { $notices = array(); $changed = array(); // Device capture. $record_device = isset( $post['ops_activity_log_record_device'] ); if ( RequestContext::records_device() !== $record_device ) { $changed[] = 'record_device'; } // Stored as '1' / '0', never a bool: update_option( false ) on an // option that does not exist yet compares equal to get_option()'s // own false and writes nothing, so "off" would silently not stick. update_option( RequestContext::OPTION_DEVICE, $record_device ? '1' : '0' ); // The database: forget, upload, or a path -- in that order of // precedence, one per save. $before_path = GeoDatabase::path(); $upload = isset( $files[ self::UPLOAD ] ) && is_array( $files[ self::UPLOAD ] ) ? $files[ self::UPLOAD ] : null; $typed_path = isset( $post['ops_activity_log_geo_path'] ) && is_string( $post['ops_activity_log_geo_path'] ) ? trim( sanitize_text_field( $post['ops_activity_log_geo_path'] ) ) : ''; if ( isset( $post['ops_activity_log_geo_forget'] ) ) { GeoDatabase::forget(); $notices[] = self::notice( true, __( 'Geolocation is off. Events are no longer located.', '404ops-activity-log' ) ); } elseif ( null !== $upload && UPLOAD_ERR_NO_FILE !== (int) ( $upload['error'] ?? UPLOAD_ERR_NO_FILE ) ) { $notices[] = self::install_upload( $upload ); } elseif ( '' !== $typed_path && $typed_path !== $before_path ) { $result = GeoDatabase::use_path( $typed_path ); $notices[] = $result['ok'] ? self::notice( true, self::describe( $result ) ) : self::notice( false, $result['error'] ); } if ( GeoDatabase::path() !== $before_path ) { $changed[] = 'geolocation_database'; } // The scheduled download. $enabled = isset( $post['ops_activity_log_geo_auto_update'] ); if ( GeoUpdater::enabled() !== $enabled ) { $changed[] = 'auto_update'; } update_option( GeoUpdater::OPTION_ENABLED, $enabled ? '1' : '0', false ); $edition = isset( $post['ops_activity_log_geo_edition'] ) && is_string( $post['ops_activity_log_geo_edition'] ) ? $post['ops_activity_log_geo_edition'] : ''; if ( in_array( $edition, GeoUpdater::EDITIONS, true ) && GeoUpdater::edition() !== $edition ) { update_option( GeoUpdater::OPTION_EDITION, $edition, false ); $changed[] = 'edition'; } $account = isset( $post['ops_activity_log_geo_account_id'] ) && is_string( $post['ops_activity_log_geo_account_id'] ) ? trim( $post['ops_activity_log_geo_account_id'] ) : ''; if ( '' === $account || 1 === preg_match( '/^\d{1,12}$/', $account ) ) { if ( GeoUpdater::account_id() !== $account ) { update_option( GeoUpdater::OPTION_ACCOUNT, $account, false ); $changed[] = 'account_id'; } } else { $notices[] = self::notice( false, __( 'A MaxMind account ID is digits only; it was not changed.', '404ops-activity-log' ) ); } // The key: blank keeps it, the checkbox clears it, anything else // replaces it -- and is never echoed, in a notice or anywhere else. $key = isset( $post['ops_activity_log_geo_licence_key'] ) && is_string( $post['ops_activity_log_geo_licence_key'] ) ? trim( $post['ops_activity_log_geo_licence_key'] ) : ''; if ( isset( $post['ops_activity_log_geo_clear_key'] ) ) { if ( GeoUpdater::has_key() ) { GeoUpdater::clear_key(); $changed[] = 'licence_key'; } } elseif ( '' !== $key ) { if ( GeoUpdater::store_key( $key ) ) { $changed[] = 'licence_key'; } else { $notices[] = self::notice( false, __( 'That licence key was not stored: it is not in MaxMind\'s format, or this site could not encrypt it.', '404ops-activity-log' ) ); } } if ( array() !== $changed ) { EventLogger::log( 'context_settings_changed', 'Location and device settings changed', null, null, array( 'changed' => $changed ) ); } if ( isset( $post[ self::UPDATE_NOW ] ) ) { $status = GeoUpdater::update(); $notices[] = self::notice( $status['ok'], $status['message'] ); } elseif ( array() === $notices ) { $notices[] = self::notice( true, __( 'Location and device settings saved.', '404ops-activity-log' ) ); } return $notices; } /** * Install an uploaded database, if it really is this request's upload. * * @param array $upload One $_FILES entry. * @return array{ok: bool, text: string} */ private static function install_upload( array $upload ): array { $error = (int) ( $upload['error'] ?? UPLOAD_ERR_NO_FILE ); $tmp = isset( $upload['tmp_name'] ) && is_string( $upload['tmp_name'] ) ? $upload['tmp_name'] : ''; $name = isset( $upload['name'] ) && is_string( $upload['name'] ) ? $upload['name'] : ''; if ( UPLOAD_ERR_INI_SIZE === $error || UPLOAD_ERR_FORM_SIZE === $error ) { return self::notice( false, __( 'The file is larger than this server accepts as an upload. Put it on the server and enter its path instead.', '404ops-activity-log' ) ); } if ( UPLOAD_ERR_OK !== $error || '' === $tmp || ! is_uploaded_file( $tmp ) ) { return self::notice( false, __( 'The upload did not arrive.', '404ops-activity-log' ) ); } if ( ! str_ends_with( strtolower( $name ), '.mmdb' ) ) { return self::notice( false, __( 'Upload the .mmdb file itself, not the .tar.gz archive it came in.', '404ops-activity-log' ) ); } $result = GeoDatabase::install( $tmp ); return $result['ok'] ? self::notice( true, self::describe( $result ) ) : self::notice( false, $result['error'] ); } /** * "Using GeoLite2-City, built 2026-09-26." * * @param array{ok: bool, type: string, built: int, error: string} $result An inspection. */ private static function describe( array $result ): string { return sprintf( /* translators: 1: database type, e.g. GeoLite2-City; 2: build date. */ __( 'Using %1$s, built %2$s.', '404ops-activity-log' ), $result['type'], gmdate( 'Y-m-d', $result['built'] ) ); } /** * @param bool $ok Success or not. * @param string $text Message. * @return array{ok: bool, text: string} */ private static function notice( bool $ok, string $text ): array { return array( 'ok' => $ok, 'text' => $text, ); } /** * The form. */ public static function render(): void { foreach ( self::$notices as $notice ) { echo '

' . esc_html( $notice['text'] ) . '

'; } $path = GeoDatabase::path(); $inspected = '' !== $path ? GeoDatabase::inspect( $path ) : null; $status = GeoUpdater::status(); echo '
'; wp_nonce_field( self::NONCE ); echo '

' . esc_html__( 'Location and device', '404ops-activity-log' ) . '

'; echo '
'; echo ''; echo '

' . esc_html__( 'Read from the browser\'s user agent when the event happens. Only the result is stored, never the user-agent text itself.', '404ops-activity-log' ) . '

'; echo '
'; echo '
' . esc_html__( 'Geolocation database', '404ops-activity-log' ) . ''; echo '

' . esc_html__( 'Adds the country (and, with a City database, the city) to each event, looked up in a MaxMind-format .mmdb file on this server. Nothing is sent anywhere. Download GeoLite2 free with your own MaxMind account, or use GeoIP2. When IP addresses are stored truncated, the location is looked up from the full address first and only the location is kept.', '404ops-activity-log' ) . '

'; if ( null === $inspected ) { echo '

' . esc_html__( 'Off: no database.', '404ops-activity-log' ) . '

'; } elseif ( $inspected['ok'] ) { echo '

' . esc_html( self::describe( $inspected ) ) . '

'; } else { echo '

' . esc_html( $inspected['error'] ) . '

'; } echo '


'; echo '

'; echo '

' . esc_html__( 'An uploaded file is kept in the uploads folder behind a deny-all .htaccess. On nginx that file does nothing: keep the database outside the web root and enter its path instead.', '404ops-activity-log' ) . '

'; if ( '' !== $path ) { echo '

'; } echo '
'; echo '
' . esc_html__( 'Weekly GeoLite2 download (optional)', '404ops-activity-log' ) . ''; echo '

' . esc_html__( 'Off by default. When on, this site downloads the database from MaxMind once a week with your MaxMind account ID and licence key, checks it against MaxMind\'s checksum, and installs it. This is the only request this plugin makes to another service, and only when you switch it on.', '404ops-activity-log' ) . '

'; echo '

'; echo '

'; echo '

'; // Never the key itself: whether one is stored, and a way to replace // or clear it. echo '

'; echo '

' . esc_html( GeoUpdater::has_key() ? __( 'A key is stored, encrypted. Leave this blank to keep it.', '404ops-activity-log' ) : __( 'No key stored.', '404ops-activity-log' ) ) . '

'; if ( GeoUpdater::has_key() ) { echo '

'; } if ( null !== $status ) { echo '

' . esc_html( sprintf( /* translators: 1: date and time; 2: what happened. */ __( 'Last download %1$s: %2$s', '404ops-activity-log' ), (string) wp_date( 'Y-m-d H:i', $status['time'] ), $status['message'] ) ) . '

'; } echo '
'; submit_button( __( 'Save location and device settings', '404ops-activity-log' ), 'primary', self::MARKER, false ); echo ' '; submit_button( __( 'Save and download now', '404ops-activity-log' ), 'secondary', self::UPDATE_NOW, false ); echo '
'; } }