Your IP : 216.73.217.32


Current Path : /home/icmq6107/clients/CTOPTOGO/wp-content/plugins/defender-security/src/controller/
Upload File :
Current File : /home/icmq6107/clients/CTOPTOGO/wp-content/plugins/defender-security/src/controller/class-scan.php

<?php
/**
 * Handles all scan related actions.
 *
 * @package WP_Defender\Controller
 */

namespace WP_Defender\Controller;

use ActionScheduler;
use WP_Defender\Event;
use Valitron\Validator;
use Calotes\Component\Request;
use Calotes\Component\Response;
use WP_Defender\Controller\Quarantine;
use WP_Defender\Traits\Formats;
use WP_Defender\Traits\Scan_Upsell;
use WP_Defender\Model\Scan_Item;
use WP_Defender\Behavior\WPMUDEV;
use WP_Defender\Model\Scan as Model_Scan;
use WP_Defender\Behavior\Scan\Core_Integrity;
use WP_Defender\Component\Network_Cron_Manager;
use WP_Defender\Component\Scan as Scan_Component;
use WP_Defender\Component\Rate as Rate_Component;
use WP_Defender\Model\Setting\Scan as Scan_Settings;
use WP_Defender\Model\Notification\Malware_Report;
use WP_Defender\Component\Config\Config_Hub_Helper;
use WP_Defender\Helper\Analytics\Scan as Scan_Analytics;
use WP_Defender\Model\Notification\Malware_Notification;
use WP_Defender\Component\Quarantine as Quarantine_Component;
use WP_Defender\Behavior\Scan\Plugin_Integrity;

/**
 * Contains methods for handling scans.
 */
class Scan extends Event {

	use Formats;
	use Scan_Upsell;

	public const SCAN_LOG = 'scan.log';

	/**
	 * Records whether a scan has been started on this installation.
	 *
	 * @var string
	 */
	public const FIRST_SCAN_STARTED = 'wp_defender_first_scan_started';

	/**
	 * Default number of issue items per page on the Scan Issues UI.
	 */
	public const DEFAULT_PER_PAGE = 10;

	/**
	 * The slug identifier for this controller.
	 *
	 * @var string
	 */
	protected $slug = 'wdf-scan';

	/**
	 * The model for handling the data.
	 *
	 * @var Scan_Settings
	 */
	protected $model;

	/**
	 * Service for handling logic.
	 *
	 * @var Scan_Component
	 */
	protected $service;
	/**
	 * Quarantine controller.
	 *
	 * @var Quarantine
	 */
	private $quarantine_controller;

	/**
	 * Initializes the model and service, registers routes, and sets up scheduled events if the model is active.
	 */
	public function __construct() {
		$this->register_page(
			$this->get_title(),
			$this->slug,
			array( $this, 'main_view' ),
			$this->parent_slug
		);

		$this->model                 = wd_di()->get( Scan_Settings::class );
		$this->service               = wd_di()->get( Scan_Component::class );
		$this->quarantine_controller = wd_di()->get( Quarantine::class );
		$wpmudev                     = wd_di()->get( WPMUDEV::class );


		$this->register_routes();
		add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
		add_action( 'wp_ajax_defender_process_scan', array( $this, 'process' ) );
		add_action( 'wp_ajax_nopriv_defender_process_scan', array( $this, 'process' ) );
		add_action( 'defender/async_scan', array( $this, 'process' ) );
		// Clean up data after successful core update.
		add_action( '_core_updated_successfully', array( $this, 'clean_up_data' ) );

		global $pagenow;
		// since 2.6.2.
		if (
			is_admin() &&
			'plugins.php' === $pagenow &&
			apply_filters( 'wd_display_vulnerability_warnings', true ) &&
			$wpmudev->is_apikey_available()
		) {
			$this->service->display_vulnerability_warnings();
		}

		/**
		 * Schedule a time to clear completed action scheduler logs.
		 *
		 * @var Network_Cron_Manager $network_cron_manager
		 */
		$network_cron_manager = wd_di()->get( Network_Cron_Manager::class );
		$network_cron_manager->register_callback(
			'wpdef_clear_scan_logs',
			array( $this, 'clear_scan_logs' ),
			WEEK_IN_SECONDS
		);

		add_filter( 'heartbeat_nopriv_send', array( $this, 'nopriv_heartbeat' ), 10, 2 );

		add_action(
			'action_scheduler_completed_action',
			array( $this, 'scan_completed_analytics' )
		);
	}

	/**
	 * Return the title of the page.
	 *
	 * @return string The title of the page.
	 */
	public function get_title(): string {
		return esc_html__( 'Issues', 'defender-security' );
	}

