AlkantarClanX12

Your IP : 216.73.217.24


Current Path : /www/capitalgmcbuickregina_830/public/wp-content/plugins/wp-cerber/includes/
Upload File :
Current File : /www/capitalgmcbuickregina_830/public/wp-content/plugins/wp-cerber/includes/CRB_Activity.php

<?php
/*
	Copyright (C) 2015-26 CERBER TECH INC., https://wpcerber.com

    Licensed under the GNU GPL.

    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program; if not, write to the Free Software
    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

*/

/*

*========================================================================*
|                                                                        |
|	       ATTENTION!  Do not change or edit this file!                  |
|                                                                        |
*========================================================================*

*/

/**
 * Owns the application-level Activity Log boundary.
 *
 * This class is the central service for recording security-relevant activity
 * events, querying them for administrative screens, and streaming them for export.
 * It keeps activity-log concerns behind one stable service boundary so callers do
 * not need to know the log table shape, query filter semantics, cache invalidation
 * rules, or export query strategy. Nearby components provide the lower-level work:
 * DB Warp executes the database operations, CRB_Activity_Alerts reacts to newly
 * recorded events, and the administrative UI consumes the filtered reads and export
 * streams produced here.
 *
 * Its expected side effects are deliberate and bounded to the activity subsystem:
 * writing and deleting activity rows, refreshing log-status cache metadata,
 * dispatching configured activity alerts, and forwarding selected events to the Lab
 * integration.
 *
 * @since 9.6.6.10
 */
final class CRB_Activity {
	/**
	 * Activity IDs logged during the current HTTP request.
	 *
	 * @var array<int,int>
	 */
	private static array $logged = array();

	/**
	 * Activity and user logged during the current HTTP request for log deduplication.
	 *
	 * @var array<string,bool>
	 */
	private static array $dedup = array();

	/**
	 * The list of activities not to log
	 *
	 * @var array<int>
	 */
	private static array $ignore = array();

	// HTTP query string parameters with mapping
	// Parameter name => DB table field, if direct mapping is applicable

	const EVENT_FILTERING_PARAMS = array(
		'filter_activity'   => 'activity',
		'filter_status'     => 'ac_status',
		'filter_set'        => false,
		'filter_ip'         => 'ip',
		'filter_login'      => 'user_login',
		'filter_user'       => 'user_id',
		'search_activity'   => false,
		'filter_sid'        => 'session_id',
		'search_url'        => false,
		'filter_country'    => 'country',
		'filter_time_begin' => false,
		'filter_time_end'   => false,
	);

	/**
	 * Storage key for caching database table status.
	 *
	 */
	const LOG_TABLE_STATUS = 'activity_table_status';

	/**
	 * Get records from the activity log using specified conditions
	 *
	 * @param array $activity List of activity IDs.
	 * @param array $user User parameters ('email' or 'id').
	 * @param array $order Order parameters ('DESC' => 'column_name' or 'ASC' => 'column_name').
	 * @param string $limit SQL LIMIT clause. Example: '10' or '0, 20'
	 *
	 * @return object[] Array of log record objects. An empty array if no records are found.
	 */
	public static function get_log( array $activity = [], array $user = [], $order = [], $limit = '' ): array {

		// Resolve the optional user filter to a numeric ID before touching the database.
		$user_id = 0;

		if ( ! empty( $user['email'] ) ) {
			$user_obj = get_user_by( 'email', $user['email'] );

			if ( ! $user_obj ) {
				return array();
			}

			$user_id = absint( $user_obj->ID );
		}
		elseif ( ! empty( $user['id'] ) ) {
			$user_id = absint( $user['id'] );
		}

		// Build and run the read through the centralized query builder.
		$db_result = warp_get_db();

		if ( $db_result->has_errors() ) {
			return array();
		}

		/** @var CRB_Database $db */
		$db = $db_result->get_results();
		$qb = $db->table( CERBER_LOG_TABLE );

		if ( $activity ) {
			$qb->where( 'activity', 'IN', array_map( 'absint', $activity ) );
		}

		if ( $user_id ) {
			$qb->where( 'user_id', '=', $user_id );
		}

		// Ordering, restricted to the supported DESC/ASC directions.
		if ( $order ) {
			$order_key = key( $order );
			$order_column = crb_sanitize_id( current( $order ) );

			// Sanitize the column so an unusable value degrades to "no ordering"
			// instead of turning the whole read into a builder error that would
			// zero the result set.
			if ( in_array( $order_key, [ 'DESC', 'ASC' ], true ) && $order_column !== '' ) {
				$qb->order_by( $order_column, $order_key );
			}
		}

		// Pagination expressed as 'count' or 'offset,count'.
		if ( $limit ) {
			$clean = preg_replace( '/[^0-9,]/', '', (string) $limit );

			if ( strpos( $clean, ',' ) !== false ) {
				list( $offset_part, $count_part ) = explode( ',', $clean, 2 );
				$qb->limit( (int) $count_part )->offset( (int) $offset_part );
			}
			elseif ( $clean !== '' ) {
				$qb->limit( (int) $clean );
			}
		}

		$result = $qb->get_query_results( CRB_Database::FETCH_OBJECTS );

		if ( $result->has_errors() ) {
			return array();
		}

		return $result->get_results( array() ) ?: array();
	}

