File "PageController.php"

Full Path: /home/jlklyejr/public_html/wp-content/test/wp-content/plugins/woocommerce/src/Internal/Admin/Logging/PageController.php
File size: 21.79 KB
MIME-type: text/x-php
Charset: utf-8

<?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Admin\Logging;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\{ LogHandlerFileV2, Settings };
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ File, FileController, FileListTable, SearchListTable };
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use WC_Admin_Status;
use WC_Log_Handler_File, WC_Log_Handler_DB;
use WC_Log_Levels;
use WP_List_Table;

/**
 * PageController class.
 */
class PageController {

	use AccessiblePrivateMethods;

	/**
	 * Instance of FileController.
	 *
	 * @var FileController
	 */
	private $file_controller;

	/**
	 * Instance of Settings.
	 *
	 * @var Settings
	 */
	private $settings;

	/**
	 * Instance of FileListTable or SearchListTable.
	 *
	 * @var FileListTable|SearchListTable
	 */
	private $list_table;

	/**
	 * Initialize dependencies.
	 *
	 * @internal
	 *
	 * @param FileController $file_controller Instance of FileController.
	 * @param Settings       $settings        Instance of Settings.
	 *
	 * @return void
	 */
	final public function init(
		FileController $file_controller,
		Settings $settings
	): void {
		$this->file_controller = $file_controller;
		$this->settings        = $settings;

		$this->init_hooks();
	}

	/**
	 * Add callbacks to hooks.
	 *
	 * @return void
	 */
	private function init_hooks(): void {
		self::add_action( 'load-woocommerce_page_wc-status', array( $this, 'maybe_do_logs_tab_action' ), 2 );

		self::add_action( 'wc_logs_load_tab', array( $this, 'setup_screen_options' ) );
		self::add_action( 'wc_logs_load_tab', array( $this, 'handle_list_table_bulk_actions' ) );
		self::add_action( 'wc_logs_load_tab', array( $this, 'notices' ) );
	}

	/**
	 * Determine if the current tab on the Status page is Logs, and if so, fire an action.
	 *
	 * @return void
	 */
	private function maybe_do_logs_tab_action(): void {
		$is_logs_tab = 'logs' === filter_input( INPUT_GET, 'tab' );

		if ( $is_logs_tab ) {
			$params = $this->get_query_params( array( 'view' ) );

			/**
			 * Action fires when the Logs tab starts loading.
			 *
			 * @param string $view The current view within the Logs tab.
			 *
			 * @since 8.6.0
			 */
			do_action( 'wc_logs_load_tab', $params['view'] );
		}
	}

	/**
	 * Notices to display on Logs screens.
	 *
	 * @return void
	 */
	private function notices() {
		if ( ! $this->settings->logging_is_enabled() ) {
			add_action(
				'admin_notices',
				function() {
					?>
					<div class="notice notice-warning">
						<p>
							<?php
							printf(
								// translators: %s is a URL to another admin screen.
								wp_kses_post( __( 'Logging is disabled. It can be enabled in <a href="%s">Logs Settings</a>.', 'woocommerce' ) ),
								esc_url( add_query_arg( 'view', 'settings', $this->get_logs_tab_url() ) )
							);
							?>
						</p>
					</div>
					<?php
				}
			);
		}
	}

	/**
	 * Get the canonical URL for the Logs tab of the Status admin page.
	 *
	 * @return string
	 */
	public function get_logs_tab_url(): string {
		return add_query_arg(
			array(
				'page' => 'wc-status',
				'tab'  => 'logs',
			),
			admin_url( 'admin.php' )
		);
	}