	/**
	 * Clean up data after core updating.
	 *
	 * @return void
	 */
	public function clean_up_data(): void {
		$this->service->clean_up();
	}

	/**
	 * Start a scan.
	 *
	 * @param  Request $request  Request object.
	 *
	 * @return Response
	 * @defender_route
	 * @defender_redirect
	 */
	public function start( Request $request ): Response {
		$data      = $request->get_data(
			array(
				'scan_type' => array(
					'type'     => 'string',
					'sanitize' => 'sanitize_key',
				),
			)
		);
		$scan_type = in_array( $data['scan_type'] ?? '', array( 'deep', 'malware' ), true )
			? $data['scan_type']
			: 'malware';

		$model = Model_Scan::create();
		if ( is_object( $model ) && ! is_wp_error( $model ) ) {
			update_site_option( self::FIRST_SCAN_STARTED, '1' );
			set_transient( 'defender_scan_triggered_by_' . $model->id, get_current_user_id(), HOUR_IN_SECONDS );
			Model_Scan::set_scan_type( $scan_type );
			$this->log( 'Initial ping self', self::SCAN_LOG );
			$this->run_scan_mechanisms_from( 'scan' );

			return new Response(
				true,
				array(
					'status'      => $model->status,
					'status_text' => $model->get_status_text(),
					'percent'     => 0,
					'scan_type'   => $scan_type,
				)
			);
		}

		return new Response(
			false,
			array(
				'message' => esc_html__( 'A scan is already in progress', 'defender-security' ),
			)
		);
	}

	/**
	 * Use this for self ping, so it can both run in background and active mode with good performance.
	 *
	 * @return void
	 * @defender_route
	 * @is_public
	 */
	public function process() {
		$lock_filename = $this->service->get_lock_filename();
		if ( ! $this->service->try_create_lock( $lock_filename ) ) {
			$this->log( 'Fallback as already a process is running', self::SCAN_LOG );

			return;
		}

		// Check if the ping is from self or not.
		$ret = $this->service->process();
		$this->log( 'process done, queue for next', self::SCAN_LOG );
		if ( false === $ret ) {
			// Ping self.
			$this->log( 'Scan not done, pinging', self::SCAN_LOG );
			$this->service->remove_lock( $lock_filename );
			$this->process();
		} else {
			$this->queue_to_sync_with_hub();
			$this->service->remove_lock( $lock_filename );
		}
	}

	/**
	 * Query status.
	 *
	 * @return Response
	 * @defender_route
	 * @defender_redirect
	 */
	public function status(): Response {
		$scan_type = Model_Scan::get_scan_type();
		$idle_scan = wd_di()->get( Model_Scan::class )->get_idle();

		if ( is_object( $idle_scan ) ) {
			$this->service->update_idle_scan_status();
			$response = $this->get_status_response_data( $idle_scan, $scan_type );

			return new Response( true, $response );
		}

		$checksum_issue = get_site_option( Core_Integrity::ISSUE_CHECKSUMS, 'false' );
		$checksum_scan  = Model_Scan::get_core_check();
		if ( 'false' !== $checksum_issue && is_object( $checksum_scan ) ) {
			$this->service->update_idle_scan_status_by_checksum_issue( $checksum_scan );
			$response = $this->get_status_response_data( $checksum_scan, $scan_type );

			return new Response( true, $response );
		}

		$scan = Model_Scan::get_active();
		if ( is_object( $scan ) ) {
			$response = $this->get_status_response_data( $scan, $scan_type );

			return new Response( true, $response );
		}

		$scan = Model_Scan::get_last();
		if ( is_object( $scan ) && ! is_wp_error( $scan ) ) {
			$response              = array_merge( $this->get_status_response_data( $scan, $scan_type ), $this->get_last_scan_time_data( $scan ) );
			$response['message']   = __( 'Malware scan completed successfully!', 'defender-security' );
			$response['scan_type'] = $scan_type;
			if ( 'deep' === $scan_type ) {
				$security_tweaks             = wd_di()->get( \WP_Defender\Controller\Security_Tweaks::class )->dashboard_widget();
				$response['hardening_count'] = (int) ( $security_tweaks['summary']['issues_count'] ?? 0 );
			}
			if ( isset( $this->quarantine_controller ) ) {
				$response['quarantine'] = $this->quarantine_controller->data_frontend()['list'] ?? array();
			}

			return new Response( true, $response );
		}

		return new Response(
			false,
			array(
				'message' => esc_html__( 'Error during scanning', 'defender-security' ),
			)
		);
	}