	/**
	 * Log an activity event into the activity log.
	 *
	 * @param int $activity Activity ID.
	 * @param string $login Login used or any additional information. Empty by default.
	 * @param int $user_id User ID; defaults to the current user when zero.
	 * @param int $status Activity status; resolved automatically when zero.
	 * @param string $ip Remote IP address; auto-detected when empty.
	 *
	 * @return bool True when the event row was written. False when the event was
	 *              skipped (duplicate or ignored), the IP address is invalid, or
	 *              the database write failed.
	 *
	 * @since 3.0
	 */
	public static function log( int $activity, string $login = '', int $user_id = 0, int $status = 0, string $ip = '' ): bool {
		global $user_ID;

		$activity = absint( $activity );

		if ( empty( $user_id ) ) {
			$user_id = ( $user_ID ) ?: 0;
		}

		$user_id = absint( $user_id );

		$key = $activity . '-' . $user_id;

		if ( ( isset( self::$dedup[ $key ] )
		       || isset( self::$ignore[ $activity ] ) )
		     && ! defined( 'CRB_ALLOW_MULTIPLE' ) ) {
			return false;
		}

		self::$dedup[ $key ] = true;
		self::$logged[ $activity ] = $activity;

		// Assemble and normalize the event; an empty result signals an invalid IP

		$event = self::build_event( $activity, $login, $user_id, $status, $ip );

		if ( ! $event ) {
			return false;
		}

		$log_row = $event['row'];

		// Persist the event to the activity log

		$ret = self::persist_event( $log_row );

		// If alerts exist, lazy-load the alert class and dispatch a notification

		if ( cerber_get_site_option( CRB_ALL_ALERTS ) ) {
			CRB_Activity_Alerts::dispatch( array(
				'activity'   => $log_row['activity'],
				'status'     => $log_row['ac_status'],
				'user_id'    => $log_row['user_id'],
				'ac_by_user' => $log_row['ac_by_user'],
				'ip'         => $log_row['ip'],
				'ip_long'    => $log_row['ip_long'],
				'login'      => $log_row['user_login'],
				'url'        => $event['url'],
				'country'    => $log_row['country'],
			) );
		}

		// Lab --------------------------------------------------------------------

		if ( in_array( $log_row['activity'], array( CRB_EV_CMS, CRB_EV_SCD, CRB_EV_SFD, 40, CRB_EV_PUR, CRB_EV_LDN, 55, 56, 71 ) ) ) {
			lab_save_push( $log_row['ip'], $log_row['activity'] );
		}

		return $ret;
	}

	/**
	 * Assemble and normalize a single activity event from the raw log arguments.
	 *
	 * Resolves and validates the IP address, derives all event fields (IP long,
	 * IPv6 /64 network, timestamp, request URL, status, country, session ID and
	 * the control details string) and packs the database row. The method has no
	 * side effects: its result is consumed by persist_event() and the alert
	 * dispatcher.
	 *
	 * @param int $activity Activity ID, already normalized by the caller.
	 * @param string $login Login used or any additional information.
	 * @param int $user_id User ID, already normalized by the caller.
	 * @param int $status Activity status; resolved here when zero.
	 * @param string $ip Remote IP address; auto-detected when empty.
	 *
	 * @return array{row:array<string,mixed>,url:string}|array{} The assembled event, or an empty array when the supplied IP address is invalid.
	 */
	private static function build_event( int $activity, string $login, int $user_id, int $status, string $ip ): array {
		$wp_cerber = get_wp_cerber();

		// Resolve the IP address: auto-detect when empty, reject when malformed

		if ( empty( $ip ) ) {
			$ip = cerber_get_remote_ip();
		}
		elseif ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return array();
		}

		if ( cerber_is_ipv4( $ip ) ) {
			$ip_long = ip2long( $ip );
		}
		else {
			$ip_long = 1;
		}