	/**
	 * Render the "Logs" tab, depending on the current default log handler.
	 *
	 * @return void
	 */
	public function render(): void {
		$handler = $this->settings->get_default_handler();
		$params  = $this->get_query_params( array( 'view' ) );

		$this->render_section_nav();

		if ( 'settings' === $params['view'] ) {
			$this->settings->render_form();

			return;
		}

		switch ( $handler ) {
			case LogHandlerFileV2::class:
				$this->render_filev2();
				return;
			case WC_Log_Handler_DB::class:
				WC_Admin_Status::status_logs_db();
				return;
			case WC_Log_Handler_File::class:
				WC_Admin_Status::status_logs_file();
				return;
		}

		/**
		 * Action fires only if there is not a built-in rendering method for the current default log handler.
		 *
		 * This is intended as a way for extensions to render log views for custom handlers.
		 *
		 * @param string $handler
		 *
		 * @since 8.6.0
		 */
		do_action( 'wc_logs_render_page', $handler );
	}

	/**
	 * Render navigation to switch between logs browsing and settings.
	 *
	 * @return void
	 */
	private function render_section_nav(): void {
		$params       = $this->get_query_params( array( 'view' ) );
		$browse_url   = $this->get_logs_tab_url();
		$settings_url = add_query_arg( 'view', 'settings', $this->get_logs_tab_url() );

		?>
		<ul class="subsubsub">
			<li>
				<?php
				printf(
					'<a href="%1$s"%2$s>%3$s</a>',
					esc_url( $browse_url ),
					'settings' !== $params['view'] ? ' class="current"' : '',
					esc_html__( 'Browse', 'woocommerce' )
				);
				?>
				|
			</li>
			<li>
				<?php
				printf(
					'<a href="%1$s"%2$s>%3$s</a>',
					esc_url( $settings_url ),
					'settings' === $params['view'] ? ' class="current"' : '',
					esc_html__( 'Settings', 'woocommerce' )
				);
				?>
			</li>
		</ul>
		<br class="clear">
		<?php
	}

	/**
	 * Render the views for the FileV2 log handler.
	 *
	 * @return void
	 */
	private function render_filev2(): void {
		$params = $this->get_query_params( array( 'view' ) );

		switch ( $params['view'] ) {
			case 'list_files':
			default:
				$this->render_list_files_view();
				break;
			case 'search_results':
				$this->render_search_results_view();
				break;
			case 'single_file':
				$this->render_single_file_view();
				break;
		}
	}

	/**
	 * Render the file list view.
	 *
	 * @return void
	 */
	private function render_list_files_view(): void {
		$params     = $this->get_query_params( array( 'order', 'orderby', 'source', 'view' ) );
		$defaults   = $this->get_query_param_defaults();
		$list_table = $this->get_list_table( $params['view'] );

		$list_table->prepare_items();

		?>
		<header id="logs-header" class="wc-logs-header">
			<h2>
				<?php esc_html_e( 'Browse log files', 'woocommerce' ); ?>
			</h2>
			<?php $this->render_search_field(); ?>
		</header>
		<form id="logs-list-table-form" method="get">
			<input type="hidden" name="page" value="wc-status" />
			<input type="hidden" name="tab" value="logs" />
			<?php foreach ( $params as $key => $value ) : ?>
				<?php if ( $value !== $defaults[ $key ] ) : ?>
					<input
						type="hidden"
						name="<?php echo esc_attr( $key ); ?>"
						value="<?php echo esc_attr( $value ); ?>"
					/>
				<?php endif; ?>
			<?php endforeach; ?>
			<?php $list_table->display(); ?>
		</form>
		<?php
	}