	/**
	 * Build the compatible status payload and consume its queued messages.
	 *
	 * @param Model_Scan $scan      Scan model used to build the status payload.
	 * @param string     $scan_type Current scan type.
	 */
	private function get_status_response_data( Model_Scan $scan, string $scan_type ): array {
		$response                    = $scan->to_array();
		$response['status_text']     = $response['status_text'] ?? $scan->get_status_text();
		$response['percent']         = $response['percent'] ?? $scan->percent;
		$response['scan_type']       = $scan_type;
		$response['status_messages'] = $scan->drain_status_messages();

		return $response;
	}

	/**
	 * Cancel current scan.
	 *
	 * @return Response
	 * @defender_route
	 * @defender_redirect
	 */
	public function cancel(): Response {
		$component = wd_di()->get( Scan_Component::class );
		$component->cancel_a_scan();
		Model_Scan::clear_scan_type();
		$last = Model_Scan::get_last();
		if ( is_object( $last ) && ! is_wp_error( $last ) ) {
			$last = $last->to_array();
		}

		return new Response(
			true,
			array(
				'scan' => $last,
			)
		);
	}

	/**
	 * Track scan item action analytics.
	 *
	 * @param  Scan_Item $scan_item  Individual item of scan issues list.
	 * @param  string    $intention  What action is going to be executed.
	 */
	private function item_action_analytics( Scan_Item $scan_item, string $intention ) {
		$allowed_intentions = Scan_Component::get_intentions();

		$event_name = 'def_threat_resolved';

		if ( in_array( $intention, $allowed_intentions, true ) ) {
			$intention_desc = array(
				'resolve'    => 'Safe Repair',
				'ignore'     => 'Ignore',
				'delete'     => 'Delete',
				'unignore'   => 'Unignore',
				'quarantine' => 'Safe Repair & Quarantine',
			);

			$resolution_method = $intention_desc[ $intention ];
			$threat_type       = '';

			if ( Scan_Item::TYPE_INTEGRITY === $scan_item->type ) {
				// Track Repair-actions.
				if ( in_array( $intention, array( 'resolve', 'quarantine' ), true ) ) {
					$threat_type = 'core file modified';
				} else {
					$threat_type = 'Unknown file in WordPress core';
				}
			} elseif ( Scan_Item::TYPE_PLUGIN_CHECK === $scan_item->type ) {
				$raw_data = $scan_item->raw_data;

				if ( isset( $raw_data['type'] ) && 'modified' === $raw_data['type'] ) {
					$threat_type = 'plugin file modified';
				}
			} elseif ( Scan_Item::TYPE_VULNERABILITY === $scan_item->type ) {
				$threat_type = 'Vulnerability';

				if ( 'resolve' === $intention ) {
					$resolution_method = 'Update';
				}
			} elseif ( Scan_Item::TYPE_SUSPICIOUS === $scan_item->type ) {
				$threat_type = 'Suspicious function';
			} elseif (
				in_array(
					$scan_item->type,
					Model_Scan::get_abandoned_types(),
					true
				)
			) {
				$threat_type = 'Outdated & removed plugins';
			}

			$this->track_feature(
				$event_name,
				array(
					'Resolution Method' => $resolution_method,
					'Threat type'       => $threat_type,
				)
			);
		}
	}

