Your IP : 216.73.216.215


Current Path : /home/flapst5/ekmtoronto.com/wp-content/plugins/wp-defender/src/controller/
Upload File :
Current File : /home/flapst5/ekmtoronto.com/wp-content/plugins/wp-defender/src/controller/class-main-setting.php

<?php
/**
 * Handles the main settings.
 *
 * @package WP_Defender\Controller
 */

namespace WP_Defender\Controller;

use Countable;
use WP_Defender\Event;
use Calotes\Component\Request;
use Calotes\Component\Response;
use WP_Defender\Behavior\WPMUDEV;
use WP_Defender\Controller\Notification;
use WP_Defender\Component\Backup_Settings;
use WP_Defender\Component\Config\Config_Adapter;
use WP_Defender\Component\Config\Local_Config_Store;
use WP_Defender\Component\Network_Cron_Manager;
use WP_Defender\Traits\Defender_Dashboard_Client;
use WP_Defender\Component\Config\Config_Hub_Helper;
use WP_Defender\Model\Setting\Main_Setting as Model_Main_Setting;
use WP_Filesystem_Base;
use WP_Defender\Controller\Security_Headers;
use WP_Defender\Controller\Firewall;

/**
 * Methods for handling main settings.
 */
class Main_Setting extends Event {
	use Defender_Dashboard_Client;

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

	/**
	 * The model for handling the data.
	 *
	 * @var Model_Main_Setting
	 */
	public $model;

	/**
	 * Service for handling logic.
	 *
	 * @var Backup_Settings
	 */
	protected $service;

	/**
	 * Service for handling WPMUDEV.
	 *
	 * @var WPMUDEV
	 */
	protected $wpmudev;

	/**
	 * Local configs store.
	 *
	 * @var Local_Config_Store
	 */
	protected $local_store;

	/**
	 * The intention/nonce action of the current request.
	 *
	 * @since 4.0.0
	 * @var string
	 */
	protected $intention = '';

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

		// Internal cache.
		$this->model       = new Model_Main_Setting();
		$this->service     = wd_di()->get( Backup_Settings::class );
		$this->wpmudev     = wd_di()->get( WPMUDEV::class );
		$this->local_store = new Local_Config_Store( $this->service );
		add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
		$this->register_routes();