	/**
	 * Render the single file view.
	 *
	 * @return void
	 */
	private function render_single_file_view(): void {
		$params = $this->get_query_params( array( 'file_id', 'view' ) );
		$file   = $this->file_controller->get_file_by_id( $params['file_id'] );

		if ( is_wp_error( $file ) ) {
			?>
			<div class="notice notice-error notice-inline">
				<?php echo wp_kses_post( wpautop( $file->get_error_message() ) ); ?>
				<?php
				printf(
					'<p><a href="%1$s">%2$s</a></p>',
					esc_url( $this->get_logs_tab_url() ),
					esc_html__( 'Return to the file list.', 'woocommerce' )
				);
				?>
			</div>
			<?php

			return;
		}

		$rotations         = $this->file_controller->get_file_rotations( $file->get_file_id() );
		$rotation_url_base = add_query_arg( 'view', 'single_file', $this->get_logs_tab_url() );

		$download_url           = add_query_arg(
			array(
				'action'  => 'export',
				'file_id' => array( $file->get_file_id() ),
			),
			wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
		);
		$delete_url             = add_query_arg(
			array(
				'action'  => 'delete',
				'file_id' => array( $file->get_file_id() ),
			),
			wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
		);
		$delete_confirmation_js = sprintf(
			"return window.confirm( '%s' )",
			esc_js( __( 'Delete this log file permanently?', 'woocommerce' ) )
		);

		$stream      = $file->get_stream();
		$line_number = 1;

		?>
		<header id="logs-header" class="wc-logs-header">
			<h2>
				<?php
				printf(
					// translators: %s is the name of a log file.
					esc_html__( 'Viewing log file %s', 'woocommerce' ),
					sprintf(
						'<span class="file-id">%s</span>',
						esc_html( $file->get_file_id() )
					)
				);
				?>
			</h2>
			<?php if ( count( $rotations ) > 1 ) : ?>
				<nav class="wc-logs-single-file-rotations">
					<h3><?php esc_html_e( 'File rotations:', 'woocommerce' ); ?></h3>
					<ul class="wc-logs-rotation-links">
						<?php if ( isset( $rotations['current'] ) ) : ?>
							<?php
							printf(
								'<li><a href="%1$s" class="button button-small button-%2$s">%3$s</a></li>',
								esc_url( add_query_arg( 'file_id', $rotations['current']->get_file_id(), $rotation_url_base ) ),
								$file->get_file_id() === $rotations['current']->get_file_id() ? 'primary' : 'secondary',
								esc_html__( 'Current', 'woocommerce' )
							);
							unset( $rotations['current'] );
							?>
						<?php endif; ?>
						<?php foreach ( $rotations as $rotation ) : ?>
							<?php
							printf(
								'<li><a href="%1$s" class="button button-small button-%2$s">%3$s</a></li>',
								esc_url( add_query_arg( 'file_id', $rotation->get_file_id(), $rotation_url_base ) ),
								$file->get_file_id() === $rotation->get_file_id() ? 'primary' : 'secondary',
								absint( $rotation->get_rotation() )
							);
							?>
						<?php endforeach; ?>
					</ul>
				</nav>
			<?php endif; ?>
			<div class="wc-logs-single-file-actions">
				<?php
				// Download button.
				printf(
					'<a href="%1$s" class="button button-secondary">%2$s</a>',
					esc_url( $download_url ),
					esc_html__( 'Download', 'woocommerce' )
				);
				?>
				<?php
				// Delete button.
				printf(
					'<a href="%1$s" class="button button-secondary" onclick="%2$s">%3$s</a>',
					esc_url( $delete_url ),
					esc_attr( $delete_confirmation_js ),
					esc_html__( 'Delete permanently', 'woocommerce' )
				);
				?>
			</div>
		</header>
		<section id="logs-entries" class="wc-logs-entries">
			<?php while ( ! feof( $stream ) ) : ?>
				<?php
				$line = fgets( $stream );
				if ( is_string( $line ) ) {
					// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- format_line does the escaping.
					echo $this->format_line( $line, $line_number );
					$line_number ++;
				}
				?>
			<?php endwhile; ?>
		</section>
		<script>
			// Clear the line number hash and highlight with a click.
			document.documentElement.addEventListener( 'click', ( event ) => {
				if ( window.location.hash && ! event.target.classList.contains( 'line-anchor' ) ) {
					let scrollPos = document.documentElement.scrollTop;
					window.location.hash = '';
					document.documentElement.scrollTop = scrollPos;
					history.replaceState( null, '', window.location.pathname + window.location.search );
				}
			} );
		</script>
		<?php
	}