	/**
	 * A central controller to pass any request from frontend to scan item.
	 *
	 * @param  Request $request  Request object.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function item_action( Request $request ): Response {
		$data      = $request->get_data(
			array(
				'id'            => array(
					'type'     => 'int',
					'sanitize' => 'sanitize_text_field',
				),
				'intention'     => array(
					'type'     => 'string',
					'sanitize' => 'sanitize_text_field',
				),
				'parent_action' => array(
					'type'     => 'string',
					'sanitize' => 'sanitize_text_field',
				),
			)
		);
		$id        = $data['id'] ?? false;
		$intention = $data['intention'] ?? false;
		// Get allowed intentions.
		$allowed_intentions   = Scan_Component::get_intentions();
		$allowed_intentions[] = 'pull_src';
		if ( false === $id || false === $intention || ! in_array(
			$intention,
			$allowed_intentions,
			true
		) ) {
			return new Response( false, array() );
		}

		$scan = Model_Scan::get_last();
		if ( $scan instanceof Model_Scan ) {
			$item = $scan->get_issue( $id );
			if ( is_object( $item ) && $item->has_method( $intention ) ) {
				if ( 'resolve' === $intention ) {
					$result = $item->resolve();
				} elseif ( 'quarantine' === $intention ) {
					$result = $item->quarantine( $data['parent_action'], $item->owner );
				} elseif ( 'delete' === $intention ) {
					$result = $item->delete();
				} elseif ( 'ignore' === $intention ) {
					$result = $item->ignore();
				} elseif ( 'unignore' === $intention ) {
					$result = $item->unignore();
				} elseif ( 'pull_src' === $intention ) {
					$result = $item->pull_src();
				}

				// Maybe track.
				if ( $this->is_tracking_active() ) {
					$this->item_action_analytics( $item, $intention );
				}

				if ( is_wp_error( $result ) ) {
					return new Response(
						false,
						array(
							'message' => $result->get_error_message(),
						)
					);
				} elseif ( isset( $result['type_notice'] ) ) {
					return new Response(
						true,
						$result
					);
				} elseif ( isset( $result['url'] ) ) {
					// Without message and interval args.
					return new Response(
						true,
						array( 'redirect' => $result['url'] )
					);
				}

				$this->queue_to_sync_with_hub();

				// Refresh scan instance.
				$scan = Model_Scan::get_last();

				if ( $scan instanceof Model_Scan ) {
					$result['scan'] = $scan->to_array();

					if ( 'quarantine' === $intention && isset( $this->quarantine_controller ) ) {
						$result['quarantine'] = $this->quarantine_controller->data_frontend()['list'] ?? array();
					}

					$success = true;
					if ( isset( $result['success'] ) && false === $result['success'] ) {
						$success = false;
					}

					return new Response( $success, $result );
				}
			}
		}

		return new Response( false, array() );
	}

	/**
	 * Process for bulk action.
	 * There is no Update-intention because it is a lengthy process. There may not be enough execution time.
	 *
	 * @param  Request $request  Request object.
	 *
	 * @defender_route
	 * @return Response
	 */
	public function bulk_action( Request $request ): Response {
		$data      = $request->get_data(
			array(
				'items' => array(
					'type'     => 'array',
					'sanitize' => 'sanitize_text_field',
				),
				'bulk'  => array(
					'type'     => 'string',
					'sanitize' => 'sanitize_text_field',
				),
			)
		);
		$items     = $data['items'] ?? array();
		$intention = $data['bulk'] ?? false;

		if (
			! is_array( $items )
			|| array() === $items
			|| ! in_array( $intention, array( 'ignore', 'unignore', 'delete' ), true )
		) {
			return new Response( false, array() );
		}
		// Try to get Scan.
		$scan = Model_Scan::get_last();
		if ( ! is_object( $scan ) ) {
			return new Response( false, array() );
		}

		$is_delete         = false;
		$delete_items      = array();
		$none_delete_items = array();
		$sync_hub          = false;
		foreach ( $items as $id ) {
			if ( 'ignore' === $intention ) {
				$sync_hub = $scan->ignore_issue( (int) $id );
			} elseif ( 'unignore' === $intention ) {
				$sync_hub = $scan->unignore_issue( (int) $id );
			} elseif ( 'delete' === $intention ) {
				$item = $scan->get_issue( (int) $id );
				// Work with every item.
				if ( is_object( $item ) && $item->has_method( $intention ) ) {
					$item_result = $item->delete();
					if ( is_wp_error( $item_result ) ) {
						$none_delete_items[] = $item_result->get_error_message();
					} elseif ( isset( $item_result['type_notice'] ) ) {
						return new Response( true, $item_result );
					} elseif ( isset( $item_result['collect_type'] ) ) {
						$is_delete      = true;
						$delete_items[] = $item_result['message'];
					}
					// If there is any error, no need to sync data.
					$sync_hub = true;
				}
			}
		}

		if ( $sync_hub ) {
			$this->queue_to_sync_with_hub();
		}

		$result = array();
		if ( array() !== $none_delete_items ) {
			$result['message'] = sprintf(
			/* translators: %s: Vulnerability item(es) */
				_n(
					'Defender doesn\'t have enough permission to remove this file: %s',
					'Defender doesn\'t have enough permission to remove these files: %s',
					count( $none_delete_items ),
					'defender-security'
				),
				'<pre>' . implode( PHP_EOL, $none_delete_items ) . '</pre>'
			);
		} elseif ( $is_delete ) {
			$result['message'] = sprintf(
			/* translators: %s: Vulnerability item(es) */
				esc_html__( '%s has (have) been deleted', 'defender-security' ),
				implode( ', ', $delete_items )
			);
		}
		// Refresh scan instance.
		$scan           = Model_Scan::get_last();
		$result['scan'] = $scan->to_array();

		return new Response( array() === $none_delete_items, $result );
	}