		$ipv6_net_64 = crb_pack_ipv6_net64( $ip );

		$stamp = microtime( true );

		// Build the request URL from the host and the path without the query string

		$request_uri = $_SERVER['REQUEST_URI'] ?? '';
		$query_position = strpos( $request_uri, '?' );
		$request_path = ( $query_position !== false )
			? substr( $request_uri, 0, $query_position )
			: $request_uri;

		// Remove non-ASCII = robustness
		$request_path = preg_replace( '/[^\x20-\x7E]/', '', (string) $request_path );

		$http_host = $_SERVER['HTTP_HOST'] ?? '';
		$http_host = preg_replace( '/[^a-z0-9.\-:\[\]]/i', '', (string) $http_host );

		$url = $http_host . $request_path;

		// Resolve the status when the caller did not provide one

		if ( ! $status ) {
			if ( $activity != CRB_EV_IPL && $activity != CRB_EV_NEL ) {
				$status = cerber_get_status( $ip, $activity );
			}
			elseif ( CRB_Globals::$blocked ) {
				$status = CRB_Globals::$blocked;
			}
		}

		$ac_bot = absint( CRB_Globals::$bot_status );
		$ac_by_user = absint( CRB_Globals::$user_id );

		$ctrl_txt = '';

		if ( $ctrl = CRB_Globals::get_ctrl_settings() ) {
			$ctrl_txt = implode( ',', $ctrl ); // @since 9.6.1.1
		}

		$details = $ctrl_txt . '|0|0|0|' . $url;
		$details = substr( $details, 0, 250 );

		$country = (string) lab_get_country( $ip );
		$status = absint( $status ); // Note: @since 8.9.4 $status is stored in a separate "ac_status" column
		$session_id = $wp_cerber->getRequestID();

		$log_row = array(
			'ip'          => $ip,
			'ip_long'     => $ip_long,
			'ipv6_net_64' => $ipv6_net_64,
			'user_login'  => $login,
			'user_id'     => $user_id,
			'stamp'       => $stamp,
			'activity'    => $activity,
			'session_id'  => $session_id,
			'country'     => $country,
			'details'     => $details,
			'ac_status'   => $status,
			'ac_bot'      => $ac_bot,
			'ac_by_user'  => $ac_by_user,
		);