	/**
	 * Render the search results view.
	 *
	 * @return void
	 */
	private function render_search_results_view(): void {
		$params     = $this->get_query_params( array( 'view' ) );
		$list_table = $this->get_list_table( $params['view'] );

		$list_table->prepare_items();

		?>
		<header id="logs-header" class="wc-logs-header">
			<h2><?php esc_html_e( 'Search results', 'woocommerce' ); ?></h2>
			<?php $this->render_search_field(); ?>
		</header>
		<?php $list_table->display(); ?>
		<?php
	}

	/**
	 * Get the default values for URL query params for FileV2 views.
	 *
	 * @return string[]
	 */
	public function get_query_param_defaults(): array {
		return array(
			'file_id' => '',
			'order'   => $this->file_controller::DEFAULTS_GET_FILES['order'],
			'orderby' => $this->file_controller::DEFAULTS_GET_FILES['orderby'],
			'search'  => '',
			'source'  => $this->file_controller::DEFAULTS_GET_FILES['source'],
			'view'    => 'list_files',
		);
	}

	/**
	 * Get and validate URL query params for FileV2 views.
	 *
	 * @param array $param_keys Optional. The names of the params you want to get.
	 *
	 * @return array
	 */
	public function get_query_params( array $param_keys = array() ): array {
		$defaults = $this->get_query_param_defaults();
		$params   = filter_input_array(
			INPUT_GET,
			array(
				'file_id' => array(
					'filter'  => FILTER_CALLBACK,
					'options' => function( $file_id ) {
						return sanitize_file_name( wp_unslash( $file_id ) );
					},
				),
				'order'   => array(
					'filter'  => FILTER_VALIDATE_REGEXP,
					'options' => array(
						'regexp'  => '/^(asc|desc)$/i',
						'default' => $defaults['order'],
					),
				),
				'orderby' => array(
					'filter'  => FILTER_VALIDATE_REGEXP,
					'options' => array(
						'regexp'  => '/^(created|modified|source|size)$/',
						'default' => $defaults['orderby'],
					),
				),
				'search'  => array(
					'filter'  => FILTER_CALLBACK,
					'options' => function( $search ) {
						return esc_html( wp_unslash( $search ) );
					},
				),
				'source'  => array(
					'filter'  => FILTER_CALLBACK,
					'options' => function( $source ) {
						return File::sanitize_source( wp_unslash( $source ) );
					},
				),
				'view'    => array(
					'filter'  => FILTER_VALIDATE_REGEXP,
					'options' => array(
						'regexp'  => '/^(list_files|single_file|search_results|settings)$/',
						'default' => $defaults['view'],
					),
				),
			),
			false
		);
		$params   = wp_parse_args( $params, $defaults );

		if ( count( $param_keys ) > 0 ) {
			$params = array_intersect_key( $params, array_flip( $param_keys ) );
		}

		return $params;
	}

	/**
	 * Get and cache an instance of the list table.
	 *
	 * @param string $view The current view, which determines which list table class to get.
	 *
	 * @return FileListTable|SearchListTable
	 */
	private function get_list_table( string $view ) {
		if ( $this->list_table instanceof WP_List_Table ) {
			return $this->list_table;
		}

		switch ( $view ) {
			case 'list_files':
				$this->list_table = new FileListTable( $this->file_controller, $this );
				break;
			case 'search_results':
				$this->list_table = new SearchListTable( $this->file_controller, $this );
				break;
		}

		return $this->list_table;
	}

	/**
	 * Register screen options for the logging views.
	 *
	 * @param string $view The current view within the Logs tab.
	 *
	 * @return void
	 */
	private function setup_screen_options( string $view ): void {
		$handler    = $this->settings->get_default_handler();
		$list_table = null;

		switch ( $handler ) {
			case LogHandlerFileV2::class:
				if ( in_array( $view, array( 'list_files', 'search_results' ), true ) ) {
					$list_table = $this->get_list_table( $view );
				}
				break;
			case 'WC_Log_Handler_DB':
					$list_table = WC_Admin_Status::get_db_log_list_table();
				break;
		}

		if ( $list_table instanceof WP_List_Table ) {
			// Ensure list table columns are initialized early enough to enable column hiding, if available.
			$list_table->prepare_column_headers();

			add_screen_option(
				'per_page',
				array(
					'default' => $list_table->get_per_page_default(),
					'option'  => $list_table::PER_PAGE_USER_OPTION_KEY,
				)
			);
		}
	}