	/**
	 * Save settings.
	 *
	 * @param  Request $request  The request object containing new settings data.
	 *
	 * @return Response
	 * @since 2.7.0 Add Scheduled Scanning to Malware settings and hide it on Malware Scanning - Reporting.
	 * Also, the backward compatibility of settings for Scan and Malware_Report models.
	 * @defender_route
	 */
	public function save_settings( Request $request ): Response {
		$data = $request->get_data_by_model( $this->model );
		// Prepare for the state's change.
		$old_integrity_check_state = $this->model->integrity_check;
		// Case#1: inherit the parent's state to nested options.
		if ( $old_integrity_check_state !== $data['integrity_check'] ) {
			$data['check_core']    = $data['integrity_check'];
			$data['check_plugins'] = $data['integrity_check'];
		}
		// Case#2: Suspicious code is activated BUT File change detection is deactivated then show the notice.
		if ( $data['scan_malware'] && ! $data['integrity_check'] ) {
			$response = array(
				'type_notice' => 'info',
				'message'     => sprintf(
					/* translators: 1. Open tag. 2. Close tag. 3. Open tag. 4. Close tag. */
					esc_html__(
						'To reduce false-positive results, we recommend enabling %1$sFile change detection%2$s options for all scan types while the %3$sSuspicious code%4$s option is enabled.',
						'defender-security'
					),
					'<strong>',
					'</strong>',
					'<strong>',
					'</strong>'
				),
			);
		} else {
			$response = array(
				'message'    => esc_html__( 'Your settings have been updated.', 'defender-security' ),
				'auto_close' => true,
			);
		}
		$before_import_schedule = $this->model->quarantine_expire_schedule;

		$this->model->import( $data );
		if ( $this->model->validate() ) {
			if ( class_exists( 'WP_Defender\Component\Quarantine' ) ) {
				$quarantine_component = wd_di()->get( Quarantine_Component::class );
				$quarantine_component->reschedule_file_expiry_cron(
					$before_import_schedule,
					$data['quarantine_expire_schedule']
				);
			}

			$this->model->save();
			Config_Hub_Helper::set_clear_active_flag();

			return new Response(
				true,
				array_merge( $response, $this->data_frontend() )
			);
		} else {
			return new Response(
				false,
				array_merge(
					array(
						'message' => $this->model->get_formatted_errors(),
					),
					$this->data_frontend()
				)
			);
		}
	}

	/**
	 * Get the issues mainly for pagination request.
	 *
	 * @param  Request $request  The request object.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function get_issues( Request $request ): Response {
		$data = $request->get_data(
			array(
				'scenario' => array(
					'type'     => 'string',
					'sanitize' => 'sanitize_text_field',
				),
				'type'     => array(
					'type'     => 'array',
					'sanitize' => 'sanitize_text_field',
				),
				'per_page' => array(
					'type'     => 'string',
					'sanitize' => 'intval',
				),
				'paged'    => array(
					'type'     => 'int',
					'sanitize' => 'intval',
				),
			)
		);

		// Validate the request.
		$v = new Validator( $data, array() );
		$v->rule( 'required', array( 'scenario', 'type', 'per_page', 'paged' ) );
		if ( ! $v->validate() ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Wrong scan issue data.', 'defender-security' ),
				)
			);
		}

		$scan   = Model_Scan::get_last();
		$issues = $scan->to_array( $data['per_page'], $data['paged'], $this->normalize_issue_types( $data['type'] ), $data['scenario'] );

		$response = array(
			'issue'   => $issues['issues_items'],
			'ignored' => $issues['ignored_items'],
			'paging'  => $issues['paging'],
			'count'   => $issues['count'],
		);

		if ( class_exists( 'WP_Defender\Controller\Quarantine' ) ) {
			$response['quarantine'] = $this->quarantine_controller->data_frontend()['list'] ?? array();
		}

		return new Response( true, $response );
	}

	/**
	 * Normalize issue type filters from the legacy and redesign payloads.
	 *
	 * @param  array|string $types  Requested issue type(s).
	 *
	 * @return array|string
	 */
	private function normalize_issue_types( $types ) {
		$type_aliases = array(
			'core'                => Scan_Item::TYPE_INTEGRITY,
			'plugin'              => Scan_Item::TYPE_PLUGIN_CHECK,
			'suspicious'          => Scan_Item::TYPE_SUSPICIOUS,
			'known_vulnerability' => Scan_Item::TYPE_VULNERABILITY,
			'plugin_closed'       => Scan_Item::TYPE_PLUGIN_CLOSED,
			'plugin_outdated'     => Scan_Item::TYPE_PLUGIN_OUTDATED,
		);

		$types = is_array( $types ) ? $types : array( $types );
		$types = array_filter(
			array_map(
				static function ( $type ) use ( $type_aliases ) {
					return $type_aliases[ $type ] ?? $type;
				},
				$types
			),
			'boolval'
		);
		$types = array_values( array_unique( $types ) );

		if ( 1 === count( $types ) ) {
			return $types[0];
		}

		return $types;
	}