		return array( 'row' => $log_row, 'url' => $url );
	}

	/**
	 * Insert an assembled event row into the activity log table.
	 *
	 * Performs the database write through the DB Warp layer (parameterized
	 * insert) and refreshes the activity status cache on success. The supplied
	 * row is trusted as already normalized by build_event().
	 *
	 * @param array<string,mixed> $log_row Database row keyed by column name.
	 *
	 * @return bool True when the row was written, false on a database error.
	 */
	private static function persist_event( array $log_row ): bool {
		$db_result = warp_get_db();
		$write = $db_result->has_errors()
			? $db_result
			: $db_result->get_results()->table( CERBER_LOG_TABLE )->insert( $log_row );

		if ( $write->has_errors() ) {
			cerber_watchdog();

			return false;
		}

		self::set_status( array( 'data_modified' => $log_row['stamp'], 'hash' => sha1( $log_row['ip'] . $log_row['stamp'] . $log_row['session_id'] ) ) );

		return true;
	}

	/**
	 * Do not log this activity
	 *
	 * @param int $activity Activity ID
	 *
	 * @return void
	 */
	public static function set_ignore( int $activity ) {
		self::$ignore[ $activity ] = true;
	}

	/**
	 * Check if the given activities were logged during the current HTTP request
	 *
	 * @param int|int[] $activity ID of activities to check for.
	 *
	 * @return boolean
	 *
	 * @since 9.5.8
	 */
	public static function is_logged( $activity ) {
		if ( ! self::$logged ) {
			return false;
		}

		if ( ! is_array( $activity ) ) {
			$activity = array( $activity );
		}

		return ! empty( array_intersect( array_map( 'absint', $activity ), self::$logged ) );
	}

	/**
	 * Returns the list of activities logged during the current HTTP request
	 *
	 * @return array
	 *
	 * @since 9.6.6.10
	 */
	static function get_logged(): array {
		return self::$logged;
	}

	/**
	 * Is log empty?
	 *
	 * @return bool True if the activity log is empty (nothing is logged)
	 *
	 * @since 9.6.6.11
	 */
	static function is_empty(): bool {
		if ( self::get_status() ) {
			return false;
		}

		// cerber_db_is_empty()

		$db_result = warp_get_db();
		$probe = $db_result->has_errors()
			? $db_result
			: $db_result->get_results()->table( CERBER_LOG_TABLE )->select( 'ip' )->limit( 1 )->get_value();

		// It is safer to treat the log as non-empty if a DB error occurs.
		if ( $probe->get_results() || $probe->has_errors() ) {
			self::set_status( [ 'non_empty' => time() ] );

			return false;
		}

		//self::set_status( [ 'non_empty' => 0 ] );

		return true;
	}

	/**
	 * Get global log status data
	 *
	 * @return array
	 *
	 * @since 9.6.6.11
	 */
	private static function get_status(): array {
		$status = cerber_cache_get( self::LOG_TABLE_STATUS );

		if ( ! $status
		     || ! is_array( $status ) ) {
			$status = array();
		}

		return $status;
	}

	/**
	 * Set global log status data
	 *
	 * @param array $data Properties to set: ['name' => value]
	 *
	 * @return bool
	 *
	 * @since 9.6.6.11
	 */
	private static function set_status( array $data ): bool {

		$status = array_merge( self::get_status(), $data );

		return cerber_cache_set( self::LOG_TABLE_STATUS, $status );
	}

	/**
	 * Reset activity log status information
	 *
	 * @return bool
	 *
	 * @since 9.6.6.12
	 */
	static function clear_status(): bool {
		return cerber_cache_set( self::LOG_TABLE_STATUS, [] );
	}

	/**
	 * Checks if the activity log has been modified since the last log update info stored in $status.
	 *
	 * @param array $status Data returned by self::get_status()
	 *
	 * @return bool True if the log has been modified since the last log update info stored in $status
	 *
	 * @see self::get_status() Use to save the status of the activity log for further comparing.
	 *
	 * @since 9.6.6.11
	 */
	static function is_modified( array $status ): bool {
		$current = self::get_status();

		if ( empty( $current['data_modified'] )
		     || empty( $status['data_modified'] ) ) {
			return true;
		}

		return ( $current['data_modified'] > $status['data_modified'] );
	}

	/**
	 * Checks if the activity log table has been modified since the specified time.
	 * It can be used for cache invalidation.
	 *
	 * @param float|int $stamp Unix timestamp
	 *
	 * @return bool True if modified
	 *
	 * @since 9.6.6.11
	 */
	static function is_modified_since( $stamp ): bool {
		if ( ! $status = self::get_status() ) {
			return true;
		}

		return ( $stamp < ( $status['data_modified'] ?? PHP_INT_MAX ) );
	}

	/**
	 * Retrieves log rows as an array of objects, using data from cache
	 * if the log was not updated from the previous invocation
	 *
	 * If the data is not in the cache, or if the cached data is outdated (based on the global log's
	 * modification status), the builder executes the query against the database, and
	 * the results are cached.
	 *
	 * @param CRB_Query_Builder $qb Configured query builder that compiles and executes the activity log read.
	 * @param int|null &$num The number of rows in the resulting dataset. Passed by reference.
	 *
	 * @return array<object> An array of objects representing the log rows.
	 *                            An empty array if no rows are found or a database error occurs.
	 *
	 * @since 9.6.6.11
	 */
	private static function get_rows( CRB_Query_Builder $qb, ?int &$num = null ): array {

		$calculate_count = func_num_args() > 1; // We have to distinct calls with and without parameter $num

		$key = 'ac_cache_' . sha1( $qb->to_sql() . '/' . ( $calculate_count ? 'A' : 'B' ) );

		if ( ! ( $cache = cerber_cache_get( $key ) )
		     || self::is_modified( $cache['status'] ?? [] ) ) {

			$cache = array();

			// Execute the read through the builder's own terminal methods. The builder owns its
			// CRB_Database connection, so query construction and execution stay on one layer.

			$rows = array();
			$found_rows = 0;
			$query_result = $qb->get_query_results( CRB_Database::FETCH_OBJECTS );

			if ( ! $query_result->has_errors() ) {
				$rows = $query_result->get_results( array() );

				// FOUND_ROWS() reports the total recorded by the SQL_CALC_FOUND_ROWS SELECT that
				// get_query_results() just ran on the same connection. Read it through the builder's
				// companion only when the caller asked for the total and the query returned rows. A
				// count failure is non-critical and degrades to zero, matching the previous behavior.

				if ( $rows && $calculate_count ) {
					$found_result = $qb->get_found_rows();

					if ( ! $found_result->has_errors() ) {
						$found_rows = (int) $found_result->get_results( 0 );
					}
				}
			}

			// Preserve the previous contract: an empty result set (no match or a database
			// error) reports a zero total, a populated result without a count request reports
			// false, otherwise the FOUND_ROWS() total.

			if ( ! $rows ) {
				$cache['rows'] = array();
				$cache['num'] = 0;
			}
			else {
				$cache['rows'] = $rows;
				$cache['num'] = $calculate_count ? $found_rows : false;
			}

			$cache['status'] = self::get_status();

			cerber_cache_set( $key, $cache, 24 * 3600 );
		}

		$num = $cache['num'];

		return $cache['rows'];
	}

	/**
	 * Retrieves activity log rows using the request filters.
	 *
	 * Builds the SQL internally from the given arguments and executes it through the
	 * cached reader.
	 *
	 * On success the payload carries:
	 *  - 'rows'    an array of raw activity log row objects (stdClass); empty when nothing matches;
	 *  - 'total'   the total number of matching rows for pagination (SQL_CALC_FOUND_ROWS), or 0 when
	 *              pagination navigation is not required;
	 *  - 'filters' the echo-back filter values for rendering the filter form.
	 *
	 * @param array $args Optional arguments to use instead of the HTTP query string.
	 *
	 * @return Revalt<array{rows:array<object>,total:int,filters:array{per_page:int,falist:array<int>,ip:string,login:string,user_id:int,search:string,sid:string,in_url:string,country:string,time_begin:int,time_end:int}}>
	 *         Success carries the read payload described above. On failure the payload is absent and
	 *         the lower-layer Revalt is chained for root-cause diagnostics.
	 *
	 * @since 9.8.1
	 */
	public static function fetch( array $args = array() ): Revalt {

		$parsed = self::parse_query( $args );

		// A failed build aborts before any read. The parser Revalt is chained for diagnostics so the
		// caller can render the filter form from its own default-shaped values.

		if ( $parsed->has_errors() ) {
			return new Revalt( null, 'activity_fetch_query_build_failed', 'Activity fetch aborted: unable to build the activity query', null, $parsed );
		}

		$built = $parsed->get_results();

		$query_builder = $built['builder'];

		// Calculate the total count only when pagination navigation is required.

		$total = 0;

		if ( ! empty( $built['calc_on'] ) ) {
			$rows = self::get_rows( $query_builder, $total );
		}
		else {
			$rows = self::get_rows( $query_builder );
		}

		return new Revalt( array(
			'rows'    => $rows,
			'total'   => $total,
			'filters' => $built['filters'],
		) );
	}

	/**
	 * Builds the activity export query and opens an unbuffered row stream for CSV export.
	 * The stream is a generator that yields one raw activity row object (stdClass) per iteration.
	 *
	 * Resolves the export metadata eagerly while the connection is still free: the header total
	 * runs as a separate buffered COUNT query and the date range as a buffered MIN/MAX query,
	 * because the unbuffered row stream locks the connection while it is consumed.
	 *
	 * On success the payload carries:
	 *  - 'stream'  a Generator yielding raw activity row objects, never wrapped into Revalt;
	 *  - 'total'   the total number of matching rows for the header (0 when the count is unavailable);
	 *  - 'filters' the echo-back filter values for the export header;
	 *  - 'range'   array{oldest:int,newest:int} of the oldest and newest event timestamps, or an
	 *              empty array when no rows match or the range query is unavailable.
	 *
	 * The returned stream owns the database connection until it is fully consumed or destroyed:
	 * iterating it to completion and stopping early both release the unbuffered result. The caller
	 * must iterate it (even an empty export) so the connection is unlocked.
	 *
	 * @param array $args Optional arguments to use instead of the HTTP query string.
	 *
	 * @return Revalt<array{stream:\Generator,total:int,filters:array{per_page:int,falist:array<int>,ip:string,login:string,user_id:int,search:string,sid:string,in_url:string,country:string,time_begin:int,time_end:int},range:array{}|array{oldest:int,newest:int}}>
	 *         Success carries the export payload described above.
	 *
	 * @since 9.8.1
	 */
	public static function stream_log( array $args = array() ): Revalt {

		// Export uses the dedicated query variant: full projection, no LIMIT, and companion
		// COUNT and MIN/MAX queries for the header total and date range.

		$parsed = self::parse_query( $args, true );

		// A failed build aborts before any query runs. The parser Revalt is chained for diagnostics.

		if ( $parsed->has_errors() ) {
			return new Revalt( null, 'activity_export_query_build_failed', 'Activity export aborted: unable to build the export query', null, $parsed );
		}

		$built = $parsed->get_results();

		$db_result = warp_get_db();

		if ( $db_result->has_errors() ) {
			return new Revalt( null, 'activity_export_db_unavailable', 'Activity export aborted: database layer unavailable', null, $db_result );
		}

		/** @var CRB_Database $db */
		$db = $db_result->get_results();

		// Resolve the header total first, while the connection is still free. A count failure
		// is non-critical: the header simply reports zero rather than aborting the export.

		$total = 0;
		$count_result = $db->query( $built['count_sql'], CRB_Database::FETCH_OBJECTS );

		if ( ! $count_result->has_errors() ) {
			$count_rows = $count_result->get_results( array() );
			$total = isset( $count_rows[0] ) ? (int) $count_rows[0]->total : 0;
		}

		// Resolve the date range while the connection is still free. Failures and zero-row
		// exports (MIN/MAX return NULL) are non-critical: the header simply omits the range.

		$range = array();

		if ( $built['range_sql'] !== '' ) {
			$range_result = $db->query( $built['range_sql'], CRB_Database::FETCH_OBJECTS );

			if ( ! $range_result->has_errors() ) {
				$range_rows = $range_result->get_results( array() );

				if ( isset( $range_rows[0] ) && $range_rows[0]->oldest !== null && $range_rows[0]->newest !== null ) {
					$range = array(
						'oldest' => (int) $range_rows[0]->oldest,
						'newest' => (int) $range_rows[0]->newest,
					);
				}
			}
		}

		// Open the unbuffered row stream. It owns the connection until fully consumed or closed.
		// A stream failure is critical: abort with a chained error so the caller surfaces it before
		// any CSV output, instead of emitting a misleading empty export.

		$stream_result = $db->query_stream( $built['sql'], CRB_Database::FETCH_OBJECTS );

		if ( $stream_result->has_errors() ) {
			return new Revalt( null, 'activity_export_stream_failed', 'Activity export aborted: unable to open the row stream', null, $stream_result );
		}

		// Wrap the unbuffered result in a generator that releases the connection when the consumer
		// reaches the last row or stops early. Raw row objects are yielded as-is.

		$row_stream = ( static function () use ( $stream_result ): \Generator {

			$row_iterator = $stream_result->get_results();

			try {
				foreach ( $row_iterator as $activity_row ) {
					yield $activity_row;
				}
			} finally {

				// Cleanup unlocks the connection: drop every reference to the unbuffered result.

				$row_iterator = null;
				$stream_result = null;
			}
		} )();

		return new Revalt( array(
			'stream'  => $row_stream,
			'total'   => $total,
			'filters' => $built['filters'],
			'range'   => $range,
		) );
	}

	/**
	 * Parse query string arguments and create an SQL query for retrieving log rows from the activity log table
	 *
	 * @former cerber_activity_query()
	 *
	 * @param array $args Optional arguments to use them instead of using query string
	 * @param bool $export When true, builds the export variant: full projection joined with the
	 *                     users table, no LIMIT, the stream SQL under 'sql', and companion COUNT
	 *                     and MIN/MAX queries compiled into 'count_sql' and 'range_sql' for the
	 *                     streamed total and date range. Set only by stream_log(). When false, the
	 *                     read variant returns the configured query builder under 'builder' for the
	 *                     cached reader to execute and read FOUND_ROWS() from.
	 *
	 * @return Revalt<array{builder:CRB_Query_Builder,filters:array{per_page:int,falist:array<int>,ip:string,login:string,user_id:int,search:string,sid:string,in_url:string,country:string,time_begin:int,time_end:int},calc_on:bool}|array{sql:string,count_sql:string,range_sql:string,filters:array{per_page:int,falist:array<int>,ip:string,login:string,user_id:int,search:string,sid:string,in_url:string,country:string,time_begin:int,time_end:int},calc_on:bool}>
	 *         Success carries the compiled query payload: the read variant carries the configured query builder
	 *         under 'builder', while the export variant carries the compiled SQL bundle under 'sql', 'count_sql'
	 *         and 'range_sql'. On failure the payload is absent and the lower-layer Revalt is chained for
	 *         root-cause diagnostics.
	 *
	 * @since 4.16
	 */
	private static function parse_query( array $args = array(), bool $export = false ): Revalt {
		global $wpdb;

		$filters = self::default_filter_values();

		$q = crb_admin_parse_query( array_keys( self::EVENT_FILTERING_PARAMS ), $args );

		// Pagination, projection mode, and the users table are resolved here so the builder
		// can compile the full statement rather than only the WHERE clause.

		$per_page = ( isset( $args['per_page'] ) ) ? absint( $args['per_page'] ) : crb_admin_get_per_page();
		$page_offset = max( 0, ( crb_get_page_num() - 1 ) * $per_page );
		$calc_on = empty( $args['compact_view'] );
		$users_table = $wpdb->users;

		$filters['per_page'] = $per_page;

		$db_result = warp_get_db();

		if ( $db_result->has_errors() ) {
			return new Revalt( null, 'activity_query_db_unavailable', 'Activity query aborted: database layer unavailable', null, $db_result );
		}

		/** @var CRB_Database $db */
		$db = $db_result->get_results();
		$qb = $db->table( CERBER_LOG_TABLE )->alias( 'log' );

		// Activity filter: an explicit filter set takes precedence over the activity list.
		$falist = array();
		if ( ! empty( $q->filter_set ) ) {
			$falist = crb_get_filter_set( $q->filter_set );
		}
		elseif ( $q->filter_activity ) {
			$falist = crb_sanitize_int( $q->filter_activity );
		}
		if ( $falist ) {
			$qb->where( 'log.activity', 'IN', $falist );
		}
		$filters['falist'] = $falist;

		// Event status filter.
		if ( $q->filter_status ) {
			if ( $status = crb_sanitize_int( $q->filter_status ) ) {
				$qb->where( 'log.ac_status', 'IN', $status );
			}
		}

		// IP filter: a numeric range, a single address, or a guaranteed no-match fallback.
		if ( $q->filter_ip ) {
			$range = cerber_any2range( $q->filter_ip );
			if ( is_array( $range ) ) {
				$qb->where( 'log.ip_long', '>=', $range['begin'] )->where( 'log.ip_long', '<=', $range['end'] );
			}
			elseif ( cerber_is_ip_or_net( $q->filter_ip ) ) {
				$qb->where( 'log.ip', '=', $q->filter_ip );
			}
			else {
				$qb->where( 'log.ip', '=', 'produce-no-result' );
			}
			$filters['ip'] = preg_replace( CRB_IP_NET_RANGE, ' ', $q->filter_ip );
		}

		// Login filter: a pipe-separated list becomes an OR group, otherwise an exact match.
		if ( $q->filter_login ) {
			if ( strpos( $q->filter_login, '|' ) !== false ) {
				$login_conditions = array();
				foreach ( explode( '|', $q->filter_login ) as $single_login ) {
					$login_conditions[] = array( 'log.user_login', '=', $single_login );
				}
				$qb->where_any( $login_conditions );
			}
			else {
				$qb->where( 'log.user_login', '=', $q->filter_login );
			}
			$filters['login'] = crb_escape_html( $q->filter_login );
		}

		// User filter: any logged-in user, or a specific user as the actor or the target.
		if ( isset( $q->filter_user ) ) {
			if ( $q->filter_user == '*' ) {
				$qb->where( 'log.user_id', '!=', 0 );
				$filters['user_id'] = 0;
			}
			elseif ( is_numeric( $q->filter_user ) ) {
				$filter_user_id = absint( $q->filter_user );
				$qb->where_any( array(
					array( 'log.user_id', '=', $filter_user_id ),
					array( 'log.ac_by_user', '=', $filter_user_id ),
				) );
				$filters['user_id'] = $filter_user_id;
			}
		}

		// Free-text search across the login and the IP columns. User-supplied wildcards
		// stay active because the term is not passed through CRB_Database::escape_like().
		if ( $q->search_activity ) {
			$search = stripslashes( $q->search_activity );
			$filters['search'] = crb_escape_html( $search );
			$pattern = '%' . $search . '%';
			$qb->where_any( array(
				array( 'log.user_login', 'LIKE', $pattern ),
				array( 'log.ip', 'LIKE', $pattern ),
			) );
		}

		// Request ID filter (session_id column)
		if ( $q->filter_sid ) {

			// session_id is marked as "Request ID" in the UI

			$sid = (string) $q->filter_sid;

			if ( ! preg_match( '/^[A-Za-z0-9]{1,32}$/', $sid ) ) {
				return new Revalt(
					null,
					'invalid_request_id',
					'Request ID must contain only letters and digits and must not be longer than 32 characters.'
				);
			}

			$qb->where( 'log.session_id', '=', $sid );
			$filters['sid'] = $sid;
		}

		// URL search within the stored request details.
		if ( $q->search_url ) {
			$search = stripslashes( $q->search_url );
			$filters['in_url'] = crb_escape_html( $search );
			$qb->where( 'log.details', 'LIKE', '%' . $search . '%' );
		}

		// Country filter, limited to the stored country code length.
		if ( $q->filter_country ) {
			$country = substr( $q->filter_country, 0, 3 );
			$filters['country'] = crb_escape_html( $country );
			$qb->where( 'log.country', '=', $country );
		}

		// Time range on the event timestamp.
		if ( $begin = $q->filter_time_begin ) {
			$filters['time_begin'] = absint( $begin );
			$qb->where( 'log.stamp', '>=', absint( $begin ) );
		}

		if ( $end = $q->filter_time_end ) {
			$filters['time_end'] = absint( $end );
			$qb->where( 'log.stamp', '<=', absint( $end ) );
		}

		// Projection and pagination depend on the mode, selected explicitly by $export.
		// The export variant joins wp_users for the display name and the aliased login
		// column and omits LIMIT so stream_log() can stream every matching row in a single
		// unbuffered pass.

		$count_sql = '';
		$range_sql = '';

		if ( $export ) {

			// The unbuffered export stream locks the connection while it is consumed, so the
			// total for the CSV header cannot be read with SQL_CALC_FOUND_ROWS/FOUND_ROWS().
			// Compile a separate COUNT query from a snapshot of the filters taken before the
			// projection and ordering are applied.

			$count_qb = clone $qb;
			$count_sql = $count_qb->select_count( '*', 'total' )->to_sql();

			// Companion MIN/MAX query for the header date range, cloned from the same
			// pre-projection snapshot so it carries the identical filters.
			$range_qb = clone $qb;
			$range_sql = $range_qb->select_min( 'log.stamp', 'oldest' )
			                      ->select_max( 'log.stamp', 'newest' )
			                      ->to_sql();

			$qb->select( 'log.*' )
			   ->select( 'u.display_name' )
			   ->select_as( 'u.user_login', 'ulogin' )
			   ->join( $users_table, 'log.user_id', 'u.ID', 'LEFT', 'u' )
			   ->order_by( 'stamp', 'DESC' );
		}
		else {
			$qb->calc_found_rows( $calc_on )
			   ->order_by( 'stamp', 'DESC' );

			if ( $per_page > 0 ) {
				$qb->limit( $per_page )
				   ->offset( $page_offset );
			}
		}

		// Materialize the SQL once so any deferred to_sql() validation (for example a missing LIMIT
		// for a set OFFSET) is recorded in the builder state before it is inspected below.
		$sql = $qb->to_sql();

		// Propagate builder validation failures as a structured error so callers abort instead of
		// running a filters-stripped query. The builder state Revalt is chained for diagnostics.
		if ( $qb->get_state()->has_errors() ) {
			return new Revalt( null, 'activity_query_build_failed', 'Activity query aborted: query builder validation failed', null, $qb->get_state() );
		}

		// The export variant streams raw SQL plus separately compiled COUNT and MIN/MAX companions.
		// The read variant hands the configured builder to the cached reader, which executes it and
		// reads FOUND_ROWS() through the builder's own terminals.
		if ( $export ) {
			return new Revalt( array( 'sql' => $sql, 'count_sql' => $count_sql, 'range_sql' => $range_sql, 'filters' => $filters, 'calc_on' => $calc_on ) );
		}

		return new Revalt( array( 'builder' => $qb, 'filters' => $filters, 'calc_on' => $calc_on ) );
	}

	/**
	 * Returns the echo-back filter values in their default (no-filter) shape.
	 *
	 * Every consumer reads a fixed set of keys from the filters array, so the shape must stay
	 * complete even when query building fails. parse_query() seeds the filters from this, and the
	 * error paths of its callers reuse it to keep the by-reference contract intact.
	 *
	 * @return array{per_page:int,falist:array<int>,ip:string,login:string,user_id:int,search:string,sid:string,in_url:string,country:string,time_begin:int,time_end:int}
	 */
	private static function default_filter_values(): array {
		return array(
			'per_page'   => 0,
			'falist'     => array(),
			'ip'         => '',
			'login'      => '',
			'user_id'    => 0,
			'search'     => '',
			'sid'        => '',
			'in_url'     => '',
			'country'    => '',
			'time_begin' => 0,
			'time_end'   => 0,
		);
	}

	/**
	 * Deletes rows from the activity table using specified values of the given fields.
	 *
	 * @param array $key_fields An associative array of conditional fields for the WHERE clause.
	 *                          Format: ['column_name' => 'value', ...]
	 *
	 * @return int The number of affected rows
	 *
	 * @since 9.6.6.12
	 */
	static function delete( $key_fields = array() ): int {

		if ( ! $key_fields ) {
			return 0;
		}

		if ( $ret = cerber_db_delete_rows( CERBER_LOG_TABLE, $key_fields ) ) {
			self::clear_status();
		}

		return $ret;
	}
}