	/**
	 * Process bulk actions initiated from the log file list table.
	 *
	 * @param string $view The current view within the Logs tab.
	 *
	 * @return void
	 */
	private function handle_list_table_bulk_actions( string $view ): void {
		// Bail if we're not using the file handler.
		if ( LogHandlerFileV2::class !== $this->settings->get_default_handler() ) {
			return;
		}

		$params = $this->get_query_params( array( 'file_id' ) );

		// Bail if this is not the list table view.
		if ( 'list_files' !== $view ) {
			return;
		}

		$action = $this->get_list_table( $view )->current_action();

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : $this->get_logs_tab_url();

		if ( $action ) {
			check_admin_referer( 'bulk-log-files' );

			if ( ! current_user_can( 'manage_woocommerce' ) ) {
				wp_die( esc_html__( 'You do not have permission to manage log files.', 'woocommerce' ) );
			}

			$sendback = remove_query_arg( array( 'deleted' ), wp_get_referer() );

			// Multiple file_id[] params will be filtered separately, but assigned to $files as an array.
			$file_ids = $params['file_id'];

			if ( ! is_array( $file_ids ) || count( $file_ids ) < 1 ) {
				wp_safe_redirect( $sendback );
				exit;
			}

			switch ( $action ) {
				case 'export':
					if ( 1 === count( $file_ids ) ) {
						$export_error = $this->file_controller->export_single_file( reset( $file_ids ) );
					} else {
						$export_error = $this->file_controller->export_multiple_files( $file_ids );
					}

					if ( is_wp_error( $export_error ) ) {
						wp_die( wp_kses_post( $export_error ) );
					}
					break;
				case 'delete':
					$deleted  = $this->file_controller->delete_files( $file_ids );
					$sendback = add_query_arg( 'deleted', $deleted, $sendback );

					/**
					 * If the delete action was triggered on the single file view, don't redirect back there
					 * since the file doesn't exist anymore.
					 */
					$sendback = remove_query_arg( array( 'view', 'file_id' ), $sendback );
					break;
			}

			$sendback = remove_query_arg( array( 'action', 'action2' ), $sendback );

			wp_safe_redirect( $sendback );
			exit;
		} elseif ( ! empty( $_REQUEST['_wp_http_referer'] ) ) {
			$removable_args = array( '_wp_http_referer', '_wpnonce', 'action', 'action2', 'filter_action' );
			wp_safe_redirect( remove_query_arg( $removable_args, $request_uri ) );
			exit;
		}

		$deleted = filter_input( INPUT_GET, 'deleted', FILTER_VALIDATE_INT );

		if ( is_numeric( $deleted ) ) {
			add_action(
				'admin_notices',
				function() use ( $deleted ) {
					?>
					<div class="notice notice-info is-dismissible">
						<p>
							<?php
							printf(
							// translators: %s is a number of files.
								esc_html( _n( '%s log file deleted.', '%s log files deleted.', $deleted, 'woocommerce' ) ),
								esc_html( number_format_i18n( $deleted ) )
							);
							?>
						</p>
					</div>
					<?php
				}
			);
		}
	}