	/**
	 * Get relative and exact display times for the last completed scan.
	 *
	 * @param  Model_Scan|null $last  Last completed scan.
	 *
	 * @return array
	 */
	private function get_last_scan_time_data( $last ): array {
		if ( ! is_object( $last ) || ! isset( $last->date_start ) || '' === $last->date_start ) {
			return array(
				'last_scan'      => '',
				'last_scan_time' => '',
			);
		}

		$last_scan_timestamp = strtotime( $last->date_start . ' UTC' );
		$time_difference     = time() - $last_scan_timestamp;
		$data                = array(
			'last_scan' => sprintf(
				/* translators: %s: human-readable time difference, e.g. "5 minutes" */
				__( '%s ago', 'defender-security' ),
				human_time_diff( $last_scan_timestamp )
			),
		);

		if ( $time_difference < DAY_IN_SECONDS ) {
			$data['last_scan_time'] = wp_date( 'g:i A', $last_scan_timestamp );
		} elseif ( $time_difference < YEAR_IN_SECONDS ) {
			$data['last_scan_time'] = wp_date( 'D, j M g:i A', $last_scan_timestamp );
		} else {
			$data['last_scan_time'] = wp_date( 'j M Y, g:i A', $last_scan_timestamp );
		}

		return $data;
	}

	/**
	 * Returns scan result data for the frontend on page load.
	 *
	 * @return array
	 */
	public function get_initial_scan_data(): array {
		$scan     = Model_Scan::get_active();
		$last     = Model_Scan::get_last();
		$per_page = self::DEFAULT_PER_PAGE;
		$paged    = 1;

		if ( ! is_object( $scan ) && ! is_object( $last ) ) {
			$scan_data = null;
		} elseif ( is_object( $scan ) && is_object( $last ) ) {
			// If an active scan exists AND there's a previous completed scan,
			// merge the active scan's progress with the last scan's issue data.
			// This ensures that during a page refresh while scanning, users still
			// see the previous scan results while the new scan is in progress.
			$scan_data = $scan->to_array( $per_page, $paged );
			$last_data = $last->to_array( $per_page, $paged );
			// Preserve previous scan's issue data.
			$scan_data['issues_items']  = $last_data['issues_items'] ?? array();
			$scan_data['ignored_items'] = $last_data['ignored_items'] ?? array();
			$scan_data['count']         = $last_data['count'] ?? array();
			$scan_data['paging']        = $last_data['paging'] ?? array();
		} elseif ( is_object( $scan ) ) {
			$scan_data = $scan->to_array( $per_page, $paged );
		} else {
			$scan_data = $last->to_array( $per_page, $paged );
		}

		$first_scan_started = get_site_option( self::FIRST_SCAN_STARTED, null );
		if ( null === $first_scan_started ) {
			// Existing installations backward compatibility.
			$first_scan_started = is_object( $scan ) || is_object( $last );
		} else {
			$first_scan_started = '1' === (string) $first_scan_started;
		}

		$data = array(
			'scan'                   => $scan_data,
			'has_first_scan_started' => $first_scan_started,
			'isEnabledScanType'      => $this->service->is_any_scan_type_active(),
		);

		// Always expose the last completed scan time at the outer level so the
		// dashboard can show it even while a new scan is actively running.
		$data = array_merge( $data, $this->get_last_scan_time_data( $last ) );

		if ( isset( $this->quarantine_controller ) ) {
			$data['quarantine'] = array(
				'list' => $this->quarantine_controller->data_frontend()['list'] ?? array(),
			);
		}
		// Display Rate notice. Without the rating's type.
		$data['isRatingDisplayed'] = defender_is_wp_org_version()
			&& is_array( $scan_data ) && isset( $data['scan']['issues_items'] )
			&& Rate_Component::is_displayed_in_redesigned_version( count( $data['scan']['issues_items'] ) );

		return array( 'scan' => $data );
	}

	/**
	 * Render main page.
	 *
	 * @return void
	 */
	public function main_view(): void {
		$this->render( 'main' );
	}

