PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.0.1
Defender Security – Malware Scanner, Login Security & Firewall v6.0.1
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / controller / class-main-setting.php

class-main-setting.php in Defender Security – Malware Scanner, Login Security & Firewall 6.0.1, at src/controller/class-main-setting.php

1,402 lines 38.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles the main settings.
4 *
5 * @package WP_Defender\Controller
6 */
7
8 namespace WP_Defender\Controller;
9
10 use Countable;
11 use WP_Defender\Event;
12 use Calotes\Component\Request;
13 use Calotes\Component\Response;
14 use WP_Defender\Behavior\WPMUDEV;
15 use WP_Defender\Controller\Notification;
16 use WP_Defender\Component\Backup_Settings;
17 use WP_Defender\Component\Config\Config_Adapter;
18 use WP_Defender\Component\Config\Local_Config_Store;
19 use WP_Defender\Component\Network_Cron_Manager;
20 use WP_Defender\Traits\Defender_Dashboard_Client;
21 use WP_Defender\Component\Config\Config_Hub_Helper;
22 use WP_Defender\Model\Setting\Main_Setting as Model_Main_Setting;
23 use WP_Filesystem_Base;
24 use WP_Defender\Controller\Security_Headers;
25 use WP_Defender\Controller\Firewall;
26
27 /**
28 * Methods for handling main settings.
29 */
30 class Main_Setting extends Event {
31 use Defender_Dashboard_Client;
32
33 /**
34 * The slug identifier for this controller.
35 *
36 * @var string
37 */
38 public $slug = 'wdf-setting';
39
40 /**
41 * The model for handling the data.
42 *
43 * @var Model_Main_Setting
44 */
45 public $model;
46
47 /**
48 * Service for handling logic.
49 *
50 * @var Backup_Settings
51 */
52 protected $service;
53
54 /**
55 * Service for handling WPMUDEV.
56 *
57 * @var WPMUDEV
58 */
59 protected $wpmudev;
60
61 /**
62 * Local configs store.
63 *
64 * @var Local_Config_Store
65 */
66 protected $local_store;
67
68 /**
69 * The intention/nonce action of the current request.
70 *
71 * @since 4.0.0
72 * @var string
73 */
74 protected $intention = '';
75
76 /**
77 * Initializes the model and service, registers routes, and sets up scheduled events if the model is active.
78 */
79 public function __construct() {
80 $this->register_page(
81 esc_html__( 'Settings', 'defender-security' ),
82 $this->slug,
83 array( $this, 'main_view' ),
84 $this->parent_slug
85 );
86
87 // Internal cache.
88 $this->model = new Model_Main_Setting();
89 $this->service = wd_di()->get( Backup_Settings::class );
90 $this->wpmudev = wd_di()->get( WPMUDEV::class );
91 $this->local_store = new Local_Config_Store( $this->service );
92 add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
93 $this->register_routes();
94
95 /**
96 * Add cron schedule to clean out outdated logs.
97 *
98 * @var Network_Cron_Manager $network_cron_manager
99 */
100 $network_cron_manager = wd_di()->get( Network_Cron_Manager::class );
101 $network_cron_manager->register_callback(
102 'wp_defender_clear_logs',
103 array( $this, 'clear_logs' ),
104 DAY_IN_SECONDS,
105 time() + HOUR_IN_SECONDS
106 );
107 add_action( 'wd_settings_update', array( $this, 'intercept_settings_update' ), 10, 2 );
108 // Initialize Security Headers so its routes are registered on every request,
109 // including AJAX calls from the Security Policies tab.
110 wd_di()->get( Security_Headers::class );
111 // Initialize Firewall so its routes (sync_ip_header, save_settings, empty_logs, etc.)
112 // are registered on AJAX requests originating from the Tools tab.
113 // Guarded to AJAX only to prevent a duplicate admin menu entry on normal page loads.
114 if ( wp_doing_ajax() ) {
115 wd_di()->get( Firewall::class );
116 }
117 }
118
119 /**
120 * Check actual config data.
121 *
122 * @return Response
123 * @defender_route
124 */
125 public function check_configs(): Response {
126 Config_Hub_Helper::clear_config_transient();
127
128 return new Response( true, array() );
129 }
130
131 /**
132 * Safe way to get cached model.
133 *
134 * @return Model_Main_Setting
135 */
136 private function get_model() {
137 if ( is_object( $this->model ) ) {
138 return $this->model;
139 }
140
141 return wd_di()->get( Model_Main_Setting::class );
142 }
143
144 /**
145 * Enqueues scripts and styles for this page.
146 * Only enqueues assets if the page is active.
147 */
148 public function enqueue_assets() {
149 if ( ! $this->is_page_active() ) {
150 return;
151 }
152
153 $handle = 'defender-ui-settings';
154 wp_enqueue_script(
155 $handle,
156 WP_DEFENDER_BASE_URL . 'assets/js/settings-ui.js',
157 array( 'def-vue', 'def-manifest', 'def-core-ui', 'defender', 'wp-i18n' ),
158 DEFENDER_VERSION,
159 true
160 );
161 wp_set_script_translations( $handle, 'wpdef' );
162
163 $data = $this->data_frontend();
164 $routes = $data['routes'] ?? array();
165 $nonces = $data['nonces'] ?? array();
166 unset( $data['routes'], $data['nonces'] );
167
168 // Include Security Headers data for the Security Policies tab.
169 $sh_controller = wd_di()->get( Security_Headers::class );
170 $sh_data = $sh_controller->data_frontend();
171 $sh_routes = $sh_data['routes'] ?? array();
172 $sh_nonces = $sh_data['nonces'] ?? array();
173 unset( $sh_data['routes'], $sh_data['nonces'] );
174
175 // Namespace security headers routes/nonces to avoid key collisions.
176 foreach ( $sh_routes as $key => $route ) {
177 $routes[ 'security_headers_' . $key ] = $route;
178 }
179 foreach ( $sh_nonces as $key => $nonce ) {
180 $nonces[ 'security_headers_' . $key ] = $nonce;
181 }
182
183 // Include Module data for the Tools tab.
184 $scan_controller = wd_di()->get( Scan::class );
185 $firewall_controller = wd_di()->get( Firewall::class );
186 $blocklist_monitor_controller = wd_di()->get( Blocklist_Monitor::class );
187 $notification_controller = wd_di()->get( Notification::class );
188 $recipients_controller = wd_di()->get( Recipients::class );
189 wp_localize_script(
190 $handle,
191 'defenderUIData',
192 array_merge(
193 $this->get_shared_data(),
194 // Specific data.
195 array(
196 'settings' => array_merge( $data, array( 'security_headers' => $sh_data ) ),
197 'scan' => $scan_controller->data_frontend(),
198 'firewall' => $firewall_controller->data_frontend(),
199 'blocklist_monitor' => $blocklist_monitor_controller->data_frontend(),
200 'notification' => $notification_controller->data_frontend(),
201 'recipients' => $recipients_controller->data_frontend(),
202 'routes' => $routes,
203 'nonces' => $nonces,
204 )
205 )
206 );
207
208 wp_enqueue_style(
209 $handle,
210 WP_DEFENDER_BASE_URL . 'assets/css/showcase.css',
211 array(),
212 DEFENDER_VERSION
213 );
214
215 $this->enqueue_main_assets();
216 }
217
218 /**
219 * Render the root element for frontend.
220 *
221 * @return void
222 */
223 public function main_view(): void {
224 $this->render( 'main' );
225 }
226
227 /**
228 * Save settings.
229 *
230 * @param Request $request The request object containing new settings data.
231 *
232 * @return Response
233 * @defender_route
234 */
235 public function save_settings( Request $request ) {
236 $model = $this->get_model();
237 $data = $request->get_data();
238
239 $model->import( $data );
240 if ( $model->validate() ) {
241 $this->set_intention( 'Settings' );
242 $model->save();
243 Config_Hub_Helper::set_clear_active_flag();
244
245 return new Response(
246 true,
247 array(
248 'message' => esc_html__( 'Your settings have been updated.', 'defender-security' ),
249 'auto_close' => true,
250 )
251 );
252 }
253
254 return new Response(
255 false,
256 array(
257 'message' => $model->get_formatted_errors(),
258 )
259 );
260 }
261
262 /**
263 * Reset settings.
264 *
265 * @return Response
266 * @defender_route
267 */
268 public function reset_settings(): Response {
269 $preserve_settings = 'preserve' === $this->get_model()->uninstall_settings;
270
271 wd_di()->get( Login_Access::class )->remove_settings();
272 wd_di()->get( Blocklist_Monitor::class )->remove_settings();
273 wd_di()->get( Dashboard::class )->remove_settings();
274 wd_di()->get( Security_Tweaks::class )->remove_settings();
275 wd_di()->get( Scan::class )->remove_settings();
276 // Parent and submodules.
277 wd_di()->get( Firewall::class )->remove_settings();
278
279 wd_di()->get( Mask_Login::class )->remove_settings();
280 wd_di()->get( Notification::class )->remove_settings();
281 wd_di()->get( Two_Factor::class )->remove_settings();
282 wd_di()->get( Blocklist_Monitor::class )->remove_settings();
283 wd_di()->get( Data_Tracking::class )->remove_settings();
284 wd_di()->get( \WP_Defender\Controller\Setup_Wizard::class )->remove_settings();
285 wd_di()->get( \WP_Defender\Controller\Activity_Log::class )->remove_settings();
286
287 $this->set_intention( 'Data Reset' );
288 // Track first until settings are removed.
289 $this->track_opt( false );
290 $this->remove_settings();
291 if ( ! $preserve_settings ) {
292 update_site_option( Scan::FIRST_SCAN_STARTED, '0' );
293 }
294 // Indicate that it is not a new installation.
295 defender_no_fresh_install();
296
297 return new Response(
298 true,
299 array(
300 'message' => esc_html__( 'Your settings have been reset.', 'defender-security' ),
301 'redirect' => network_admin_url( 'admin.php?page=wp-defender' ),
302 'interval' => 1,
303 )
304 );
305 }
306
307 /**
308 * Tracks the settings toggle.
309 *
310 * @param bool $active The status of the toggle.
311 *
312 * @return void
313 */
314 public function track_opt( $active ) {
315 $model = $this->get_model();
316 // Track only if the Data tracking option was enabled before changes.
317 if ( $model->usage_tracking ) {
318 $from = $this->get_triggered_location();
319 $this->track_opt_toggle( $active, $from );
320 }
321 }
322
323 /**
324 * Removes settings for all submodules.
325 */
326 public function remove_settings(): void {
327 wd_di()->get( Model_Main_Setting::class )->delete();
328 }
329
330 /**
331 * Delete all the data & the cache.
332 */
333 public function remove_data() {
334 }
335
336 /**
337 * Provides data for the frontend.
338 *
339 * @return array An array of data for the frontend.
340 */
341 public function data_frontend(): array {
342 $model = $this->get_model();
343
344 $this->service->maybe_create_default_config();
345 $configs = $this->local_store->get_frontend_configs();
346
347 $link = $this->wpmudev->is_member()
348 ? 'https://wpmudev.com/translate/projects/wpdef/'
349 : 'https://translate.wordpress.org/projects/wp-plugins/defender-security/';
350 $can_whitelabel = wd_di()->get( \WP_Defender\Integrations\Dashboard_Whitelabel::class )->can_whitelabel();
351
352 return array_merge(
353 array(
354 'general' => array(
355 'translate' => $model->translate,
356 'show_usage_tracking' => $this->wpmudev->is_wpmu_dev_admin() || ! $can_whitelabel,
357 'usage_tracking' => $model->usage_tracking,
358 'translation_link' => $link,
359 ),
360 'data_settings' => array(
361 'uninstall_settings' => $model->uninstall_settings,
362 'uninstall_data' => $model->uninstall_data,
363 'uninstall_quarantine' => $model->uninstall_quarantine,
364 ),
365 'accessibility' => array(
366 'high_contrast_mode' => $model->high_contrast_mode,
367 ),
368 'misc' => array(
369 'setting_url' => network_admin_url( is_multisite() ? 'settings.php' : 'options-general.php' ),
370 'privacy_link' => Model_Main_Setting::PRIVACY_LINK,
371 ),
372 'configs' => $configs,
373 'hub_connector' => wd_di()->get( Hub_Connector::class )->data_frontend(),
374 'antibot' => wd_di()->get( Antibot_Global_Firewall::class )->data_frontend(),
375 ),
376 $this->dump_routes_and_nonces()
377 );
378 }
379
380 /**
381 * Converts the current object state to an array.
382 *
383 * @return array The array representation of the object.
384 */
385 public function to_array(): array {
386 return array();
387 }
388
389 /**
390 * Imports data into the model.
391 *
392 * @param array $data Data to be imported into the model.
393 */
394 public function import_data( array $data ) {
395 $model = $this->get_model();
396
397 $model->import( $data );
398 if ( $model->validate() ) {
399 $model->save();
400 }
401 }
402
403 /**
404 * Validates the importer data.
405 * This function checks if the importer data is valid by verifying its configuration data and comparing it with the
406 * sample data.
407 *
408 * @param array $importer The importer data to be validated.
409 *
410 * @return bool Returns true if the importer data is valid, false otherwise.
411 */
412 private function validate_importer( $importer ): bool {
413 return $this->service->verify_config_data( $importer );
414 }
415
416 /**
417 * Import config.
418 *
419 * @return Response
420 * @defender_route
421 */
422 public function import_config(): Response {
423 global $wp_filesystem;
424 // Initialize the WP filesystem, no more using 'file-put-contents' function.
425 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
426 require_once ABSPATH . '/wp-admin/includes/file.php';
427 WP_Filesystem();
428 }
429 $file = defender_get_data_from_request( 'file', 'f' );
430 $tmp = $file['tmp_name'];
431 $content = $wp_filesystem->get_contents( $tmp );
432 $importer = json_decode( $content, true );
433 if ( ! is_array( $importer ) ) {
434 return new Response(
435 false,
436 array(
437 'message' => esc_html__( 'The file is corrupted.', 'defender-security' ),
438 )
439 );
440 }
441
442
443 if ( ! $this->validate_importer( $importer ) ) {
444 return new Response(
445 false,
446 array(
447 'message' => esc_html__(
448 'An error occurred while importing the file. Please check your file or upload another file.',
449 'defender-security'
450 ),
451 )
452 );
453 }
454
455 // Do not use wp_strip_all_tags() to prevent XSS attack.
456 $name = sanitize_text_field( $importer['name'] );
457 $this->local_store->add_imported( $importer );
458
459 // Detect Pro-only modules that were skipped during validation
460 // to inform Free users that some settings were not imported.
461 $response_data = array(
462 'message' => sprintf(
463 /* translators: %s: Config name. */
464 esc_html__(
465 '%s config has been uploaded successfully – you can now apply it to this site.',
466 'defender-security'
467 ),
468 '<strong>' . $name . '</strong>'
469 ),
470 'configs' => $this->local_store->get_frontend_configs(),
471 );
472
473 if ( ! $this->wpmudev->is_pro() ) {
474 $sample = $this->service->gather_data();
475 $pro_modules = array();
476
477 foreach ( $importer['configs'] as $slug => $module ) {
478 // Modules present in the imported config but NOT in Free's sample data are Pro-only.
479 if ( ! isset( $sample[ $slug ] ) ) {
480 $pro_modules[] = $slug;
481 }
482 }
483
484 if ( ! empty( $pro_modules ) ) {
485 $module_names = array_map(
486 function ( $slug ) {
487 $names = array(
488 'audit' => esc_html__( 'Audit Logs', 'defender-security' ),
489 'waf' => esc_html__( 'Web Application Firewall (WAF)', 'defender-security' ),
490 'blocklist_monitor' => esc_html__( 'Blocklist Monitor', 'defender-security' ),
491 'pwned_passwords' => esc_html__( 'Pwned Passwords', 'defender-security' ),
492 'force_strong_password' => esc_html__( 'Strong Passwords', 'defender-security' ),
493 'session_protection' => esc_html__( 'Session Protection', 'defender-security' ),
494 'two_factor' => esc_html__( 'Two-Factor Authentication', 'defender-security' ),
495 'mask_login' => esc_html__( 'Hide Login URL', 'defender-security' ),
496 'security_headers' => esc_html__( 'Security Policies', 'defender-security' ),
497 );
498
499 return $names[ $slug ] ?? $slug;
500 },
501 $pro_modules
502 );
503
504 $response_data['message'] .= ' ' . sprintf(
505 /* translators: %s: Comma-separated list of Pro-only feature names. */
506 esc_html__(
507 'This config included features not available in the Free version: %s. Only the available settings were imported.',
508 'defender-security'
509 ),
510 implode( ', ', $module_names )
511 );
512 }
513 }
514
515 return new Response(
516 true,
517 $response_data
518 );
519 }
520
521 /**
522 * Create config.
523 *
524 * @param Request $request The request object containing new config data.
525 *
526 * @return Response
527 * @defender_route
528 */
529 public function new_config( Request $request ): Response {
530 $data = $request->get_data();
531 $name = trim( $data['name'] );
532 if ( '' === $name ) {
533 return new Response(
534 false,
535 array(
536 'message' => esc_html__( 'Invalid config name', 'defender-security' ),
537 )
538 );
539 }
540 $name = sanitize_text_field( $name );
541 $desc = '';
542 if ( isset( $data['desc'] ) && is_string( $data['desc'] ) && '' !== trim( $data['desc'] ) ) {
543 $desc = wp_kses_post( $data['desc'] );
544 } elseif ( isset( $data['description'] ) && is_string( $data['description'] ) && '' !== trim( $data['description'] ) ) {
545 $desc = wp_kses_post( $data['description'] );
546 }
547 $note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
548 ? sanitize_text_field( (string) $data['note_added_time'] )
549 : '';
550 $this->local_store->create_from_current( $name, $desc, $note_added_time );
551
552 return new Response(
553 true,
554 array(
555 'message' => sprintf(
556 /* translators: %s: Config name. */
557 esc_html__( '%s config saved successfully.', 'defender-security' ),
558 '<strong>' . $name . '</strong>'
559 ),
560 'configs' => $this->local_store->get_frontend_configs(),
561 )
562 );
563 }
564
565 /**
566 * Download config
567 *
568 * @return Response|void
569 * @defender_route
570 */
571 public function download_config() {
572 $key = defender_get_data_from_request( 'key', 'g' );
573 if ( ! is_string( $key ) || '' === trim( $key ) ) {
574 return new Response(
575 false,
576 array(
577 'message' => esc_html__( 'Invalid config', 'defender-security' ),
578 )
579 );
580 }
581
582 $config = $this->local_store->get_full_for_key( $key );
583 if ( null === $config ) {
584 $config = $this->prepare_config_for_download( $key );
585 }
586 if ( null === $config ) {
587 return new Response(
588 false,
589 array(
590 'message' => esc_html__( 'Invalid config', 'defender-security' ),
591 )
592 );
593 }
594
595 $filename = 'wp-defender-config-' . sanitize_file_name( $config['name'] ) . '.json';
596 header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
597 echo wp_json_encode( $config, JSON_PRETTY_PRINT );
598 exit();
599 }
600
601 /**
602 * Download multiple configs as a zip file.
603 *
604 * @return Response|void
605 * @defender_route
606 */
607 public function download_configs_zip() {
608 $keys_raw = defender_get_data_from_request( 'keys', 'g' );
609 if ( ! is_string( $keys_raw ) || '' === trim( $keys_raw ) ) {
610 return new Response(
611 false,
612 array(
613 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
614 )
615 );
616 }
617
618 $decoded = json_decode( wp_unslash( $keys_raw ), true );
619 $keys = is_array( $decoded ) ? $decoded : explode( ',', $keys_raw );
620 $keys = array_values(
621 array_filter(
622 array_map(
623 static function ( $key ) {
624 return is_string( $key ) ? trim( $key ) : '';
625 },
626 $keys
627 ),
628 'boolval'
629 )
630 );
631
632 if ( array() === $keys ) {
633 return new Response(
634 false,
635 array(
636 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
637 )
638 );
639 }
640
641 if ( ! class_exists( 'ZipArchive' ) ) {
642 return new Response(
643 false,
644 array(
645 'message' => esc_html__( 'Zip archive is not supported on this server.', 'defender-security' ),
646 )
647 );
648 }
649
650 $tmp_zip_path = wp_tempnam( 'wp-defender-configs-' . time() . '.zip' );
651 if ( false === $tmp_zip_path ) {
652 return new Response(
653 false,
654 array(
655 'message' => esc_html__( 'Could not prepare zip file.', 'defender-security' ),
656 )
657 );
658 }
659
660 $cleanup_tmp_zip = static function () use ( $tmp_zip_path ) {
661 if ( file_exists( $tmp_zip_path ) ) {
662 wp_delete_file( $tmp_zip_path );
663 }
664 };
665
666 $zip = new \ZipArchive();
667 if ( true !== $zip->open( $tmp_zip_path, \ZipArchive::OVERWRITE ) ) {
668 $cleanup_tmp_zip();
669
670 return new Response(
671 false,
672 array(
673 'message' => esc_html__( 'Could not create zip file.', 'defender-security' ),
674 )
675 );
676 }
677
678 $has_entries = false;
679
680 foreach ( $keys as $key ) {
681 $config = $this->local_store->get_full_for_key( $key );
682 if ( null === $config ) {
683 $config = $this->prepare_config_for_download( $key );
684 }
685 if ( null === $config ) {
686 continue;
687 }
688
689 $file_name = $this->make_unique_zip_entry_name(
690 'wp-defender-config-' . sanitize_file_name( $config['name'] ),
691 $key,
692 $zip
693 );
694 if ( false === $zip->addFromString( $file_name, wp_json_encode( $config, JSON_PRETTY_PRINT ) ) ) {
695 $zip->close();
696 $cleanup_tmp_zip();
697
698 return new Response(
699 false,
700 array(
701 'message' => esc_html__( 'Could not prepare zip file.', 'defender-security' ),
702 )
703 );
704 }
705
706 $has_entries = true;
707 }
708
709 $zip->close();
710
711 if ( ! $has_entries ) {
712 $cleanup_tmp_zip();
713
714 return new Response(
715 false,
716 array(
717 'message' => esc_html__( 'No valid configs found to export.', 'defender-security' ),
718 )
719 );
720 }
721
722 $download_name = 'wp-defender-configs-' . gmdate( 'Ymd-His' ) . '.zip';
723 header( 'Content-Type: application/zip' );
724 header( 'Content-Disposition: attachment; filename="' . $download_name . '"' );
725 header( 'Content-Length: ' . filesize( $tmp_zip_path ) );
726 readfile( $tmp_zip_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
727 $cleanup_tmp_zip();
728 exit();
729 }
730
731 /**
732 * Apply config.
733 *
734 * @param Request $request The request object containing new config data.
735 *
736 * @return Response
737 * @defender_route
738 */
739 public function apply_config( Request $request ) {
740 $data = $request->get_data();
741 $key = trim( $data['key'] );
742 if ( '' === $key ) {
743 return new Response(
744 false,
745 array(
746 'message' => esc_html__( 'Invalid config', 'defender-security' ),
747 )
748 );
749 }
750
751 $config = $this->local_store->get_full_for_key( $key );
752 if ( null === $config ) {
753 $config = get_site_option( $key );
754 }
755 if ( false === $config || null === $config ) {
756 return new Response(
757 false,
758 array(
759 'message' => esc_html__( 'Invalid config', 'defender-security' ),
760 )
761 );
762 }
763 // Return error message or bool value for auth action.
764 $restore_result = $this->service->restore_data( $config['configs'], 'plugin' );
765 if ( is_string( $restore_result ) ) {
766 return $this->apply_config_recommendations_error_message();
767 }
768
769 $this->local_store->apply( $key );
770 $this->service->make_config_active( $key );
771 // Track.
772 $this->track_feature(
773 'def_config_applied',
774 array(
775 // The check is based on the fact that the Default config cannot be deleted.
776 'Config Type' => isset( $config['is_removable'] ) && false === $config['is_removable'] ? 'Default' : 'Custom',
777 )
778 );
779
780 $message = sprintf(
781 /* translators: %s: Config name. */
782 esc_html__(
783 '%s config has been applied successfully.',
784 'defender-security'
785 ),
786 '<strong>' . $config['name'] . '</strong>'
787 );
788 $return = array();
789 if ( $restore_result ) {
790 $login_url = wp_login_url();
791 $settings_mask_login = new \WP_Defender\Model\Setting\Mask_Login();
792 if ( $settings_mask_login->is_active() ) {
793 $login_url = $settings_mask_login->get_new_login_url();
794 }
795 $message .= '<br/>' . sprintf(
796 /* translators: %s: Login link. */
797 esc_html__(
798 'Due to currently applied security recommendations, you will now need to %s.',
799 'defender-security'
800 ),
801 '<a href="' . $login_url . '"><strong>' . esc_html__( 're-login', 'defender-security' ) . '</strong></a>'
802 );
803 $message .= '<br/>';
804 $message .= esc_html__( 'This will auto reload now.', 'defender-security' );
805
806 $return['reload'] = 3;
807 $redirect = rawurlencode( network_admin_url( 'admin.php?page=wdf-setting&view=configs' ) );
808 if ( isset( $data['screen'] ) && 'dashboard' === $data['screen'] ) {
809 $redirect = rawurlencode( network_admin_url( 'admin.php?page=wp-defender' ) );
810 }
811 $return['redirect'] = add_query_arg(
812 'redirect_to',
813 $redirect,
814 $login_url
815 );
816 $return['interval'] = 2;
817 }
818
819 $return['message'] = $message;
820 $return['auto_close'] = true;
821 $return['configs'] = $this->local_store->get_frontend_configs();
822
823 return new Response( true, $return );
824 }
825
826 /**
827 * Update config.
828 *
829 * @param Request $request The request object containing new config data.
830 *
831 * @return Response
832 * @defender_route
833 */
834 public function update_config( Request $request ) {
835 $data = $request->get_data();
836 $key = trim( $data['key'] );
837 $key = $this->resolve_config_key( $key );
838 $name = trim( $data['name'] );
839 $description = isset( $data['desc'] ) ? trim( (string) $data['desc'] ) : '';
840 if ( '' === $description && isset( $data['description'] ) ) {
841 $description = trim( (string) $data['description'] );
842 }
843 $note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
844 ? sanitize_text_field( (string) $data['note_added_time'] )
845 : null;
846 if ( '' === $name || '' === $key ) {
847 return new Response(
848 false,
849 array(
850 'message' => esc_html__( 'Invalid config', 'defender-security' ),
851 )
852 );
853 }
854
855 $config = $this->local_store->get_one( $key );
856 if ( null === $config ) {
857 $config = get_site_option( $key );
858 }
859 if ( false === $config || null === $config ) {
860 return new Response(
861 false,
862 array(
863 'message' => esc_html__( 'Invalid config', 'defender-security' ),
864 )
865 );
866 }
867
868 $option_updated = $this->local_store->update_metadata( $key, $name, $description, $note_added_time );
869 if ( ! $option_updated ) {
870 $old_config = $config;
871 $config['name'] = sanitize_text_field( $name );
872 $config['description'] = sanitize_textarea_field( $description );
873 if ( null !== $note_added_time ) {
874 $config['note_added_time'] = $note_added_time;
875 }
876 $option_updated = update_site_option( $key, $config );
877 }
878
879 if ( $option_updated ) {
880 return new Response(
881 true,
882 array(
883 'message' => sprintf(
884 /* translators: %s: Config name. */
885 esc_html__( '%s config saved successfully.', 'defender-security' ),
886 '<strong>' . $name . '</strong>'
887 ),
888 'auto_close' => true,
889 'configs' => $this->local_store->get_frontend_configs(),
890 )
891 );
892 } else {
893 return new Response(
894 false,
895 array(
896 'message' => esc_html__(
897 'An error occurred while saving your config. Please try it again.',
898 'defender-security'
899 ),
900 )
901 );
902 }
903 }
904
905 /**
906 * Sync config metadata list.
907 *
908 * This method updates name/description/note timestamp for provided configs
909 * without changing config payload values.
910 *
911 * @param Request $request The request object containing configs metadata list.
912 *
913 * @return Response
914 * @defender_route
915 */
916 public function sync_configs_metadata( Request $request ): Response {
917 // Defense in depth: central routing already validates nonce and access.
918 if ( ! $this->check_permission() ) {
919 return new Response(
920 false,
921 array(
922 'message' => esc_html__( 'You shall not pass.', 'defender-security' ),
923 )
924 );
925 }
926
927 $data = $request->get_data();
928 if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
929 return new Response(
930 false,
931 array(
932 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
933 )
934 );
935 }
936
937 $stored_configs = $this->local_store->get_all();
938 foreach ( $data['configs'] as $item ) {
939 if ( ! is_array( $item ) ) {
940 return new Response(
941 false,
942 array(
943 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
944 )
945 );
946 }
947
948 $key = '';
949 if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
950 $key = trim( $item['key'] );
951 } elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
952 $key = trim( $item['id'] );
953 }
954 $key = $this->resolve_config_key( $key, $stored_configs );
955
956 if (
957 '' === $key ||
958 0 !== strpos( $key, 'wp_defender_config' ) ||
959 ! isset( $stored_configs[ $key ] )
960 ) {
961 return new Response(
962 false,
963 array(
964 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
965 )
966 );
967 }
968
969 $config = $this->local_store->get_one( $key );
970 if ( null === $config ) {
971 continue;
972 }
973
974 $name = isset( $item['name'] ) ? (string) $item['name'] : null;
975 $description = null;
976 $note_added_time = null;
977 if ( isset( $item['description'] ) && is_string( $item['description'] ) ) {
978 $description = $item['description'];
979 } elseif ( isset( $item['desc'] ) && is_string( $item['desc'] ) ) {
980 $description = $item['desc'];
981 }
982 if ( isset( $item['note_added_time'] ) && is_scalar( $item['note_added_time'] ) ) {
983 $note_added_time = (string) $item['note_added_time'];
984 }
985 $this->local_store->update_metadata( $key, $name, $description, $note_added_time );
986 }
987
988 return new Response(
989 true,
990 array(
991 'message' => esc_html__( 'Configs synced successfully.', 'defender-security' ),
992 'configs' => $this->local_store->get_frontend_configs(),
993 )
994 );
995 }
996
997 /**
998 * Delete config.
999 *
1000 * @param Request $request The request object containing config key.
1001 *
1002 * @return Response
1003 * @defender_route
1004 */
1005 public function delete_config( Request $request ) {
1006 $data = $request->get_data();
1007 $key = trim( $data['key'] );
1008 $key = $this->resolve_config_key( $key, $this->local_store->get_all() );
1009 if ( '' === $key ) {
1010 return new Response(
1011 false,
1012 array(
1013 'message' => esc_html__( 'Invalid config', 'defender-security' ),
1014 )
1015 );
1016 }
1017
1018 $config = $this->local_store->get_one( $key );
1019 if ( null === $config ) {
1020 $config = get_site_option( $key );
1021 }
1022 if ( isset( $config['is_removable'] ) && false === (bool) $config['is_removable'] ) {
1023 return new Response(
1024 false,
1025 array(
1026 'message' => esc_html__( 'Config can\'t be removed', 'defender-security' ),
1027 )
1028 );
1029 }
1030
1031 if ( $this->local_store->delete_one( $key ) ) {
1032 return new Response(
1033 true,
1034 array(
1035 'message' => esc_html__( 'Config removed successfully.', 'defender-security' ),
1036 'auto_close' => true,
1037 'configs' => $this->local_store->get_frontend_configs(),
1038 )
1039 );
1040 }
1041
1042 if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
1043 delete_site_option( $key );
1044 $this->service->remove_index( $key );
1045 $this->service->clear_keys();
1046 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1047
1048 return new Response(
1049 true,
1050 array(
1051 'message' => esc_html__( 'Config removed successfully.', 'defender-security' ),
1052 'auto_close' => true,
1053 'configs' => $this->local_store->get_frontend_configs(),
1054 )
1055 );
1056 }
1057
1058 return new Response(
1059 false,
1060 array(
1061 'message' => esc_html__( 'Invalid config', 'defender-security' ),
1062 )
1063 );
1064 }
1065
1066 /**
1067 * Syncs configs list by keeping only the provided config keys.
1068 *
1069 * This method is intentionally separate from `delete_config` for backward compatibility.
1070 *
1071 * @param Request $request The request object containing the list of configs to keep.
1072 *
1073 * @return Response
1074 * @defender_route
1075 */
1076 public function sync_configs_list( Request $request ): Response {
1077 // Defense in depth: central routing already enforces nonce and private access checks.
1078 if ( ! $this->check_permission() ) {
1079 return new Response(
1080 false,
1081 array(
1082 'message' => esc_html__( 'You shall not pass.', 'defender-security' ),
1083 )
1084 );
1085 }
1086
1087 $data = $request->get_data();
1088 if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
1089 return new Response(
1090 false,
1091 array(
1092 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1093 )
1094 );
1095 }
1096
1097 $stored_configs = $this->local_store->get_all();
1098 $keep_keys = array();
1099 foreach ( $data['configs'] as $item ) {
1100 if ( ! is_array( $item ) ) {
1101 return new Response(
1102 false,
1103 array(
1104 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1105 )
1106 );
1107 }
1108 $key = '';
1109 if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
1110 $key = trim( $item['key'] );
1111 } elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
1112 $key = trim( $item['id'] );
1113 }
1114
1115 if (
1116 '' === $key ||
1117 0 !== strpos( $key, 'wp_defender_config' ) ||
1118 ! isset( $stored_configs[ $key ] )
1119 ) {
1120 return new Response(
1121 false,
1122 array(
1123 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1124 )
1125 );
1126 }
1127
1128 $keep_keys[ $key ] = true;
1129 }
1130
1131 $this->local_store->sync_keep_list( array_keys( $keep_keys ) );
1132
1133 return new Response(
1134 true,
1135 array(
1136 'message' => esc_html__( 'Config list synced successfully.', 'defender-security' ),
1137 'auto_close' => true,
1138 'configs' => $this->local_store->get_frontend_configs(),
1139 )
1140 );
1141 }
1142
1143 /**
1144 * Exports strings.
1145 *
1146 * @return array An array of strings.
1147 */
1148 public function export_strings(): array {
1149 return array();
1150 }
1151
1152 /**
1153 * Resolves a config key from an incoming identifier.
1154 *
1155 * Supports direct option keys and numeric Hub IDs.
1156 *
1157 * @param string $incoming_key Incoming key/id.
1158 * @param array|null $stored_configs Optional preloaded configs map.
1159 *
1160 * @return string
1161 */
1162 private function resolve_config_key( string $incoming_key, ?array $stored_configs = null ): string {
1163 $key = trim( $incoming_key );
1164 if ( '' === $key ) {
1165 return '';
1166 }
1167
1168 if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
1169 return $key;
1170 }
1171
1172 $configs = is_array( $stored_configs ) ? $stored_configs : $this->local_store->get_all();
1173 foreach ( $configs as $config_key => $config ) {
1174 if ( ! is_array( $config ) || ! isset( $config['hub_id'] ) ) {
1175 continue;
1176 }
1177 if ( (string) $config['hub_id'] === $key ) {
1178 return (string) $config_key;
1179 }
1180 }
1181
1182 return $key;
1183 }
1184
1185 /**
1186 * Generates an error message for when there is an issue applying some tweaks from the Recommendations tab.
1187 *
1188 * @return Response The response object containing the error message and fresh frontend configurations.
1189 */
1190 private function apply_config_recommendations_error_message(): Response {
1191 $message = sprintf(
1192 /* translators: 1: Recommendations tab, 2: wp-config.php file, 3: Documentation. */
1193 esc_html__(
1194 '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.',
1195 'defender-security'
1196 ),
1197 '<strong>' . esc_html__( 'Recommendations', 'defender-security' ) . '</strong>',
1198 '<strong>' . esc_html__( 'wp-config.php', 'defender-security' ) . '</strong>',
1199 '<a href="' . WP_DEFENDER_DOCS_LINK . '#manually-applying-recommendations" target="_blank">' . esc_html__( 'documentation', 'defender-security' ) . '</a>'
1200 );
1201
1202 return new Response(
1203 false,
1204 array(
1205 'message' => $message,
1206 'configs' => $this->local_store->get_frontend_configs(),
1207 )
1208 );
1209 }
1210
1211 /**
1212 * Prepares a config payload for download.
1213 *
1214 * @param string $key Config option key.
1215 *
1216 * @return array|null
1217 */
1218 private function prepare_config_for_download( string $key ): ?array {
1219 $config = get_site_option( $key );
1220 if ( false === $config || ! is_array( $config ) ) {
1221 return null;
1222 }
1223
1224 $sample = $this->service->gather_data();
1225 foreach ( $sample as $slug => $data ) {
1226 foreach ( $data as $k => $val ) {
1227 if ( ! isset( $config['configs'][ $slug ][ $k ] ) ) {
1228 $config['configs'][ $slug ][ $k ] = null;
1229 }
1230 }
1231 }
1232
1233 return $config;
1234 }
1235
1236 /**
1237 * Creates a unique JSON filename for a zip entry.
1238 *
1239 * @param string $base_name Base file name without extension.
1240 * @param string $key Config option key.
1241 * @param \ZipArchive $zip Active zip archive.
1242 *
1243 * @return string
1244 */
1245 private function make_unique_zip_entry_name( string $base_name, string $key, \ZipArchive $zip ): string {
1246 $base_name = trim( $base_name );
1247 if ( '' === $base_name ) {
1248 $base_name = 'wp-defender-config';
1249 }
1250
1251 $try_name = $base_name . '.json';
1252 if ( false === $zip->locateName( $try_name ) ) {
1253 return $try_name;
1254 }
1255
1256 $key_suffix = sanitize_file_name( str_replace( 'wp_defender_config_', '', $key ) );
1257 if ( '' !== $key_suffix ) {
1258 $try_name = $base_name . '-' . $key_suffix . '.json';
1259 if ( false === $zip->locateName( $try_name ) ) {
1260 return $try_name;
1261 }
1262 }
1263
1264 $index = 2;
1265 while ( true ) {
1266 $try_name = $base_name . '-' . $index . '.json';
1267 if ( false === $zip->locateName( $try_name ) ) {
1268 return $try_name;
1269 }
1270 ++$index;
1271 }
1272 }
1273
1274 /**
1275 * Clear out lines that are older than 30 days.
1276 *
1277 * @return void
1278 */
1279 public function clear_logs(): void {
1280 // since 2.7.0.
1281 $time_limit = apply_filters( 'wpdef_clear_logs_time_limit', MONTH_IN_SECONDS );
1282
1283 if ( is_multisite() ) {
1284 global $wpdb;
1285 $offset = 0;
1286 $limit = 100;
1287 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1288 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1289 ARRAY_A
1290 );
1291 while ( is_array( $blogs ) && array() !== $blogs ) {
1292 foreach ( $blogs as $blog ) {
1293 switch_to_blog( $blog['blog_id'] );
1294
1295 $this->clear_logs_from_files( $time_limit );
1296
1297 restore_current_blog();
1298 }
1299 $offset += $limit;
1300 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1301 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1302 ARRAY_A
1303 );
1304 }
1305 } else {
1306 $this->clear_logs_from_files( $time_limit );
1307 }
1308 }
1309
1310 /**
1311 * Clear log files older than the specified time.
1312 *
1313 * @param int $time_limit The time limit in seconds.
1314 *
1315 * @return void
1316 * @since 2.7.0
1317 */
1318 public function clear_logs_from_files( int $time_limit = MONTH_IN_SECONDS ) {
1319 global $wp_filesystem;
1320 // Initialize the WP filesystem, no more using 'file-put-contents' function.
1321 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
1322 require_once ABSPATH . '/wp-admin/includes/file.php';
1323 WP_Filesystem();
1324 }
1325 $now = wp_date( 'c' );
1326 $files = array( wd_internal_log() );
1327
1328 foreach ( $files as $file_name ) {
1329 $file_path = $this->get_log_path( $file_name );
1330
1331 if ( ! file_exists( $file_path ) ) {
1332 return;
1333 }
1334
1335 $content = file( $file_path );
1336 $size_of_content = is_array( $content ) || $content instanceof Countable ? count( $content ) : 0;
1337
1338 foreach ( $content as $index => $line ) {
1339 // If the line does not start with '[' (it's probably not a new entry).
1340 $first_char = substr( $line, 0, 1 );
1341
1342 if ( '[' !== $first_char ) {
1343 // Delete.
1344 unset( $content[ $index ] );
1345 }
1346
1347 /**
1348 * Get the date from entry. Items can be an array it two cases - if there's a valid date, or if the line
1349 * contained something like [header] in the start. Cannot make assumptions just on the fact it's an array.
1350 */
1351 preg_match( '/\[(.*)\]/', $line, $items );
1352
1353 // If, for some reason, can't get the date, or it's not the size of an ISO 8601 date.
1354 if ( ! isset( $items[1] ) || 25 !== strlen( $items[1] ) ) {
1355 // Delete.
1356 unset( $content[ $index ] );
1357 } else {
1358 // It looks like it's a valid date string, compare with today.
1359 $time_diff = strtotime( $now ) - strtotime( $items[1] );
1360
1361 // 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.
1362 if ( $time_diff < $time_limit ) {
1363 break;
1364 }
1365
1366 unset( $content[ $index ] );
1367 }
1368 }
1369
1370 // Nothing changed - do nothing.
1371 if ( ( is_array( $content ) || $content instanceof Countable ? count( $content ) : 0 ) === $size_of_content ) {
1372 return;
1373 }
1374
1375 // Glue back together and write back to file.
1376 $content = implode( '', $content );
1377
1378 $wp_filesystem->put_contents( $file_path, $content );
1379 }
1380 }
1381
1382 /**
1383 * Track the data if there are settings changes.
1384 *
1385 * @param array $old_settings Old settings.
1386 * @param array $new_settings New settings.
1387 *
1388 * @return void
1389 * @since 4.2.0
1390 */
1391 public function intercept_settings_update( $old_settings, $new_settings ) {
1392 $from = $this->get_triggered_location();
1393 if (
1394 '' !== $from
1395 && isset( $new_settings['usage_tracking'], $old_settings['usage_tracking'] )
1396 && $new_settings['usage_tracking'] !== $old_settings['usage_tracking']
1397 ) {
1398 $this->track_opt_toggle( (bool) $new_settings['usage_tracking'], $from );
1399 }
1400 }
1401 }
1402