	/**
	 * Format a log file line.
	 *
	 * @param string $line        The unformatted log file line.
	 * @param int    $line_number The line number.
	 *
	 * @return string
	 */
	private function format_line( string $line, int $line_number ): string {
		$classes = array( 'line' );

		$line = esc_html( $line );
		if ( empty( $line ) ) {
			$line = '&nbsp;';
		}

		$segments      = explode( ' ', $line, 3 );
		$has_timestamp = false;
		$has_level     = false;

		if ( isset( $segments[0] ) && false !== strtotime( $segments[0] ) ) {
			$classes[]     = 'log-entry';
			$segments[0]   = sprintf(
				'<span class="log-timestamp">%s</span>',
				$segments[0]
			);
			$has_timestamp = true;
		}

		if ( isset( $segments[1] ) && WC_Log_Levels::is_valid_level( strtolower( $segments[1] ) ) ) {
			$segments[1] = sprintf(
				'<span class="%1$s">%2$s</span>',
				esc_attr( 'log-level log-level--' . strtolower( $segments[1] ) ),
				esc_html( WC_Log_Levels::get_level_label( strtolower( $segments[1] ) ) )
			);
			$has_level   = true;
		}

		if ( isset( $segments[2] ) && $has_timestamp && $has_level ) {
			$message_chunks = explode( 'CONTEXT:', $segments[2], 2 );
			if ( isset( $message_chunks[1] ) ) {
				try {
					$maybe_json = stripslashes( html_entity_decode( trim( $message_chunks[1] ) ) );
					$context    = json_decode( $maybe_json, false, 512, JSON_THROW_ON_ERROR );

					$message_chunks[1] = sprintf(
						'<details><summary>%1$s</summary>%2$s</details>',
						esc_html__( 'Additional context', 'woocommerce' ),
						wp_json_encode( $context, JSON_PRETTY_PRINT )
					);

					$segments[2] = implode( ' ', $message_chunks );
					$classes[]   = 'has-context';
				} catch ( \JsonException $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
					// It's not valid JSON so don't do anything with it.
				}
			}
		}

		if ( count( $segments ) > 1 ) {
			$line = implode( ' ', $segments );
		}

		$classes = implode( ' ', $classes );

		return sprintf(
			'<span id="L%1$d" class="%2$s">%3$s%4$s</span>',
			absint( $line_number ),
			esc_attr( $classes ),
			sprintf(
				'<a href="#L%1$d" class="line-anchor"></a>',
				absint( $line_number )
			),
			sprintf(
				'<span class="line-content">%s</span>',
				wp_kses_post( $line )
			)
		);
	}

	/**
	 * Render a form for searching within log files.
	 *
	 * @return void
	 */
	private function render_search_field(): void {
		$params     = $this->get_query_params( array( 'date_end', 'date_filter', 'date_start', 'search', 'source' ) );
		$defaults   = $this->get_query_param_defaults();
		$file_count = $this->file_controller->get_files( $params, true );

		if ( $file_count > 0 ) {
			?>
			<form id="logs-search" class="wc-logs-search" method="get">
				<fieldset class="wc-logs-search-fieldset">
					<input type="hidden" name="page" value="wc-status" />
					<input type="hidden" name="tab" value="logs" />
					<input type="hidden" name="view" value="search_results" />
					<?php foreach ( $params as $key => $value ) : ?>
						<?php if ( $value !== $defaults[ $key ] ) : ?>
							<input
								type="hidden"
								name="<?php echo esc_attr( $key ); ?>"
								value="<?php echo esc_attr( $value ); ?>"
							/>
						<?php endif; ?>
					<?php endforeach; ?>
					<label for="logs-search-field">
						<?php esc_html_e( 'Search within these files', 'woocommerce' ); ?>
						<input
							id="logs-search-field"
							class="wc-logs-search-field"
							type="text"
							name="search"
							value="<?php echo esc_attr( $params['search'] ); ?>"
						/>
					</label>
					<?php submit_button( __( 'Search', 'woocommerce' ), 'secondary', null, false ); ?>
				</fieldset>
				<?php if ( $file_count >= $this->file_controller::SEARCH_MAX_FILES ) : ?>
					<div class="wc-logs-search-notice">
						<?php
						printf(
							// translators: %s is a number.
							esc_html__(
								'⚠️ Only %s files can be searched at one time. Try filtering the file list before searching.',
								'woocommerce'
							),
							esc_html( number_format_i18n( $this->file_controller::SEARCH_MAX_FILES ) )
						);
						?>
					</div>
				<?php endif; ?>
			</form>
			<?php
		}
	}
}