	/**
	 * Enqueues scripts and styles for this page.
	 * Only enqueues assets if the page is active.
	 */
	public function enqueue_assets() {
		if ( ! $this->is_page_active() ) {
			return;
		}

		$handle = 'defender-ui-scan';
		wp_enqueue_script(
			$handle,
			WP_DEFENDER_BASE_URL . 'assets/js/scan-ui.js',
			array( 'def-vue', 'def-manifest', 'def-core-ui', 'defender', 'wp-i18n' ),
			DEFENDER_VERSION,
			true
		);
		wp_set_script_translations( $handle, 'wpdef' );

		$scan_routes_data       = $this->dump_routes_and_nonces();
		$quarantine_routes_data = $this->quarantine_controller->dump_routes_and_nonces();
		if ( defender_is_wp_org_version() ) {
			$rate_routes_nonces = wd_di()->get( \WP_Defender\Controller\Rate::class )->dump_routes_and_nonces();
			$rate_routes        = $rate_routes_nonces['routes'];
			$rate_nonces        = $rate_routes_nonces['nonces'];
		} else {
			$rate_routes = array();
			$rate_nonces = array();
		}

		wp_localize_script(
			$handle,
			'defenderUIData',
			array_merge(
				$this->get_shared_data(),
				array(
					'routes' => array_merge(
						$scan_routes_data['routes'],
						$quarantine_routes_data['routes'],
						$rate_routes
					),
					'nonces' => array_merge(
						$scan_routes_data['nonces'],
						$quarantine_routes_data['nonces'],
						$rate_nonces
					),
				),
				$this->get_initial_scan_data()
			)
		);

		wp_enqueue_style(
			$handle,
			WP_DEFENDER_BASE_URL . 'assets/css/showcase.css',
			array(),
			DEFENDER_VERSION
		);

		$this->enqueue_main_assets();
	}

	/**
	 * Converts the current object state to an array.
	 *
	 * @return array The array representation of the object.
	 */
	public function to_array(): array {
		$scan = Model_Scan::get_active();
		$last = Model_Scan::get_last();
		if ( ! is_object( $scan ) && ! is_object( $last ) ) {
			$scan = null;
		} else {
			$scan = is_object( $scan ) ? $scan->to_array() : $last->to_array();
		}

		return array_merge(
			array(
				'scan'   => $scan,
				'report' => array(
					'enabled'   => true,
					'frequency' => 'weekly',
				),
			),
			$this->dump_routes_and_nonces()
		);
	}

	/**
	 * Removes settings for all submodules.
	 */
	public function remove_settings(): void {
		( new Scan_Settings() )->delete();
	}

	/**
	 * Delete all the data & the cache.
	 */
	public function remove_data(): void {
		delete_site_option( self::FIRST_SCAN_STARTED );
		delete_site_option( Model_Scan::IGNORE_INDEXER );
		delete_site_option( Model_Scan::OPTION_SCAN_TYPE );
		Model_Scan::clear_all_status_messages();
		delete_site_option( Core_Integrity::ISSUE_CHECKSUMS );
		delete_site_transient( Plugin_Integrity::$org_slugs );
		delete_site_transient( Plugin_Integrity::$org_responses );
	}

	/**
	 * Provides data for the frontend.
	 *
	 * @return array An array of data for the frontend.
	 */
	public function data_frontend(): array {
		$scan     = Model_Scan::get_active();
		$last     = Model_Scan::get_last();
		$per_page = 10;
		$paged    = 1;
		if ( ! is_object( $scan ) && ! is_object( $last ) ) {
			$scan = null;
		} else {
			$scan = is_object( $scan ) ? $scan->to_array( $per_page, $paged ) : $last->to_array( $per_page, $paged );
		}
		$settings = new Scan_Settings();
		$report   = wd_di()->get( Malware_Report::class );

		$scan['isEnabledScanType'] = $this->service->is_any_scan_type_active();

		$data               = array(
			'scan'         => $scan,
			'settings'     => $settings->export(),
			'notification' => $report->to_string(),
			'misc'         => array(
				'labels' => $settings->labels(),
			),
		);
		$data['quarantine'] = $this->quarantine_controller->data_frontend();

		return array_merge( $data, $this->dump_routes_and_nonces() );
	}

	/**
	 * Imports data into the model.
	 *
	 * @param array $data  Data to be imported into the model.
	 */
	public function import_data( array $data ) {
		$model = $this->model;
		if ( array() === $data ) {
			$model->scheduled_scanning = false;
			$model->frequency          = 'weekly';
			$model->day_n              = 1;
			$model->day                = 'sunday';
			$model->time               = '4:00';
			$model->save();
		} else {
			$model->import( $data );
			if ( $model->validate() ) {
				$model->save();
			}
		}
	}