		/**
		 * Add cron schedule to clean out outdated logs.
		 *
		 * @var Network_Cron_Manager $network_cron_manager
		 */
		$network_cron_manager = wd_di()->get( Network_Cron_Manager::class );
		$network_cron_manager->register_callback(
			'wp_defender_clear_logs',
			array( $this, 'clear_logs' ),
			DAY_IN_SECONDS,
			time() + HOUR_IN_SECONDS
		);
		add_action( 'wd_settings_update', array( $this, 'intercept_settings_update' ), 10, 2 );
		// Initialize Security Headers so its routes are registered on every request,
		// including AJAX calls from the Security Policies tab.
		wd_di()->get( Security_Headers::class );
		// Initialize Firewall so its routes (sync_ip_header, save_settings, empty_logs, etc.)
		// are registered on AJAX requests originating from the Tools tab.
		// Guarded to AJAX only to prevent a duplicate admin menu entry on normal page loads.
		if ( wp_doing_ajax() ) {
			wd_di()->get( Firewall::class );
			wd_di()->get( Audit_Logging::class );
		}
	}

	/**
	 * Check actual config data.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function check_configs(): Response {
		Config_Hub_Helper::clear_config_transient();

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

	/**
	 * Safe way to get cached model.
	 *
	 * @return Model_Main_Setting
	 */
	private function get_model() {
		if ( is_object( $this->model ) ) {
			return $this->model;
		}

		return wd_di()->get( Model_Main_Setting::class );
	}

	/**
	 * 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-settings';
		wp_enqueue_script(
			$handle,
			WP_DEFENDER_BASE_URL . 'assets/js/settings-ui.js',
			array( 'def-vue', 'def-manifest', 'def-core-ui', 'defender', 'wp-i18n' ),
			DEFENDER_VERSION,
			true
		);
		wp_set_script_translations( $handle, 'wpdef' );

		$data   = $this->data_frontend();
		$routes = $data['routes'] ?? array();
		$nonces = $data['nonces'] ?? array();
		unset( $data['routes'], $data['nonces'] );

		// Include Security Headers data for the Security Policies tab.
		$sh_controller = wd_di()->get( Security_Headers::class );
		$sh_data       = $sh_controller->data_frontend();
		$sh_routes     = $sh_data['routes'] ?? array();
		$sh_nonces     = $sh_data['nonces'] ?? array();
		unset( $sh_data['routes'], $sh_data['nonces'] );

		// Namespace security headers routes/nonces to avoid key collisions.
		foreach ( $sh_routes as $key => $route ) {
			$routes[ 'security_headers_' . $key ] = $route;
		}
		foreach ( $sh_nonces as $key => $nonce ) {
			$nonces[ 'security_headers_' . $key ] = $nonce;
		}

		// Include Module data for the Tools tab.
		$scan_controller              = wd_di()->get( Scan::class );
		$firewall_controller          = wd_di()->get( Firewall::class );
		$audit_logging_controller     = wd_di()->get( Audit_Logging::class );
		$blocklist_monitor_controller = wd_di()->get( Blocklist_Monitor::class );
		$notification_controller      = wd_di()->get( Notification::class );
		$recipients_controller        = wd_di()->get( Recipients::class );
		wp_localize_script(
			$handle,
			'defenderUIData',
			array_merge(
				$this->get_shared_data(),
				// Specific data.
				array(
					'settings'          => array_merge( $data, array( 'security_headers' => $sh_data ) ),
					'scan'              => $scan_controller->data_frontend(),
					'firewall'          => $firewall_controller->data_frontend(),
					'audit_logging'     => $audit_logging_controller->settings_data_frontend(),
					'blocklist_monitor' => $blocklist_monitor_controller->data_frontend(),
					'notification'      => $notification_controller->data_frontend(),
					'recipients'        => $recipients_controller->data_frontend(),
					'routes'            => $routes,
					'nonces'            => $nonces,
				)
			)
		);

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

		$this->enqueue_main_assets();
	}

	/**
	 * Render the root element for frontend.
	 *
	 * @return void
	 */
	public function main_view(): void {
		$this->render( 'main' );
	}

	/**
	 * Save settings.
	 *
	 * @param  Request $request  The request object containing new settings data.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function save_settings( Request $request ) {
		$model = $this->get_model();
		$data  = $request->get_data();

		$model->import( $data );
		if ( $model->validate() ) {
			$this->set_intention( 'Settings' );
			$model->save();
			Config_Hub_Helper::set_clear_active_flag();

			return new Response(
				true,
				array(
					'message'    => esc_html__( 'Your settings have been updated.', 'wpdef' ),
					'auto_close' => true,
				)
			);
		}

		return new Response(
			false,
			array(
				'message' => $model->get_formatted_errors(),
			)
		);
	}

	/**
	 * Reset settings.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function reset_settings(): Response {
		$preserve_settings = 'preserve' === $this->get_model()->uninstall_settings;

		wd_di()->get( Login_Access::class )->remove_settings();
		wd_di()->get( Audit_Logging::class )->remove_settings();
		wd_di()->get( Blocklist_Monitor::class )->remove_settings();
		wd_di()->get( Dashboard::class )->remove_settings();
		wd_di()->get( Security_Tweaks::class )->remove_settings();
		wd_di()->get( Scan::class )->remove_settings();
		// Parent and submodules.
		wd_di()->get( Firewall::class )->remove_settings();

		wd_di()->get( Mask_Login::class )->remove_settings();
		wd_di()->get( Notification::class )->remove_settings();
		wd_di()->get( Two_Factor::class )->remove_settings();
		wd_di()->get( Blocklist_Monitor::class )->remove_settings();
		wd_di()->get( Data_Tracking::class )->remove_settings();
		wd_di()->get( \WP_Defender\Controller\Setup_Wizard::class )->remove_settings();
		wd_di()->get( \WP_Defender\Controller\Activity_Log::class )->remove_settings();

		$this->set_intention( 'Data Reset' );
		// Track first until settings are removed.
		$this->track_opt( false );
		$this->remove_settings();
		if ( ! $preserve_settings ) {
			update_site_option( Scan::FIRST_SCAN_STARTED, '0' );
		}
		// Indicate that it is not a new installation.
		defender_no_fresh_install();

		return new Response(
			true,
			array(
				'message'  => esc_html__( 'Your settings have been reset.', 'wpdef' ),
				'redirect' => network_admin_url( 'admin.php?page=wp-defender' ),
				'interval' => 1,
			)
		);
	}

	/**
	 * Tracks the settings toggle.
	 *
	 * @param  bool $active  The status of the toggle.
	 *
	 * @return void
	 */
	public function track_opt( $active ) {
		$model = $this->get_model();
		// Track only if the Data tracking option was enabled before changes.
		if ( $model->usage_tracking ) {
			$from = $this->get_triggered_location();
			$this->track_opt_toggle( $active, $from );
		}
	}

	/**
	 * Removes settings for all submodules.
	 */
	public function remove_settings(): void {
		wd_di()->get( Model_Main_Setting::class )->delete();
	}

	/**
	 * Delete all the data & the cache.
	 */
	public function remove_data() {
	}

	/**
	 * Provides data for the frontend.
	 *
	 * @return array An array of data for the frontend.
	 */
	public function data_frontend(): array {
		$model = $this->get_model();

		$this->service->maybe_create_default_config();
		$configs = $this->local_store->get_frontend_configs();

		$link           = $this->wpmudev->is_member()
			? 'https://wpmudev.com/translate/projects/wpdef/'
			: 'https://translate.wordpress.org/projects/wp-plugins/defender-security/';
		$can_whitelabel = wd_di()->get( \WP_Defender\Integrations\Dashboard_Whitelabel::class )->can_whitelabel();

		return array_merge(
			array(
				'general'       => array(
					'translate'           => $model->translate,
					'show_usage_tracking' => $this->wpmudev->is_wpmu_dev_admin() || ! $can_whitelabel,
					'usage_tracking'      => $model->usage_tracking,
					'translation_link'    => $link,
				),
				'data_settings' => array(
					'uninstall_settings'   => $model->uninstall_settings,
					'uninstall_data'       => $model->uninstall_data,
					'uninstall_quarantine' => $model->uninstall_quarantine,
				),
				'accessibility' => array(
					'high_contrast_mode' => $model->high_contrast_mode,
				),
				'misc'          => array(
					'setting_url'  => network_admin_url( is_multisite() ? 'settings.php' : 'options-general.php' ),
					'privacy_link' => Model_Main_Setting::PRIVACY_LINK,
				),
				'configs'       => $configs,
				'hub_connector' => wd_di()->get( Hub_Connector::class )->data_frontend(),
				'antibot'       => wd_di()->get( Antibot_Global_Firewall::class )->data_frontend(),
			),
			$this->dump_routes_and_nonces()
		);
	}

	/**
	 * Converts the current object state to an array.
	 *
	 * @return array The array representation of the object.
	 */
	public function to_array(): array {
		return array();
	}

	/**
	 * Imports data into the model.
	 *
	 * @param  array $data  Data to be imported into the model.
	 */
	public function import_data( array $data ) {
		$model = $this->get_model();

		// Preserve Usage Tracking; it is a user privacy choice that a config must not change.
		$current_usage_tracking = $model->usage_tracking;

		$model->import( $data );
		$model->usage_tracking = $current_usage_tracking;

		if ( $model->validate() ) {
			$model->save();
		}
	}

	/**
	 * Validates the importer data.
	 * This function checks if the importer data is valid by verifying its configuration data and comparing it with the
	 * sample data.
	 *
	 * @param  array $importer  The importer data to be validated.
	 *
	 * @return bool Returns true if the importer data is valid, false otherwise.
	 */
	private function validate_importer( $importer ): bool {
		return $this->service->verify_config_data( $importer );
	}

	/**
	 * Import config.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function import_config(): Response {
		global $wp_filesystem;
		// Initialize the WP filesystem, no more using 'file-put-contents' function.
		if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
			require_once ABSPATH . '/wp-admin/includes/file.php';
			WP_Filesystem();
		}
		$file     = defender_get_data_from_request( 'file', 'f' );
		$tmp      = $file['tmp_name'];
		$content  = $wp_filesystem->get_contents( $tmp );
		$importer = json_decode( $content, true );
		if ( ! is_array( $importer ) ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'The file is corrupted.', 'wpdef' ),
				)
			);
		}

		if ( ! $this->validate_importer( $importer ) ) {
			return new Response(
				false,
				array(
					'message' => esc_html__(
						'An error occurred while importing the file. Please check your file or upload another file.',
						'wpdef'
					),
				)
			);
		}

		// Do not use wp_strip_all_tags() to prevent XSS attack.
		$name = sanitize_text_field( $importer['name'] );
		$this->local_store->add_imported( $importer );

		// Detect Pro-only modules that were skipped during validation
		// to inform Free users that some settings were not imported.
		$response_data = array(
			'message' => sprintf(
				/* translators: %s: Config name. */
				esc_html__(
					'%s config has been uploaded successfully – you can now apply it to this site.',
					'wpdef'
				),
				'<strong>' . $name . '</strong>'
			),
			'configs' => $this->local_store->get_frontend_configs(),
		);

		if ( ! $this->wpmudev->is_pro() ) {
			$sample      = $this->service->gather_data();
			$pro_modules = array();

			foreach ( $importer['configs'] as $slug => $module ) {
				// Modules present in the imported config but NOT in Free's sample data are Pro-only.
				if ( ! isset( $sample[ $slug ] ) ) {
					$pro_modules[] = $slug;
				}
			}

			if ( array() !== $pro_modules ) {
				$module_names = array_map(
					function ( $slug ) {
						$names = array(
							'audit'                 => esc_html__( 'Audit Logs', 'wpdef' ),
							'waf'                   => esc_html__( 'Web Application Firewall (WAF)', 'wpdef' ),
							'blocklist_monitor'     => esc_html__( 'Blocklist Monitor', 'wpdef' ),
							'pwned_passwords'       => esc_html__( 'Pwned Passwords', 'wpdef' ),
							'force_strong_password' => esc_html__( 'Strong Passwords', 'wpdef' ),
							'session_protection'    => esc_html__( 'Session Protection', 'wpdef' ),
							'two_factor'            => esc_html__( 'Two-Factor Authentication', 'wpdef' ),
							'mask_login'            => esc_html__( 'Hide Login URL', 'wpdef' ),
							'security_headers'      => esc_html__( 'Security Policies', 'wpdef' ),
						);

						return $names[ $slug ] ?? $slug;
					},
					$pro_modules
				);

				$response_data['message'] .= ' ' . sprintf(
					/* translators: %s: Comma-separated list of Pro-only feature names. */
					esc_html__(
						'This config included features not available in the Free version: %s. Only the available settings were imported.',
						'wpdef'
					),
					implode( ', ', $module_names )
				);
			}
		}

		return new Response(
			true,
			$response_data
		);
	}

	/**
	 * Create config.
	 *
	 * @param  Request $request  The request object containing new config data.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function new_config( Request $request ): Response {
		$data = $request->get_data();
		$name = trim( $data['name'] );
		if ( '' === $name ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config name', 'wpdef' ),
				)
			);
		}
		$name = sanitize_text_field( $name );
		$desc = '';
		if ( isset( $data['desc'] ) && is_string( $data['desc'] ) && '' !== trim( $data['desc'] ) ) {
			$desc = wp_kses_post( $data['desc'] );
		} elseif ( isset( $data['description'] ) && is_string( $data['description'] ) && '' !== trim( $data['description'] ) ) {
			$desc = wp_kses_post( $data['description'] );
		}
		$note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
			? sanitize_text_field( (string) $data['note_added_time'] )
			: '';
		$this->local_store->create_from_current( $name, $desc, $note_added_time );

		return new Response(
			true,
			array(
				'message' => sprintf(
					/* translators: %s: Config name. */
					esc_html__( '%s config saved successfully.', 'wpdef' ),
					'<strong>' . $name . '</strong>'
				),
				'configs' => $this->local_store->get_frontend_configs(),
			)
		);
	}

	/**
	 * Download config
	 *
	 * @return Response|void
	 * @defender_route
	 */
	public function download_config() {
		$key = defender_get_data_from_request( 'key', 'g' );
		if ( ! is_string( $key ) || '' === trim( $key ) ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config', 'wpdef' ),
				)
			);
		}

		$config = $this->local_store->get_full_for_key( $key );
		if ( null === $config ) {
			$config = $this->prepare_config_for_download( $key );
		}
		if ( null === $config ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config', 'wpdef' ),
				)
			);
		}

		$filename = 'wp-defender-config-' . sanitize_file_name( $config['name'] ) . '.json';
		header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
		echo wp_json_encode( $config, JSON_PRETTY_PRINT );
		exit();
	}

	/**
	 * Download multiple configs as a zip file.
	 *
	 * @return Response|void
	 * @defender_route
	 */
	public function download_configs_zip() {
		$keys_raw = defender_get_data_from_request( 'keys', 'g' );
		if ( ! is_string( $keys_raw ) || '' === trim( $keys_raw ) ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config list', 'wpdef' ),
				)
			);
		}

		$decoded = json_decode( wp_unslash( $keys_raw ), true );
		$keys    = is_array( $decoded ) ? $decoded : explode( ',', $keys_raw );
		$keys    = array_values(
			array_filter(
				array_map(
					static function ( $key ) {
						return is_string( $key ) ? trim( $key ) : '';
					},
					$keys
				),
				'boolval'
			)
		);

		if ( array() === $keys ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config list', 'wpdef' ),
				)
			);
		}

		if ( ! class_exists( 'ZipArchive' ) ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Zip archive is not supported on this server.', 'wpdef' ),
				)
			);
		}

		$tmp_zip_path = wp_tempnam( 'wp-defender-configs-' . time() . '.zip' );
		if ( false === $tmp_zip_path ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Could not prepare zip file.', 'wpdef' ),
				)
			);
		}

		$cleanup_tmp_zip = static function () use ( $tmp_zip_path ) {
			if ( file_exists( $tmp_zip_path ) ) {
				wp_delete_file( $tmp_zip_path );
			}
		};

		$zip = new \ZipArchive();
		if ( true !== $zip->open( $tmp_zip_path, \ZipArchive::OVERWRITE ) ) {
			$cleanup_tmp_zip();

			return new Response(
				false,
				array(
					'message' => esc_html__( 'Could not create zip file.', 'wpdef' ),
				)
			);
		}

		$has_entries = false;

		foreach ( $keys as $key ) {
			$config = $this->local_store->get_full_for_key( $key );
			if ( null === $config ) {
				$config = $this->prepare_config_for_download( $key );
			}
			if ( null === $config ) {
				continue;
			}

			$file_name = $this->make_unique_zip_entry_name(
				'wp-defender-config-' . sanitize_file_name( $config['name'] ),
				$key,
				$zip
			);
			if ( false === $zip->addFromString( $file_name, wp_json_encode( $config, JSON_PRETTY_PRINT ) ) ) {
				$zip->close();
				$cleanup_tmp_zip();

				return new Response(
					false,
					array(
						'message' => esc_html__( 'Could not prepare zip file.', 'wpdef' ),
					)
				);
			}

			$has_entries = true;
		}

		$zip->close();

		if ( ! $has_entries ) {
			$cleanup_tmp_zip();

			return new Response(
				false,
				array(
					'message' => esc_html__( 'No valid configs found to export.', 'wpdef' ),
				)
			);
		}

		$download_name = 'wp-defender-configs-' . gmdate( 'Ymd-His' ) . '.zip';
		header( 'Content-Type: application/zip' );
		header( 'Content-Disposition: attachment; filename="' . $download_name . '"' );
		header( 'Content-Length: ' . filesize( $tmp_zip_path ) );
		readfile( $tmp_zip_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
		$cleanup_tmp_zip();
		exit();
	}

	/**
	 * Apply config.
	 *
	 * @param  Request $request  The request object containing new config data.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function apply_config( Request $request ) {
		$data = $request->get_data();
		$key  = trim( $data['key'] );
		if ( '' === $key ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config', 'wpdef' ),
				)
			);
		}

		$config = $this->local_store->get_full_for_key( $key );
		if ( null === $config ) {
			$config = get_site_option( $key );
		}
		if ( false === $config || null === $config ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config', 'wpdef' ),
				)
			);
		}
		// Return error message or bool value for auth action.
		$restore_result = $this->service->restore_data( $config['configs'], 'plugin' );
		if ( is_string( $restore_result ) ) {
			return $this->apply_config_recommendations_error_message();
		}

		$this->local_store->apply( $key );
		$this->service->make_config_active( $key );
		// Track.
		$this->track_feature(
			'def_config_applied',
			array(
				// The check is based on the fact that the Default config cannot be deleted.
				'Config Type' => isset( $config['is_removable'] ) && false === $config['is_removable'] ? 'Default' : 'Custom',
			)
		);

		$message = sprintf(
			/* translators: %s: Config name. */
			esc_html__(
				'%s config has been applied successfully.',
				'wpdef'
			),
			'<strong>' . $config['name'] . '</strong>'
		);
		$return = array();
		if ( $restore_result ) {
			$login_url           = wp_login_url();
			$settings_mask_login = new \WP_Defender\Model\Setting\Mask_Login();
			if ( $settings_mask_login->is_active() ) {
				$login_url = $settings_mask_login->get_new_login_url();
			}
			$message .= '<br/>' . sprintf(
				/* translators: %s: Login link. */
				esc_html__(
					'Due to currently applied security recommendations, you will now need to %s.',
					'wpdef'
				),
				'<a href="' . $login_url . '"><strong>' . esc_html__( 're-login', 'wpdef' ) . '</strong></a>'
			);
			$message .= '<br/>';
			$message .= esc_html__( 'This will auto reload now.', 'wpdef' );

			$return['reload'] = 3;
			$redirect         = rawurlencode( network_admin_url( 'admin.php?page=wdf-setting&view=configs' ) );
			if ( isset( $data['screen'] ) && 'dashboard' === $data['screen'] ) {
				$redirect = rawurlencode( network_admin_url( 'admin.php?page=wp-defender' ) );
			}
			$return['redirect'] = add_query_arg(
				'redirect_to',
				$redirect,
				$login_url
			);
			$return['interval'] = 2;
		}

		$return['message']    = $message;
		$return['auto_close'] = true;
		$return['configs']    = $this->local_store->get_frontend_configs();

		return new Response( true, $return );
	}

	/**
	 * Update config.
	 *
	 * @param  Request $request  The request object containing new config data.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function update_config( Request $request ) {
		$data        = $request->get_data();
		$key         = trim( $data['key'] );
		$key         = $this->resolve_config_key( $key );
		$name        = trim( $data['name'] );
		$description = isset( $data['desc'] ) ? trim( (string) $data['desc'] ) : '';
		if ( '' === $description && isset( $data['description'] ) ) {
			$description = trim( (string) $data['description'] );
		}
		$note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
			? sanitize_text_field( (string) $data['note_added_time'] )
			: null;
		if ( '' === $name || '' === $key ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config', 'wpdef' ),
				)
			);
		}

		$config = $this->local_store->get_one( $key );
		if ( null === $config ) {
			$config = get_site_option( $key );
		}
		if ( false === $config || null === $config ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config', 'wpdef' ),
				)
			);
		}

		$option_updated = $this->local_store->update_metadata( $key, $name, $description, $note_added_time );
		if ( ! $option_updated ) {
			$old_config            = $config;
			$config['name']        = sanitize_text_field( $name );
			$config['description'] = sanitize_textarea_field( $description );
			if ( null !== $note_added_time ) {
				$config['note_added_time'] = $note_added_time;
			}
			$option_updated = update_site_option( $key, $config );
		}

		if ( $option_updated ) {
			return new Response(
				true,
				array(
					'message'    => sprintf(
						/* translators: %s: Config name. */
						esc_html__( '%s config saved successfully.', 'wpdef' ),
						'<strong>' . $name . '</strong>'
					),
					'auto_close' => true,
					'configs'    => $this->local_store->get_frontend_configs(),
				)
			);
		} else {
			return new Response(
				false,
				array(
					'message' => esc_html__(
						'An error occurred while saving your config. Please try it again.',
						'wpdef'
					),
				)
			);
		}
	}

	/**
	 * Sync config metadata list.
	 *
	 * This method updates name/description/note timestamp for provided configs
	 * without changing config payload values.
	 *
	 * @param  Request $request  The request object containing configs metadata list.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function sync_configs_metadata( Request $request ): Response {
		// Defense in depth: central routing already validates nonce and access.
		if ( ! $this->check_permission() ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'You shall not pass.', 'wpdef' ),
				)
			);
		}

		$data = $request->get_data();
		if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config list', 'wpdef' ),
				)
			);
		}

		$stored_configs = $this->local_store->get_all();
		foreach ( $data['configs'] as $item ) {
			if ( ! is_array( $item ) ) {
				return new Response(
					false,
					array(
						'message' => esc_html__( 'Invalid config list', 'wpdef' ),
					)
				);
			}

			$key = '';
			if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
				$key = trim( $item['key'] );
			} elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
				$key = trim( $item['id'] );
			}
			$key = $this->resolve_config_key( $key, $stored_configs );

			if (
				'' === $key ||
				0 !== strpos( $key, 'wp_defender_config' ) ||
				! isset( $stored_configs[ $key ] )
			) {
				return new Response(
					false,
					array(
						'message' => esc_html__( 'Invalid config list', 'wpdef' ),
					)
				);
			}

			$config = $this->local_store->get_one( $key );
			if ( null === $config ) {
				continue;
			}

			$name            = isset( $item['name'] ) ? (string) $item['name'] : null;
			$description     = null;
			$note_added_time = null;
			if ( isset( $item['description'] ) && is_string( $item['description'] ) ) {
				$description = $item['description'];
			} elseif ( isset( $item['desc'] ) && is_string( $item['desc'] ) ) {
				$description = $item['desc'];
			}
			if ( isset( $item['note_added_time'] ) && is_scalar( $item['note_added_time'] ) ) {
				$note_added_time = (string) $item['note_added_time'];
			}
			$this->local_store->update_metadata( $key, $name, $description, $note_added_time );
		}

		return new Response(
			true,
			array(
				'message' => esc_html__( 'Configs synced successfully.', 'wpdef' ),
				'configs' => $this->local_store->get_frontend_configs(),
			)
		);
	}

	/**
	 * Delete config.
	 *
	 * @param  Request $request  The request object containing config key.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function delete_config( Request $request ) {
		$data = $request->get_data();
		$key  = trim( $data['key'] );
		$key  = $this->resolve_config_key( $key, $this->local_store->get_all() );
		if ( '' === $key ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config', 'wpdef' ),
				)
			);
		}

		$config = $this->local_store->get_one( $key );
		if ( null === $config ) {
			$config = get_site_option( $key );
		}
		if ( isset( $config['is_removable'] ) && false === (bool) $config['is_removable'] ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Config can\'t be removed', 'wpdef' ),
				)
			);
		}

		if ( $this->local_store->delete_one( $key ) ) {
			return new Response(
				true,
				array(
					'message'    => esc_html__( 'Config removed successfully.', 'wpdef' ),
					'auto_close' => true,
					'configs'    => $this->local_store->get_frontend_configs(),
				)
			);
		}

		if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
			delete_site_option( $key );
			$this->service->remove_index( $key );
			$this->service->clear_keys();
			delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );

			return new Response(
				true,
				array(
					'message'    => esc_html__( 'Config removed successfully.', 'wpdef' ),
					'auto_close' => true,
					'configs'    => $this->local_store->get_frontend_configs(),
				)
			);
		}

		return new Response(
			false,
			array(
				'message' => esc_html__( 'Invalid config', 'wpdef' ),
			)
		);
	}

	/**
	 * Syncs configs list by keeping only the provided config keys.
	 *
	 * This method is intentionally separate from `delete_config` for backward compatibility.
	 *
	 * @param  Request $request  The request object containing the list of configs to keep.
	 *
	 * @return Response
	 * @defender_route
	 */
	public function sync_configs_list( Request $request ): Response {
		// Defense in depth: central routing already enforces nonce and private access checks.
		if ( ! $this->check_permission() ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'You shall not pass.', 'wpdef' ),
				)
			);
		}

		$data = $request->get_data();
		if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
			return new Response(
				false,
				array(
					'message' => esc_html__( 'Invalid config list', 'wpdef' ),
				)
			);
		}

		$stored_configs = $this->local_store->get_all();
		$keep_keys      = array();
		foreach ( $data['configs'] as $item ) {
			if ( ! is_array( $item ) ) {
				return new Response(
					false,
					array(
						'message' => esc_html__( 'Invalid config list', 'wpdef' ),
					)
				);
			}
			$key = '';
			if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
				$key = trim( $item['key'] );
			} elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
				$key = trim( $item['id'] );
			}

			if (
				'' === $key ||
				0 !== strpos( $key, 'wp_defender_config' ) ||
				! isset( $stored_configs[ $key ] )
			) {
				return new Response(
					false,
					array(
						'message' => esc_html__( 'Invalid config list', 'wpdef' ),
					)
				);
			}

			$keep_keys[ $key ] = true;
		}

		$this->local_store->sync_keep_list( array_keys( $keep_keys ) );

		return new Response(
			true,
			array(
				'message'    => esc_html__( 'Config list synced successfully.', 'wpdef' ),
				'auto_close' => true,
				'configs'    => $this->local_store->get_frontend_configs(),
			)
		);
	}

	/**
	 * Exports strings.
	 *
	 * @return array An array of strings.
	 */
	public function export_strings(): array {
		return array();
	}

	/**
	 * Resolves a config key from an incoming identifier.
	 *
	 * Supports direct option keys and numeric Hub IDs.
	 *
	 * @param  string     $incoming_key    Incoming key/id.
	 * @param  array|null $stored_configs  Optional preloaded configs map.
	 *
	 * @return string
	 */
	private function resolve_config_key( string $incoming_key, ?array $stored_configs = null ): string {
		$key = trim( $incoming_key );
		if ( '' === $key ) {
			return '';
		}

		if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
			return $key;
		}

		$configs = is_array( $stored_configs ) ? $stored_configs : $this->local_store->get_all();
		foreach ( $configs as $config_key => $config ) {
			if ( ! is_array( $config ) || ! isset( $config['hub_id'] ) ) {
				continue;
			}
			if ( (string) $config['hub_id'] === $key ) {
				return (string) $config_key;
			}
		}

		return $key;
	}

	/**
	 * Generates an error message for when there is an issue applying some tweaks from the Hardening tab.
	 *
	 * @return Response The response object containing the error message and fresh frontend configurations.
	 */
	private function apply_config_recommendations_error_message(): Response {
		$message = sprintf(
			/* translators: 1: Hardening tab, 2: wp-config.php file, 3: Documentation. */
			esc_html__(
				'There was an issue with applying some of the tweaks from the %1$s tab because we cannot make changes to your %2$s file. Please see our %3$s to apply the changes manually.',
				'wpdef'
			),
			'<strong>' . esc_html__( 'Hardening', 'wpdef' ) . '</strong>',
			'<strong>' . esc_html__( 'wp-config.php', 'wpdef' ) . '</strong>',
			'<a href="' . WP_DEFENDER_DOCS_LINK . '#manually-applying-recommendations" target="_blank">' . esc_html__( 'documentation', 'wpdef' ) . '</a>'
		);

		return new Response(
			false,
			array(
				'message' => $message,
				'configs' => $this->local_store->get_frontend_configs(),
			)
		);
	}

	/**
	 * Prepares a config payload for download.
	 *
	 * @param  string $key  Config option key.
	 *
	 * @return array|null
	 */
	private function prepare_config_for_download( string $key ): ?array {
		$config = get_site_option( $key );
		if ( false === $config || ! is_array( $config ) ) {
			return null;
		}

		$sample = $this->service->gather_data();
		foreach ( $sample as $slug => $data ) {
			foreach ( $data as $k => $val ) {
				if ( ! isset( $config['configs'][ $slug ][ $k ] ) ) {
					$config['configs'][ $slug ][ $k ] = null;
				}
			}
		}

		return $config;
	}

	/**
	 * Creates a unique JSON filename for a zip entry.
	 *
	 * @param  string      $base_name  Base file name without extension.
	 * @param  string      $key  Config option key.
	 * @param  \ZipArchive $zip  Active zip archive.
	 *
	 * @return string
	 */
	private function make_unique_zip_entry_name( string $base_name, string $key, \ZipArchive $zip ): string {
		$base_name = trim( $base_name );
		if ( '' === $base_name ) {
			$base_name = 'wp-defender-config';
		}

		$try_name = $base_name . '.json';
		if ( false === $zip->locateName( $try_name ) ) {
			return $try_name;
		}

		$key_suffix = sanitize_file_name( str_replace( 'wp_defender_config_', '', $key ) );
		if ( '' !== $key_suffix ) {
			$try_name = $base_name . '-' . $key_suffix . '.json';
			if ( false === $zip->locateName( $try_name ) ) {
				return $try_name;
			}
		}

		$index = 2;
		while ( true ) {
			$try_name = $base_name . '-' . $index . '.json';
			if ( false === $zip->locateName( $try_name ) ) {
				return $try_name;
			}
			++$index;
		}
	}

	/**
	 * Clear out lines that are older than 30 days.
	 *
	 * @return void
	 */
	public function clear_logs(): void {
		// since 2.7.0.
		$time_limit = apply_filters( 'wpdef_clear_logs_time_limit', MONTH_IN_SECONDS );

		if ( is_multisite() ) {
			global $wpdb;
			$offset = 0;
			$limit  = 100;
			$blogs  = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
				ARRAY_A
			);
			while ( is_array( $blogs ) && array() !== $blogs ) {
				foreach ( $blogs as $blog ) {
					switch_to_blog( $blog['blog_id'] );

					$this->clear_logs_from_files( $time_limit );

					restore_current_blog();
				}
				$offset += $limit;
				$blogs   = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
					$wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
					ARRAY_A
				);
			}
		} else {
			$this->clear_logs_from_files( $time_limit );
		}
	}

	/**
	 * Clear log files older than the specified time.
	 *
	 * @param  int $time_limit  The time limit in seconds.
	 *
	 * @return void
	 * @since 2.7.0
	 */
	public function clear_logs_from_files( int $time_limit = MONTH_IN_SECONDS ) {
		global $wp_filesystem;
		// Initialize the WP filesystem, no more using 'file-put-contents' function.
		if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
			require_once ABSPATH . '/wp-admin/includes/file.php';
			WP_Filesystem();
		}
		$now   = wp_date( 'c' );
		$files = array( wd_internal_log() );

		foreach ( $files as $file_name ) {
			$file_path = $this->get_log_path( $file_name );

			if ( ! file_exists( $file_path ) ) {
				return;
			}

			$content         = file( $file_path );
			$size_of_content = is_array( $content ) || $content instanceof Countable ? count( $content ) : 0;

			foreach ( $content as $index => $line ) {
				// If the line does not start with '[' (it's probably not a new entry).
				$first_char = substr( $line, 0, 1 );

				if ( '[' !== $first_char ) {
					// Delete.
					unset( $content[ $index ] );
				}

				/**
				 * Get the date from entry. Items can be an array it two cases - if there's a valid date, or if the line
				 * contained something like [header] in the start. Cannot make assumptions just on the fact it's an array.
				 */
				preg_match( '/\[(.*)\]/', $line, $items );

				// If, for some reason, can't get the date, or it's not the size of an ISO 8601 date.
				if ( ! isset( $items[1] ) || 25 !== strlen( $items[1] ) ) {
					// Delete.
					unset( $content[ $index ] );
				} else {
					// It looks like it's a valid date string, compare with today.
					$time_diff = strtotime( $now ) - strtotime( $items[1] );

					// We don't need to continue on, because if this entry is not older than specific time, the next one will not be as well.
					if ( $time_diff < $time_limit ) {
						break;
					}

					unset( $content[ $index ] );
				}
			}

			// Nothing changed - do nothing.
			if ( ( is_array( $content ) || $content instanceof Countable ? count( $content ) : 0 ) === $size_of_content ) {
				return;
			}

			// Glue back together and write back to file.
			$content = implode( '', $content );

			$wp_filesystem->put_contents( $file_path, $content );
		}
	}

	/**
	 * Track the data if there are settings changes.
	 *
	 * @param  array $old_settings  Old settings.
	 * @param  array $new_settings  New settings.
	 *
	 * @return void
	 * @since 4.2.0
	 */
	public function intercept_settings_update( $old_settings, $new_settings ) {
		$from = $this->get_triggered_location();
		if (
			'' !== $from
			&& isset( $new_settings['usage_tracking'], $old_settings['usage_tracking'] )
			&& $new_settings['usage_tracking'] !== $old_settings['usage_tracking']
		) {
			$this->track_opt_toggle( (bool) $new_settings['usage_tracking'], $from );
		}
	}
}