AlkantarClanX12

Your IP : 216.73.217.24


Current Path : /www/capitalgmcbuickregina_830/public/wp-content/plugins/wp-cerber/admin/
Upload File :
Current File : /www/capitalgmcbuickregina_830/public/wp-content/plugins/wp-cerber/admin/CRB_Traffic_Log.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

*/

/**
 * Owns the application-level Traffic Log boundary.
 *
 *
 * @since 9.8.1
 */
final class CRB_Traffic_Log {

	// Note: values is not specified yet

	const QUERY_PARAMS = array(
		'filter_sid'            => 'to_be_specified',
		'filter_http_code'      => 'to_be_specified',
		'filter_http_code_mode' => 'to_be_specified',
		'filter_ip'             => 'to_be_specified',
		'filter_processing'     => 'to_be_specified',
		'filter_user'           => 'to_be_specified',
		'filter_user_alt'       => 'to_be_specified',
		'filter_user_mode'      => 'to_be_specified',
		'filter_wp_type'        => 'to_be_specified',
		'filter_wp_type_mode'   => 'to_be_specified',
		'search_traffic'        => 'to_be_specified',
		'filter_method'         => 'to_be_specified',
		'filter_activity'       => 'to_be_specified',
		'filter_set'            => 'to_be_specified',
		'filter_errors'         => 'to_be_specified',
	);

	/**
	 * Builds a normalized traffic-log query payload from filter arguments.
	 *
	 * Resolves pagination, applies supported filters, and compiles row, count, and range SQL
	 * statements for the traffic-log dataset. The returned payload always has the same shape.
	 *
	 * On success, SQL keys contain executable statements and 'filters' contains normalized
	 * echo-back filter values. On failure, SQL keys are empty strings and 'filters' contains
	 * the available echo-back filter values. Chained diagnostics preserve lower-level causes
	 * when available.
	 *
	 * Callers must treat has_errors() as authoritative and must not execute SQL from an
	 * erroring result.
	 *
	 * @param array<string,mixed> $args Optional query argument overrides.
	 *
	 * @return Revalt<array{sql:string,count_sql:string,range_sql:string,filters:array{per_page:int,activity:array<int>,ip:string,processing:int,user:string,wp_type:int}}>
	 *         Stable query payload with compiled SQL on success or empty SQL strings on failure.
	 *
	 * @since 6.0
	 */
	public static function parse_query( array $args = array() ): Revalt {

		$filters = self::default_filter_values();

		// Pagination is resolved here so the builder can compile LIMIT and OFFSET directly.
		$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 );
		$filters['per_page'] = $per_page;

		$db_result = warp_get_db();

		if ( $db_result->has_errors() ) {
			return new Revalt( self::make_query_payload( $filters ), 'traffic_query_db_unavailable', 'Traffic log query aborted: database layer unavailable', null, $db_result );
		}

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

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

		$join_activity = false;