	/**
	 * Exports strings.
	 *
	 * @return array An array of strings.
	 */
	public function export_strings(): array {
		$strings = array();
		if ( $this->service->is_any_scan_type_active() ) {
			$strings[] = esc_html__( 'Active', 'defender-security' );
		} else {
			$strings[] = esc_html__( 'Inactive', 'defender-security' );
		}

		$scan_notification = new Malware_Notification();
		if ( 'enabled' === $scan_notification->status ) {
			$strings[] = esc_html__( 'Email notifications active', 'defender-security' );
		}
			$strings[] = sprintf(
			/* translators: %s: Html for Pro-tag. */
				esc_html__( 'Scheduled scan inactive %s', 'defender-security' ),
				'<span class="sui-tag sui-tag-pro">Pro</span>'
			);

		return $strings;
	}

	/**
	 * Generates configuration strings based on the provided configuration.
	 *
	 * @param  array $config  Configuration data.
	 *
	 * @return array Returns an array of configuration strings.
	 */
	public function config_strings( array $config ): array {
		$strings   = array();
		$strings[] = $this->service->check_scan_active_by( $config )
			? esc_html__( 'Active', 'defender-security' )
			: esc_html__( 'Inactive', 'defender-security' );

		if ( 'enabled' === $config['notification'] ) {
			$strings[] = esc_html__( 'Email notifications active', 'defender-security' );
		}
		if ( ! ( property_exists( $this, 'is_pro' ) ? $this->is_pro : wd_di()->get( WPMUDEV::class )->is_pro() ) ) {
			$strings[] = sprintf(
			/* translators: %s: Html for Pro-tag. */
				esc_html__( 'Scheduled scan inactive %s', 'defender-security' ),
				'<span class="sui-tag sui-tag-pro">Pro</span>'
			);
		}

		return $strings;
	}

	/**
	 * Run different scan actions based on the scan location.
	 *
	 * @param string $type Denotes type of the scan from the following 4 possible values: scan, install, hub or report.
	 *
	 * @return void
	 */
	public function run_scan_mechanisms_from( $type ) {
		$this->service->gather_actioned_plugin_details();
		$this->do_async_scan( $type );
	}

	/**
	 * Triggers the asynchronous scan.
	 *
	 * @param string $type Denotes type of the scan from the following 4 possible values: scan, install, hub or report.
	 *
	 * @return void
	 */
	public function do_async_scan( string $type ): void {
		wd_di()->get( Model_Scan::class )->delete_idle();
		// Delete the slug from the previous scan.
		delete_site_option( Core_Integrity::ISSUE_CHECKSUMS );

		if ( function_exists( 'as_enqueue_async_action' ) ) {
			as_enqueue_async_action(
				'defender/async_scan',
				array(
					'type' => $type,
				),
				'defender'
			);
		}
	}

	/**
	 * Clear completed action scheduler logs.
	 *
	 * @return void
	 * @since 2.6.5
	 */
	public function clear_scan_logs(): void {
		$scan_component = wd_di()->get( Scan_Component::class );
		$result         = $scan_component::clear_logs();

		if ( isset( $result['error'] ) ) {
			$this->log( 'WP CRON Error : ' . $result['error'], self::SCAN_LOG );
		}
	}

	/**
	 * When user session is expired and scan is running, then don't login via heartbeat modal.
	 *
	 * @param  array  $response  The no-priv Heartbeat response.
	 * @param  string $screen_id  The screen id.
	 *
	 * @return mixed
	 * @since 3.11.0
	 */
	public function nopriv_heartbeat( $response, $screen_id ) {
		if ( false !== strpos( $screen_id, $this->slug ) ) {
			$scan = Model_Scan::get_active();

			if ( is_object( $scan ) ) {
				$response['wp-auth-check'] = true;
			}
		}

		return $response;
	}

	/**
	 * Triggers and send analytics data on scan completed.
	 *
	 * @param  int $action_id  Action ID.
	 *
	 * @return void
	 */
	public function scan_completed_analytics( $action_id ) {
		if (
			class_exists( ActionScheduler::class )
			&& method_exists( ActionScheduler::class, 'store' )
			&& 'defender' === ActionScheduler::store()->fetch_action( $action_id )->get_group()
		) {
			$scan_analytics = wd_di()->get( Scan_Analytics::class );

			$scan_model     = wd_di()->get( Model_Scan::class );
			$analytics_data = $scan_analytics->scan_completed( $scan_model );
			if ( array() === $analytics_data ) {
				return;
			}

			$this->track_feature(
				$analytics_data['event'],
				$analytics_data['data']
			);
		}
	}
}