		// 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( self::make_query_payload( $filters ), 'traffic_query_invalid_request_id', 'Traffic log query aborted: invalid request ID' );
			}

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

		// HTTP response code filter: a list of codes, or a single code with an optional greater-than mode.
		if ( $q->filter_http_code ) {
			if ( is_array( $q->filter_http_code ) ) {
				$http_codes = array_filter( array_map( 'absint', $q->filter_http_code ) );
				if ( $http_codes ) {
					$qb->where( 'log.http_code', 'IN', $http_codes );
				}
			}
			else {
				$code_operator = ( $q->filter_http_code_mode == 'GT' ) ? '>' : '=';
				$qb->where( 'log.http_code', $code_operator, absint( $q->filter_http_code ) );
			}
		}

		// 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 );
		}

		// Processing-time filter (longer than the given number of milliseconds).
		if ( $q->filter_processing ) {
			$proc = absint( $q->filter_processing );
			$qb->where( 'log.processing', '>', $proc );
			$filters['processing'] = $proc;
		}

		// User filter: any authenticated user, or a list of user IDs, with an optional exclude mode.
		$filter_user = $q->filter_user !== false ? $q->filter_user : ( $q->filter_user_alt !== false ? $q->filter_user_alt : '' );

		if ( $filter_user == '*' ) {
			$user_operator = empty( $q->filter_user_mode ) ? '!=' : '=';
			$qb->where( 'log.user_id', $user_operator, 0 );
			$filters['user'] = '*';
		}
		else {
			$matches = array();
			if ( preg_match_all( '/\d+/', $filter_user, $matches ) ) {
				$user_ids = array_map( 'absint', $matches[0] );
				$user_operator = empty( $q->filter_user_mode ) ? 'IN' : 'NOT IN';
				$qb->where( 'log.user_id', $user_operator, $user_ids );

				if ( $user_operator === 'IN' ) {
					$filters['user'] = implode( ',', $user_ids );
				}
			}
		}

		// WordPress request type filter, with an optional greater-than mode.
		if ( $q->filter_wp_type ) {
			$wp_type = absint( $q->filter_wp_type );
			$type_operator = ( $q->filter_wp_type_mode === 'GT' ) ? '>' : '=';
			$qb->where( 'log.wp_type', $type_operator, $wp_type );
			$filters['wp_type'] = $wp_type;
		}

		// Search across the stored request data. Each search term is passed through
		// CRB_Database::escape_like() so user-supplied wildcards are matched literally.
		if ( $q->search_traffic ) {
			$search = stripslashes_deep( $q->search_traffic );

			if ( $search['ip'] ) {
				if ( $ip = filter_var( $search['ip'], FILTER_VALIDATE_IP ) ) {
					$qb->where( 'log.ip', '=', $ip );
					$filters['ip'] = $ip;
				}
				else {
					$qb->where( 'log.ip', 'LIKE', '%' . CRB_Database::escape_like( $search['ip'] ) . '%' );
				}
			}
			if ( $search['uri'] ) {
				$qb->where( 'log.uri', 'LIKE', '%' . CRB_Database::escape_like( $search['uri'] ) . '%' );
			}
			if ( $search['fields'] ) {
				$qb->where( 'log.request_fields', 'LIKE', '%' . CRB_Database::escape_like( $search['fields'] ) . '%' );
			}
			if ( $search['details'] ) {
				$qb->where( 'log.request_details', 'LIKE', '%' . CRB_Database::escape_like( $search['details'] ) . '%' );
			}
			if ( $search['date_from'] ) {
				if ( $stamp = strtotime( 'midnight ' . $search['date_from'] ) ) {
					$gmt_offset = get_option( 'gmt_offset' ) * 3600;
					$qb->where( 'log.stamp', '>=', absint( $stamp ) - $gmt_offset );
				}
			}
			if ( $search['date_to'] ) {
				if ( $stamp = 24 * 3600 + strtotime( 'midnight ' . $search['date_to'] ) ) {
					$gmt_offset = get_option( 'gmt_offset' ) * 3600;
					$qb->where( 'log.stamp', '<=', absint( $stamp ) - $gmt_offset );
				}
			}
			if ( ! $q->filter_errors && $search['errors'] ) {
				$qb->where( 'log.php_errors', 'LIKE', '%' . CRB_Database::escape_like( $search['errors'] ) . '%' );
			}
		}

		// HTTP request method filter.
		if ( $q->filter_method ) {
			$qb->where( 'log.request_method', '=', $q->filter_method );
		}

		// Activity filter.
		$activity_in = array();
		if ( $q->filter_set ) {
			$activity_in = crb_get_filter_set( $q->filter_set );
		}
		elseif ( $q->filter_activity ) {
			$activity_in = crb_sanitize_int( $q->filter_activity );
		}
		if ( $activity_in ) {
			$qb->where( 'act.activity', 'IN', $activity_in );
			$filters['activity'] = $activity_in;
			$join_activity = true;
		}

		// Any-software-error filter: an HTTP 500 response or a non-empty stored PHP error list.
		if ( $q->filter_errors ) {
			$qb->where_any( array(
				array( 'log.http_code', '=', 500 ),
				array( 'log.php_errors', '!=', '' ),
			) );
		}

		// The activity join is shared by the row and the count statements.
		if ( $join_activity ) {
			$qb->join( CERBER_LOG_TABLE, 'log.session_id', 'act.session_id', 'INNER', 'act' );
		}

		// Count statement: cloned from the filter-only state, before projection, ordering, and limits.
		$count_qb = clone $qb;
		$count_sql = $count_qb->select_count( 'log.stamp', 'total' )->to_sql();

		// Companion MIN/MAX query for the export header date range.
		$range_qb = clone $qb;
		$range_sql = $range_qb->select_min( 'log.stamp', 'oldest' )->select_max( 'log.stamp', 'newest' )->to_sql();

		// Row statement: the session id leads the projection so the cached reader can key rows by it.
		$qb->select( 'log.session_id' );

		if ( ! empty( $args['columns'] ) ) {
			foreach ( $args['columns'] as $col_name ) {
				$qb->select( 'log.' . $col_name );
			}
		}
		else {
			$qb->select( 'log.*' );
		}

		$qb->order_by( 'log.stamp', 'DESC' );

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

		$sql = $qb->to_sql();

		// Propagate builder validation failures as a structured error.
		if ( $qb->get_state()->has_errors() ) {
			return new Revalt( self::make_query_payload( $filters ), 'traffic_query_build_failed', 'Traffic log query aborted: query builder validation failed', null, $qb->get_state() );
		}

		return new Revalt( self::make_query_payload( $filters, $sql, $count_sql, $range_sql ) );
	}

	/**
	 * Retrieves traffic log rows for the log-browser screen.
	 *
	 * Builds the traffic-log query from the supplied filter arguments, runs the row and count reads,
	 * and decodes serialized request payload columns before returning. Rows are keyed by session id
	 * so the renderer and related lookups can address them directly.
	 *
	 * On success the payload carries:
	 * - 'rows'    decoded traffic log row objects keyed by session id; empty when nothing matches;
	 * - 'total'   total number of matching rows for pagination;
	 * - 'filters' echo-back filter values reused by the renderer.
	 *
	 * On query construction failure, no database reads are performed. The returned result keeps a stable
	 * payload shape with empty SQL strings and the available echo-back filter values. Callers must treat
	 * has_errors() as authoritative and must not consume row data from an erroring result.
	 *
	 * @param array<string,mixed> $args Optional query argument overrides.
	 *
	 * @return Revalt<array{rows:array<string,object>,total:int,filters:array<string,mixed>}|array{sql:string,count_sql:string,range_sql:string,filters:array<string,mixed>}>
	 *         Successful row-read payload, or stable query-construction payload on failure.
	 *
	 * @since 9.8.1
	 */
	public static function fetch_rows( array $args = array() ): Revalt {

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

		if ( $parsed->has_errors() ) {
			return $parsed;
		}

		$sql_query = $parsed->get_element( 'sql', '' );
		$sql_found = $parsed->get_element( 'count_sql', '' );
		$filters   = $parsed->get_element( 'filters', array() );

		// Run the row and count reads through the query cache. The row projection leads with the
		// session id, so MYSQL_FETCH_OBJECT_K keys the returned rows by it.

		list( $traffic_log_rows, $total_rows ) = crb_q_cache_get( array(
			array( $sql_query, MYSQL_FETCH_OBJECT_K ),
			array( $sql_found )
		), CERBER_TRAF_TABLE );

		if ( is_object( $traffic_log_rows ) ) { // Due to possible JSON from cache
			$traffic_log_rows = get_object_vars( $traffic_log_rows );
		}

		// Decode the serialized request payload columns once after fetching.

		if ( $traffic_log_rows ) {
			foreach ( $traffic_log_rows as $key => $row ) {
				$traffic_log_rows[ $key ]->request_details = crb_auto_decode( $row->request_details );
				$traffic_log_rows[ $key ]->request_fields = crb_auto_decode( $row->request_fields );
				$traffic_log_rows[ $key ]->php_errors = crb_auto_decode( $row->php_errors );
			}
		}

		$total_rows = (int) ( $total_rows[0][0] ?? 0 );

		return new Revalt( array(
			'rows'    => $traffic_log_rows ?: array(),
			'total'   => $total_rows,
			'filters' => $filters,
		) );
	}

	/**
	 * Builds the traffic export query and opens an unbuffered row stream for CSV export.
	 * The stream is a generator that yields one raw traffic 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. The export is always unbounded:
	 * per_page is forced to 0 so parse_query() emits no LIMIT.
	 *
	 * On success the payload carries:
	 *  - 'stream'  a Generator yielding raw traffic 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 request 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<string,mixed> $args Optional query argument overrides, including a 'columns'
	 *                                  projection subset. per_page is ignored: the export is unbounded.
	 *
	 * @return Revalt<array{stream:\Generator,total:int,filters:array{per_page:int,activity:array<int>,ip:string,processing:int,user:string,wp_type:int},range:array{}|array{oldest:int,newest:int}}>
	 *         Success carries the export 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 stream_log( array $args = array() ): Revalt {

		// The export is always unbounded: force per_page to 0 so parse_query() emits no LIMIT.
		$args['per_page'] = 0;

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

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

		if ( $parsed->has_errors() ) {
			return new Revalt( null, 'traffic_export_query_build_failed', 'Traffic 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, 'traffic_export_db_unavailable', 'Traffic 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, 'traffic_export_stream_failed', 'Traffic 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 $traffic_row ) {
					yield $traffic_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,
		) );
	}

	/**
	 * Returns the echo-back filter values in their default (no-filter) shape.
	 *
	 * Callers read a fixed set of keys from the filters array, so the shape must stay complete even
	 * when no filter is active. parse_query() seeds the filters from this.
	 *
	 * @return array{per_page:int,activity:array<int>,ip:string,processing:int,user:string,wp_type:int}
	 */
	private static function default_filter_values(): array {
		return array(
			'per_page'   => 0,
			'activity'   => array(),
			'ip'         => '',
			'processing' => 0,
			'user'       => '',
			'wp_type'    => 0,
		);
	}


	/**
	 * Builds a stable traffic query payload.
	 *
	 * Empty SQL strings mean that no executable query was produced.
	 *
	 * @param array<string,mixed> $filters Normalized echo-back filter values.
	 * @param string $sql Optional row SELECT statement.
	 * @param string $count_sql Optional COUNT SELECT statement.
	 * @param string $range_sql Optional MIN/MAX range SELECT statement.
	 *
	 * @return array{sql:string,count_sql:string,range_sql:string,filters:array<string,mixed>}
	 *
	 * @see parse_query()
	 */
	private static function make_query_payload( array $filters, string $sql = '', string $count_sql = '', string $range_sql = '' ): array {
		return array(
			'sql'       => $sql,
			'count_sql' => $count_sql,
			'range_sql' => $range_sql,
			'filters'   => $filters,
		);
	}

	/**
	 * Is log empty?
	 *
	 * @return bool True if the activity log is empty (nothing is logged)
	 *
	 */
	static function is_empty(): bool {

		$db_result = warp_get_db();
		$probe = $db_result->has_errors()
			? $db_result
			: $db_result->get_results()->table( CERBER_TRAF_TABLE )->select( 'stamp' )->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() ) {
			return false;
		}

		return true;
	}
}