PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.3.0
Defender Security – Malware Scanner, Login Security & Firewall v6.3.0
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
← All changes | src/controller/class-main-setting.php +657 -176 5.6.0 → 6.3.0 View file →
@@ -11,18 +11,25 @@
11 11 use WP_Defender\Event;
12 12 use Calotes\Component\Request;
13 13 use Calotes\Component\Response;
14 14 use WP_Defender\Behavior\WPMUDEV;
15 +use WP_Defender\Controller\Notification;
15 16 use WP_Defender\Component\Backup_Settings;
16 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;
17 21 use WP_Defender\Component\Config\Config_Hub_Helper;
18 22 use WP_Defender\Model\Setting\Main_Setting as Model_Main_Setting;
19 23 use WP_Filesystem_Base;
24 +use WP_Defender\Controller\Security_Headers;
25 +use WP_Defender\Controller\Firewall;
20 26
21 27 /**
22 28 * Methods for handling main settings.
23 29 */
24 30 class Main_Setting extends Event {
31 + use Defender_Dashboard_Client;
25 32
26 33 /**
27 34 * The slug identifier for this controller.
28 35 *
@@ -51,8 +58,15 @@
51 58 */
52 59 protected $wpmudev;
53 60
54 61 /**
62 + * Local configs store.
63 + *
64 + * @var Local_Config_Store
65 + */
66 + protected $local_store;
67 +
68 + /**
55 69 * The intention/nonce action of the current request.
56 70 *
57 71 * @since 4.0.0
58 72 * @var string
@@ -70,18 +84,38 @@
70 84 $this->parent_slug
71 85 );
72 86
73 87 // Internal cache.
74 - $this->model = new Model_Main_Setting();
75 - $this->service = wd_di()->get( Backup_Settings::class );
76 - $this->wpmudev = wd_di()->get( WPMUDEV::class );
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 );
77 92 add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
78 93 $this->register_routes();
79 94
80 - // Add cron schedule to clean out outdated logs.
81 - add_action( 'wp_defender_clear_logs', array( $this, 'clear_logs' ) );
82 - add_action( 'admin_init', array( $this, 'check_cron_schedule' ) );
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 + );
83 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 + wd_di()->get( Audit_Logging::class );
117 + }
84 118 }
85 119
86 120 /**
87 121 * Check actual config data.
@@ -115,10 +149,73 @@
115 149 public function enqueue_assets() {
116 150 if ( ! $this->is_page_active() ) {
117 151 return;
118 152 }
119 - wp_localize_script( 'def-settings', 'settings', $this->data_frontend() );
120 - wp_enqueue_script( 'def-settings' );
153 +
154 + $handle = 'defender-ui-settings';
155 + wp_enqueue_script(
156 + $handle,
157 + WP_DEFENDER_BASE_URL . 'assets/js/settings-ui.js',
158 + array( 'def-vue', 'def-manifest', 'def-core-ui', 'defender', 'wp-i18n' ),
159 + DEFENDER_VERSION,
160 + true
161 + );
162 + wp_set_script_translations( $handle, 'wpdef' );
163 +
164 + $data = $this->data_frontend();
165 + $routes = $data['routes'] ?? array();
166 + $nonces = $data['nonces'] ?? array();
167 + unset( $data['routes'], $data['nonces'] );
168 +
169 + // Include Security Headers data for the Security Policies tab.
170 + $sh_controller = wd_di()->get( Security_Headers::class );
171 + $sh_data = $sh_controller->data_frontend();
172 + $sh_routes = $sh_data['routes'] ?? array();
173 + $sh_nonces = $sh_data['nonces'] ?? array();
174 + unset( $sh_data['routes'], $sh_data['nonces'] );
175 +
176 + // Namespace security headers routes/nonces to avoid key collisions.
177 + foreach ( $sh_routes as $key => $route ) {
178 + $routes[ 'security_headers_' . $key ] = $route;
179 + }
180 + foreach ( $sh_nonces as $key => $nonce ) {
181 + $nonces[ 'security_headers_' . $key ] = $nonce;
182 + }
183 +
184 + // Include Module data for the Tools tab.
185 + $scan_controller = wd_di()->get( Scan::class );
186 + $firewall_controller = wd_di()->get( Firewall::class );
187 + $audit_logging_controller = wd_di()->get( Audit_Logging::class );
188 + $blocklist_monitor_controller = wd_di()->get( Blocklist_Monitor::class );
189 + $notification_controller = wd_di()->get( Notification::class );
190 + $recipients_controller = wd_di()->get( Recipients::class );
191 + wp_localize_script(
192 + $handle,
193 + 'defenderUIData',
194 + array_merge(
195 + $this->get_shared_data(),
196 + // Specific data.
197 + array(
198 + 'settings' => array_merge( $data, array( 'security_headers' => $sh_data ) ),
199 + 'scan' => $scan_controller->data_frontend(),
200 + 'firewall' => $firewall_controller->data_frontend(),
201 + 'audit_logging' => $audit_logging_controller->settings_data_frontend(),
202 + 'blocklist_monitor' => $blocklist_monitor_controller->data_frontend(),
203 + 'notification' => $notification_controller->data_frontend(),
204 + 'recipients' => $recipients_controller->data_frontend(),
205 + 'routes' => $routes,
206 + 'nonces' => $nonces,
207 + )
208 + )
209 + );
210 +
211 + wp_enqueue_style(
212 + $handle,
213 + WP_DEFENDER_BASE_URL . 'assets/css/core-ui.css',
214 + array(),
215 + DEFENDER_VERSION
216 + );
217 +
121 218 $this->enqueue_main_assets();
122 219 }
123 220
124 221 /**
@@ -171,10 +268,12 @@
171 268 * @return Response
172 269 * @defender_route
173 270 */
174 271 public function reset_settings(): Response {
175 - wd_di()->get( Advanced_Tools::class )->remove_settings();
176 - wd_di()->get( Audit_Logging::class )->remove_settings();
272 + $preserve_settings = 'preserve' === $this->get_model()->uninstall_settings;
273 +
274 + wd_di()->get( Login_Access::class )->remove_settings();
275 + wd_di()->get( Blocklist_Monitor::class )->remove_settings();
177 276 wd_di()->get( Dashboard::class )->remove_settings();
178 277 wd_di()->get( Security_Tweaks::class )->remove_settings();
179 278 wd_di()->get( Scan::class )->remove_settings();
180 279 // Parent and submodules.
@@ -183,12 +282,19 @@
183 282 wd_di()->get( Mask_Login::class )->remove_settings();
184 283 wd_di()->get( Notification::class )->remove_settings();
185 284 wd_di()->get( Two_Factor::class )->remove_settings();
186 285 wd_di()->get( Blocklist_Monitor::class )->remove_settings();
286 + wd_di()->get( Data_Tracking::class )->remove_settings();
287 + wd_di()->get( \WP_Defender\Controller\Setup_Wizard::class )->remove_settings();
288 + wd_di()->get( \WP_Defender\Controller\Activity_Log::class )->remove_settings();
289 +
187 290 $this->set_intention( 'Data Reset' );
188 291 // Track first until settings are removed.
189 292 $this->track_opt( false );
190 293 $this->remove_settings();
294 + if ( ! $preserve_settings ) {
295 + update_site_option( Scan::FIRST_SCAN_STARTED, '0' );
296 + }
191 297 // Indicate that it is not a new installation.
192 298 defender_no_fresh_install();
193 299
194 300 return new Response(
@@ -238,17 +344,10 @@
238 344 public function data_frontend(): array {
239 345 $model = $this->get_model();
240 346
241 347 $this->service->maybe_create_default_config();
242 - $configs = $this->get_configs_and_update_status();
348 + $configs = $this->local_store->get_frontend_configs();
243 349
244 - foreach ( $configs as &$config ) {
245 - // Unset the data as we don't need it.
246 - if ( isset( $config['configs'] ) ) {
247 - unset( $config['configs'] );
248 - }
249 - }
250 -
251 350 $link = $this->wpmudev->is_member()
252 351 ? 'https://wpmudev.com/translate/projects/wpdef/'
253 352 : 'https://translate.wordpress.org/projects/wp-plugins/defender-security/';
254 353 $can_whitelabel = wd_di()->get( \WP_Defender\Integrations\Dashboard_Whitelabel::class )->can_whitelabel();
@@ -273,8 +372,10 @@
273 372 'setting_url' => network_admin_url( is_multisite() ? 'settings.php' : 'options-general.php' ),
274 373 'privacy_link' => Model_Main_Setting::PRIVACY_LINK,
275 374 ),
276 375 'configs' => $configs,
376 + 'hub_connector' => wd_di()->get( Hub_Connector::class )->data_frontend(),
377 + 'antibot' => wd_di()->get( Antibot_Global_Firewall::class )->data_frontend(),
277 378 ),
278 379 $this->dump_routes_and_nonces()
279 380 );
280 381 }
@@ -295,9 +396,14 @@
295 396 */
296 397 public function import_data( array $data ) {
297 398 $model = $this->get_model();
298 399
400 + // Preserve Usage Tracking; it is a user privacy choice that a config must not change.
401 + $current_usage_tracking = $model->usage_tracking;
402 +
299 403 $model->import( $data );
404 + $model->usage_tracking = $current_usage_tracking;
405 +
300 406 if ( $model->validate() ) {
301 407 $model->save();
302 408 }
303 409 }
@@ -311,29 +417,29 @@
311 417 *
312 418 * @return bool Returns true if the importer data is valid, false otherwise.
313 419 */
314 420 private function validate_importer( $importer ): bool {
315 - if ( $this->service->verify_config_data( $importer ) ) {
316 - // Validate content. This is the current data, we use this for verify the schema.
317 - $sample = $this->service->gather_data();
318 - foreach ( $importer['configs'] as $slug => $module ) {
319 - // This is not in the sample, file is invalid.
320 - if ( ! isset( $sample[ $slug ] ) ) {
321 - return false;
322 - }
421 + if ( ! $this->service->verify_config_data( $importer ) || ! is_array( $importer['configs'] ) ) {
422 + return false;
423 + }
323 424
324 - $keys = array_keys( $sample[ $slug ] );
325 - $import_keys = array_keys( $module );
326 - $diff = array_diff( $import_keys, $keys );
327 - if ( count( $diff ) ) {
328 - return false;
329 - }
425 + $sample = $this->service->gather_data();
426 + foreach ( $importer['configs'] as $slug => $module ) {
427 + // Ignore whole modules unavailable on this site, such as Pro-only modules in Free builds.
428 + if ( ! isset( $sample[ $slug ] ) ) {
429 + continue;
430 + }
330 431
331 - return true;
432 + if (
433 + ! is_array( $module )
434 + || ! is_array( $sample[ $slug ] )
435 + || array() !== array_diff_key( $module, $sample[ $slug ] )
436 + ) {
437 + return false;
332 438 }
333 439 }
334 440
335 - return false;
441 + return true;
336 442 }
337 443
338 444 /**
339 445 * Import config.
@@ -360,14 +466,8 @@
360 466 )
361 467 );
362 468 }
363 469
364 - // If it's old config structure then we upgrade configs to new format.
365 - if ( ! empty( $importer['configs'] ) && ! $this->service->check_for_new_structure( $importer['configs'] ) ) {
366 - $adapter = wd_di()->get( Config_Adapter::class );
367 - $importer['configs'] = $adapter->upgrade( $importer['configs'] );
368 - }
369 -
370 470 if ( ! $this->validate_importer( $importer ) ) {
371 471 return new Response(
372 472 false,
373 473 array(
@@ -379,37 +479,70 @@
379 479 );
380 480 }
381 481
382 482 // Do not use wp_strip_all_tags() to prevent XSS attack.
383 - $name = sanitize_text_field( $importer['name'] );
384 - $configs = array(
385 - 'name' => $name,
386 - 'immortal' => false,
387 - 'is_removable' => true,
483 + $name = sanitize_text_field( $importer['name'] );
484 + $this->local_store->add_imported( $importer );
485 +
486 + // Detect Pro-only modules that were skipped during validation
487 + // to inform Free users that some settings were not imported.
488 + $response_data = array(
489 + 'message' => sprintf(
490 + /* translators: %s: Config name. */
491 + esc_html__(
492 + '%s config has been uploaded successfully – you can now apply it to this site.',
493 + 'defender-security'
494 + ),
495 + '<strong>' . $name . '</strong>'
496 + ),
497 + 'configs' => $this->local_store->get_frontend_configs(),
388 498 );
389 499
390 - $configs['configs'] = $importer['configs'];
391 - $configs['description'] = isset( $importer['description'] ) && ! empty( $importer['description'] )
392 - ? sanitize_textarea_field( $importer['description'] )
393 - : '';
394 - $configs['strings'] = $this->service->import_module_strings( $importer );
395 - $key = 'wp_defender_config_import_' . time();
396 - update_site_option( $key, $configs );
397 - $this->service->index_key( $key );
500 + if ( ! $this->wpmudev->is_pro() ) {
501 + $sample = $this->service->gather_data();
502 + $pro_modules = array();
398 503
399 - return new Response(
400 - true,
401 - array(
402 - 'message' => sprintf(
403 - /* translators: %s: Config name. */
504 + foreach ( $importer['configs'] as $slug => $module ) {
505 + // Modules present in the imported config but NOT in Free's sample data are Pro-only.
506 + if ( ! isset( $sample[ $slug ] ) ) {
507 + $pro_modules[] = $slug;
508 + }
509 + }
510 +
511 + if ( array() !== $pro_modules ) {
512 + $module_names = array_map(
513 + function ( $slug ) {
514 + $names = array(
515 + 'audit' => esc_html__( 'Audit Logs', 'defender-security' ),
516 + 'waf' => esc_html__( 'Web Application Firewall (WAF)', 'defender-security' ),
517 + 'blocklist_monitor' => esc_html__( 'Blocklist Monitor', 'defender-security' ),
518 + 'pwned_passwords' => esc_html__( 'Pwned Passwords', 'defender-security' ),
519 + 'force_strong_password' => esc_html__( 'Strong Passwords', 'defender-security' ),
520 + 'session_protection' => esc_html__( 'Session Protection', 'defender-security' ),
521 + 'two_factor' => esc_html__( 'Two-Factor Authentication', 'defender-security' ),
522 + 'mask_login' => esc_html__( 'Hide Login URL', 'defender-security' ),
523 + 'security_headers' => esc_html__( 'Security Policies', 'defender-security' ),
524 + );
525 +
526 + return $names[ $slug ] ?? $slug;
527 + },
528 + $pro_modules
529 + );
530 +
531 + $response_data['message'] .= ' ' . sprintf(
532 + /* translators: %s: Comma-separated list of Pro-only feature names. */
404 533 esc_html__(
405 - '%s config has been uploaded successfully – you can now apply it to this site.',
534 + 'This config included features not available in the Free version: %s. Only the available settings were imported.',
406 535 'defender-security'
407 536 ),
408 - '<strong>' . $name . '</strong>'
409 - ),
410 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
411 - )
537 + implode( ', ', $module_names )
538 + );
539 + }
540 + }
541 +
542 + return new Response(
543 + true,
544 + $response_data
412 545 );
413 546 }
414 547
415 548 /**
@@ -422,9 +555,9 @@
422 555 */
423 556 public function new_config( Request $request ): Response {
424 557 $data = $request->get_data();
425 558 $name = trim( $data['name'] );
426 - if ( empty( $name ) ) {
559 + if ( '' === $name ) {
427 560 return new Response(
428 561 false,
429 562 array(
430 563 'message' => esc_html__( 'Invalid config name', 'defender-security' ),
@@ -430,95 +563,196 @@
430 563 'message' => esc_html__( 'Invalid config name', 'defender-security' ),
431 564 )
432 565 );
433 566 }
434 - $name = sanitize_text_field( $name );
435 - $desc = isset( $data['desc'] ) && ! empty( $data['desc'] ) ? wp_kses_post( $data['desc'] ) : '';
436 - $key = 'wp_defender_config_' . time();
437 - $settings = $this->service->parse_data_for_import();
438 - $data = array_merge(
567 + $name = sanitize_text_field( $name );
568 + $desc = '';
569 + if ( isset( $data['desc'] ) && is_string( $data['desc'] ) && '' !== trim( $data['desc'] ) ) {
570 + $desc = wp_kses_post( $data['desc'] );
571 + } elseif ( isset( $data['description'] ) && is_string( $data['description'] ) && '' !== trim( $data['description'] ) ) {
572 + $desc = wp_kses_post( $data['description'] );
573 + }
574 + $note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
575 + ? sanitize_text_field( (string) $data['note_added_time'] )
576 + : '';
577 + $this->local_store->create_from_current( $name, $desc, $note_added_time );
578 +
579 + return new Response(
580 + true,
439 581 array(
440 - 'name' => $name,
441 - 'immortal' => false,
442 - 'description' => $desc,
443 - 'is_removable' => true,
444 - ),
445 - $settings
582 + 'message' => sprintf(
583 + /* translators: %s: Config name. */
584 + esc_html__( '%s config saved successfully.', 'defender-security' ),
585 + '<strong>' . $name . '</strong>'
586 + ),
587 + 'configs' => $this->local_store->get_frontend_configs(),
588 + )
446 589 );
590 + }
447 591
448 - // Add config to HUB.
449 - $hub_id = Config_Hub_Helper::add_configs_to_hub( $data );
450 -
451 - if ( $hub_id ) {
452 - $data['hub_id'] = $hub_id;
453 - }
454 -
455 - unset( $data['labels'] );
456 -
457 - if ( update_site_option( $key, $data ) ) {
458 - $this->service->index_key( $key );
459 -
592 + /**
593 + * Download config
594 + *
595 + * @return Response|void
596 + * @defender_route
597 + */
598 + public function download_config() {
599 + $key = defender_get_data_from_request( 'key', 'g' );
600 + if ( ! is_string( $key ) || '' === trim( $key ) ) {
460 601 return new Response(
461 - true,
602 + false,
462 603 array(
463 - 'message' => sprintf(
464 - /* translators: %s: Config name. */
465 - esc_html__( '%s config saved successfully.', 'defender-security' ),
466 - '<strong>' . $name . '</strong>'
467 - ),
468 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
604 + 'message' => esc_html__( 'Invalid config', 'defender-security' ),
469 605 )
470 606 );
471 - } else {
607 + }
608 +
609 + $config = $this->local_store->get_full_for_key( $key );
610 + if ( null === $config ) {
611 + $config = $this->prepare_config_for_download( $key );
612 + }
613 + if ( null === $config ) {
472 614 return new Response(
473 615 false,
474 616 array(
475 - 'message' => esc_html__(
476 - 'An error occurred while saving your config. Please try it again.',
477 - 'defender-security'
478 - ),
617 + 'message' => esc_html__( 'Invalid config', 'defender-security' ),
479 618 )
480 619 );
481 620 }
621 +
622 + $filename = 'wp-defender-config-' . sanitize_file_name( $config['name'] ) . '.json';
623 + header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
624 + echo wp_json_encode( $config, JSON_PRETTY_PRINT );
625 + exit();
482 626 }
483 627
484 628 /**
485 - * Download config
629 + * Download multiple configs as a zip file.
486 630 *
487 631 * @return Response|void
488 632 * @defender_route
489 633 */
490 - public function download_config() {
491 - $key = defender_get_data_from_request( 'key', 'g' );
492 - if ( empty( $key ) ) {
634 + public function download_configs_zip() {
635 + $keys_raw = defender_get_data_from_request( 'keys', 'g' );
636 + if ( ! is_string( $keys_raw ) || '' === trim( $keys_raw ) ) {
493 637 return new Response(
494 638 false,
495 639 array(
496 - 'message' => esc_html__( 'Invalid config', 'defender-security' ),
640 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
497 641 )
498 642 );
499 643 }
500 644
501 - $config = get_site_option( $key );
502 - if ( false === $config ) {
645 + $decoded = json_decode( wp_unslash( $keys_raw ), true );
646 + $keys = is_array( $decoded ) ? $decoded : explode( ',', $keys_raw );
647 + $keys = array_values(
648 + array_filter(
649 + array_map(
650 + static function ( $key ) {
651 + return is_string( $key ) ? trim( $key ) : '';
652 + },
653 + $keys
654 + ),
655 + 'boolval'
656 + )
657 + );
658 +
659 + if ( array() === $keys ) {
503 660 return new Response(
504 661 false,
505 662 array(
506 - 'message' => esc_html__( 'Invalid config', 'defender-security' ),
663 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
507 664 )
508 665 );
509 666 }
510 - $sample = $this->service->gather_data();
511 - foreach ( $sample as $slug => $data ) {
512 - foreach ( $data as $key => $val ) {
513 - if ( ! isset( $config['configs'][ $slug ][ $key ] ) ) {
514 - $config['configs'][ $slug ][ $key ] = null;
515 - }
667 +
668 + if ( ! class_exists( 'ZipArchive' ) ) {
669 + return new Response(
670 + false,
671 + array(
672 + 'message' => esc_html__( 'Zip archive is not supported on this server.', 'defender-security' ),
673 + )
674 + );
675 + }
676 +
677 + $tmp_zip_path = wp_tempnam( 'wp-defender-configs-' . time() . '.zip' );
678 + if ( false === $tmp_zip_path ) {
679 + return new Response(
680 + false,
681 + array(
682 + 'message' => esc_html__( 'Could not prepare zip file.', 'defender-security' ),
683 + )
684 + );
685 + }
686 +
687 + $cleanup_tmp_zip = static function () use ( $tmp_zip_path ) {
688 + if ( file_exists( $tmp_zip_path ) ) {
689 + wp_delete_file( $tmp_zip_path );
516 690 }
691 + };
692 +
693 + $zip = new \ZipArchive();
694 + if ( true !== $zip->open( $tmp_zip_path, \ZipArchive::OVERWRITE ) ) {
695 + $cleanup_tmp_zip();
696 +
697 + return new Response(
698 + false,
699 + array(
700 + 'message' => esc_html__( 'Could not create zip file.', 'defender-security' ),
701 + )
702 + );
517 703 }
518 - $filename = 'wp-defender-config-' . sanitize_file_name( $config['name'] ) . '.json';
519 - header( 'Content-disposition: attachment; filename=' . $filename );
520 - echo wp_json_encode( $config, JSON_PRETTY_PRINT );
704 +
705 + $has_entries = false;
706 +
707 + foreach ( $keys as $key ) {
708 + $config = $this->local_store->get_full_for_key( $key );
709 + if ( null === $config ) {
710 + $config = $this->prepare_config_for_download( $key );
711 + }
712 + if ( null === $config ) {
713 + continue;
714 + }
715 +
716 + $file_name = $this->make_unique_zip_entry_name(
717 + 'wp-defender-config-' . sanitize_file_name( $config['name'] ),
718 + $key,
719 + $zip
720 + );
721 + if ( false === $zip->addFromString( $file_name, wp_json_encode( $config, JSON_PRETTY_PRINT ) ) ) {
722 + $zip->close();
723 + $cleanup_tmp_zip();
724 +
725 + return new Response(
726 + false,
727 + array(
728 + 'message' => esc_html__( 'Could not prepare zip file.', 'defender-security' ),
729 + )
730 + );
731 + }
732 +
733 + $has_entries = true;
734 + }
735 +
736 + $zip->close();
737 +
738 + if ( ! $has_entries ) {
739 + $cleanup_tmp_zip();
740 +
741 + return new Response(
742 + false,
743 + array(
744 + 'message' => esc_html__( 'No valid configs found to export.', 'defender-security' ),
745 + )
746 + );
747 + }
748 +
749 + $download_name = 'wp-defender-configs-' . gmdate( 'Ymd-His' ) . '.zip';
750 + header( 'Content-Type: application/zip' );
751 + header( 'Content-Disposition: attachment; filename="' . $download_name . '"' );
752 + header( 'Content-Length: ' . filesize( $tmp_zip_path ) );
753 + readfile( $tmp_zip_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
754 + $cleanup_tmp_zip();
521 755 exit();
522 756 }
523 757
524 758 /**
@@ -531,9 +765,9 @@
531 765 */
532 766 public function apply_config( Request $request ) {
533 767 $data = $request->get_data();
534 768 $key = trim( $data['key'] );
535 - if ( empty( $key ) ) {
769 + if ( '' === $key ) {
536 770 return new Response(
537 771 false,
538 772 array(
539 773 'message' => esc_html__( 'Invalid config', 'defender-security' ),
@@ -540,10 +774,13 @@
540 774 )
541 775 );
542 776 }
543 777
544 - $config = get_site_option( $key );
545 - if ( false === $config ) {
778 + $config = $this->local_store->get_full_for_key( $key );
779 + if ( null === $config ) {
780 + $config = get_site_option( $key );
781 + }
782 + if ( false === $config || null === $config ) {
546 783 return new Response(
547 784 false,
548 785 array(
549 786 'message' => esc_html__( 'Invalid config', 'defender-security' ),
@@ -555,8 +792,9 @@
555 792 if ( is_string( $restore_result ) ) {
556 793 return $this->apply_config_recommendations_error_message();
557 794 }
558 795
796 + $this->local_store->apply( $key );
559 797 $this->service->make_config_active( $key );
560 798 // Track.
561 799 $this->track_feature(
562 800 'def_config_applied',
@@ -566,9 +804,9 @@
566 804 )
567 805 );
568 806
569 807 $message = sprintf(
570 - /* translators: %s: Config name. */
808 + /* translators: %s: Config name. */
571 809 esc_html__(
572 810 '%s config has been applied successfully.',
573 811 'defender-security'
574 812 ),
@@ -581,9 +819,9 @@
581 819 if ( $settings_mask_login->is_active() ) {
582 820 $login_url = $settings_mask_login->get_new_login_url();
583 821 }
584 822 $message .= '<br/>' . sprintf(
585 - /* translators: %s: Login link. */
823 + /* translators: %s: Login link. */
586 824 esc_html__(
587 825 'Due to currently applied security recommendations, you will now need to %s.',
588 826 'defender-security'
589 827 ),
@@ -606,9 +844,9 @@
606 844 }
607 845
608 846 $return['message'] = $message;
609 847 $return['auto_close'] = true;
610 - $return['configs'] = Config_Hub_Helper::get_fresh_frontend_configs( $this->service );
848 + $return['configs'] = $this->local_store->get_frontend_configs();
611 849
612 850 return new Response( true, $return );
613 851 }
614 852
@@ -622,11 +860,18 @@
622 860 */
623 861 public function update_config( Request $request ) {
624 862 $data = $request->get_data();
625 863 $key = trim( $data['key'] );
864 + $key = $this->resolve_config_key( $key );
626 865 $name = trim( $data['name'] );
627 - $description = trim( $data['desc'] );
628 - if ( empty( $name ) || empty( $key ) ) {
866 + $description = isset( $data['desc'] ) ? trim( (string) $data['desc'] ) : '';
867 + if ( '' === $description && isset( $data['description'] ) ) {
868 + $description = trim( (string) $data['description'] );
869 + }
870 + $note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
871 + ? sanitize_text_field( (string) $data['note_added_time'] )
872 + : null;
873 + if ( '' === $name || '' === $key ) {
629 874 return new Response(
630 875 false,
631 876 array(
632 877 'message' => esc_html__( 'Invalid config', 'defender-security' ),
@@ -633,10 +878,13 @@
633 878 )
634 879 );
635 880 }
636 881
637 - $config = get_site_option( $key );
638 - if ( false === $config ) {
882 + $config = $this->local_store->get_one( $key );
883 + if ( null === $config ) {
884 + $config = get_site_option( $key );
885 + }
886 + if ( false === $config || null === $config ) {
639 887 return new Response(
640 888 false,
641 889 array(
642 890 'message' => esc_html__( 'Invalid config', 'defender-security' ),
@@ -643,22 +891,17 @@
643 891 )
644 892 );
645 893 }
646 894
647 - $old_config = $config;
648 - $config['name'] = sanitize_text_field( $name );
649 - $config['description'] = sanitize_textarea_field( $description );
650 -
651 - // Check data has been changed or not.
652 - if (
653 - $old_config['name'] === $config['name'] &&
654 - $old_config['description'] === $config['description']
655 - ) {
656 - // Data is not changed, so not need to run update query.
657 - $option_updated = true;
658 - } else {
895 + $option_updated = $this->local_store->update_metadata( $key, $name, $description, $note_added_time );
896 + if ( ! $option_updated ) {
897 + $old_config = $config;
898 + $config['name'] = sanitize_text_field( $name );
899 + $config['description'] = sanitize_textarea_field( $description );
900 + if ( null !== $note_added_time ) {
901 + $config['note_added_time'] = $note_added_time;
902 + }
659 903 $option_updated = update_site_option( $key, $config );
660 - Config_Hub_Helper::update_on_hub( $config );
661 904 }
662 905
663 906 if ( $option_updated ) {
664 907 return new Response(
@@ -664,14 +907,14 @@
664 907 return new Response(
665 908 true,
666 909 array(
667 910 'message' => sprintf(
668 - /* translators: %s: Config name. */
911 + /* translators: %s: Config name. */
669 912 esc_html__( '%s config saved successfully.', 'defender-security' ),
670 913 '<strong>' . $name . '</strong>'
671 914 ),
672 915 'auto_close' => true,
673 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
916 + 'configs' => $this->local_store->get_frontend_configs(),
674 917 )
675 918 );
676 919 } else {
677 920 return new Response(
@@ -686,8 +929,100 @@
686 929 }
687 930 }
688 931
689 932 /**
933 + * Sync config metadata list.
934 + *
935 + * This method updates name/description/note timestamp for provided configs
936 + * without changing config payload values.
937 + *
938 + * @param Request $request The request object containing configs metadata list.
939 + *
940 + * @return Response
941 + * @defender_route
942 + */
943 + public function sync_configs_metadata( Request $request ): Response {
944 + // Defense in depth: central routing already validates nonce and access.
945 + if ( ! $this->check_permission() ) {
946 + return new Response(
947 + false,
948 + array(
949 + 'message' => esc_html__( 'You shall not pass.', 'defender-security' ),
950 + )
951 + );
952 + }
953 +
954 + $data = $request->get_data();
955 + if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
956 + return new Response(
957 + false,
958 + array(
959 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
960 + )
961 + );
962 + }
963 +
964 + $stored_configs = $this->local_store->get_all();
965 + foreach ( $data['configs'] as $item ) {
966 + if ( ! is_array( $item ) ) {
967 + return new Response(
968 + false,
969 + array(
970 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
971 + )
972 + );
973 + }
974 +
975 + $key = '';
976 + if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
977 + $key = trim( $item['key'] );
978 + } elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
979 + $key = trim( $item['id'] );
980 + }
981 + $key = $this->resolve_config_key( $key, $stored_configs );
982 +
983 + if (
984 + '' === $key ||
985 + 0 !== strpos( $key, 'wp_defender_config' ) ||
986 + ! isset( $stored_configs[ $key ] )
987 + ) {
988 + return new Response(
989 + false,
990 + array(
991 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
992 + )
993 + );
994 + }
995 +
996 + $config = $this->local_store->get_one( $key );
997 + if ( null === $config ) {
998 + continue;
999 + }
1000 +
1001 + $name = isset( $item['name'] ) ? (string) $item['name'] : null;
1002 + $description = null;
1003 + $note_added_time = null;
1004 + if ( isset( $item['description'] ) && is_string( $item['description'] ) ) {
1005 + $description = $item['description'];
1006 + } elseif ( isset( $item['desc'] ) && is_string( $item['desc'] ) ) {
1007 + $description = $item['desc'];
1008 + }
1009 + if ( isset( $item['note_added_time'] ) && is_scalar( $item['note_added_time'] ) ) {
1010 + $note_added_time = (string) $item['note_added_time'];
1011 + }
1012 + $this->local_store->update_metadata( $key, $name, $description, $note_added_time );
1013 + }
1014 +
1015 + return new Response(
1016 + true,
1017 + array(
1018 + 'message' => esc_html__( 'Configs synced successfully.', 'defender-security' ),
1019 + 'configs' => $this->local_store->get_frontend_configs(),
1020 + )
1021 + );
1022 + }
1023 +
1024 + /**
690 1025 * Delete config.
691 1026 *
692 1027 * @param Request $request The request object containing config key.
693 1028 *
@@ -696,9 +1031,10 @@
696 1031 */
697 1032 public function delete_config( Request $request ) {
698 1033 $data = $request->get_data();
699 1034 $key = trim( $data['key'] );
700 - if ( empty( $key ) ) {
1035 + $key = $this->resolve_config_key( $key, $this->local_store->get_all() );
1036 + if ( '' === $key ) {
701 1037 return new Response(
702 1038 false,
703 1039 array(
704 1040 'message' => esc_html__( 'Invalid config', 'defender-security' ),
@@ -705,10 +1041,13 @@
705 1041 )
706 1042 );
707 1043 }
708 1044
709 - $config = get_site_option( $key );
710 - if ( isset( $config['is_removable'] ) && ! $config['is_removable'] ) {
1045 + $config = $this->local_store->get_one( $key );
1046 + if ( null === $config ) {
1047 + $config = get_site_option( $key );
1048 + }
1049 + if ( isset( $config['is_removable'] ) && false === (bool) $config['is_removable'] ) {
711 1050 return new Response(
712 1051 false,
713 1052 array(
714 1053 'message' => esc_html__( 'Config can\'t be removed', 'defender-security' ),
@@ -715,16 +1054,24 @@
715 1054 )
716 1055 );
717 1056 }
718 1057
719 - // Remove from HUB.
720 - if ( isset( $config['hub_id'] ) ) {
721 - Config_Hub_Helper::delete_configs_from_hub( (int) $config['hub_id'] );
1058 + if ( $this->local_store->delete_one( $key ) ) {
1059 + return new Response(
1060 + true,
1061 + array(
1062 + 'message' => esc_html__( 'Config removed successfully.', 'defender-security' ),
1063 + 'auto_close' => true,
1064 + 'configs' => $this->local_store->get_frontend_configs(),
1065 + )
1066 + );
722 1067 }
723 1068
724 1069 if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
725 1070 delete_site_option( $key );
1071 + $this->service->remove_index( $key );
726 1072 $this->service->clear_keys();
1073 + delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
727 1074
728 1075 return new Response(
729 1076 true,
730 1077 array(
@@ -729,9 +1076,9 @@
729 1076 true,
730 1077 array(
731 1078 'message' => esc_html__( 'Config removed successfully.', 'defender-security' ),
732 1079 'auto_close' => true,
733 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
1080 + 'configs' => $this->local_store->get_frontend_configs(),
734 1081 )
735 1082 );
736 1083 }
737 1084
@@ -743,8 +1090,85 @@
743 1090 );
744 1091 }
745 1092
746 1093 /**
1094 + * Syncs configs list by keeping only the provided config keys.
1095 + *
1096 + * This method is intentionally separate from `delete_config` for backward compatibility.
1097 + *
1098 + * @param Request $request The request object containing the list of configs to keep.
1099 + *
1100 + * @return Response
1101 + * @defender_route
1102 + */
1103 + public function sync_configs_list( Request $request ): Response {
1104 + // Defense in depth: central routing already enforces nonce and private access checks.
1105 + if ( ! $this->check_permission() ) {
1106 + return new Response(
1107 + false,
1108 + array(
1109 + 'message' => esc_html__( 'You shall not pass.', 'defender-security' ),
1110 + )
1111 + );
1112 + }
1113 +
1114 + $data = $request->get_data();
1115 + if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
1116 + return new Response(
1117 + false,
1118 + array(
1119 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1120 + )
1121 + );
1122 + }
1123 +
1124 + $stored_configs = $this->local_store->get_all();
1125 + $keep_keys = array();
1126 + foreach ( $data['configs'] as $item ) {
1127 + if ( ! is_array( $item ) ) {
1128 + return new Response(
1129 + false,
1130 + array(
1131 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1132 + )
1133 + );
1134 + }
1135 + $key = '';
1136 + if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
1137 + $key = trim( $item['key'] );
1138 + } elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
1139 + $key = trim( $item['id'] );
1140 + }
1141 +
1142 + if (
1143 + '' === $key ||
1144 + 0 !== strpos( $key, 'wp_defender_config' ) ||
1145 + ! isset( $stored_configs[ $key ] )
1146 + ) {
1147 + return new Response(
1148 + false,
1149 + array(
1150 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1151 + )
1152 + );
1153 + }
1154 +
1155 + $keep_keys[ $key ] = true;
1156 + }
1157 +
1158 + $this->local_store->sync_keep_list( array_keys( $keep_keys ) );
1159 +
1160 + return new Response(
1161 + true,
1162 + array(
1163 + 'message' => esc_html__( 'Config list synced successfully.', 'defender-security' ),
1164 + 'auto_close' => true,
1165 + 'configs' => $this->local_store->get_frontend_configs(),
1166 + )
1167 + );
1168 + }
1169 +
1170 + /**
747 1171 * Exports strings.
748 1172 *
749 1173 * @return array An array of strings.
750 1174 */
@@ -752,48 +1176,53 @@
752 1176 return array();
753 1177 }
754 1178
755 1179 /**
756 - * Update config status and return them.
1180 + * Resolves a config key from an incoming identifier.
757 1181 *
758 - * @return array
1182 + * Supports direct option keys and numeric Hub IDs.
1183 + *
1184 + * @param string $incoming_key Incoming key/id.
1185 + * @param array|null $stored_configs Optional preloaded configs map.
1186 + *
1187 + * @return string
759 1188 */
760 - private function get_configs_and_update_status() {
761 - $configs = Config_Hub_Helper::get_configs( $this->service );
762 - $is_remove = Config_Hub_Helper::check_remove_active_flag();
1189 + private function resolve_config_key( string $incoming_key, ?array $stored_configs = null ): string {
1190 + $key = trim( $incoming_key );
1191 + if ( '' === $key ) {
1192 + return '';
1193 + }
763 1194
764 - // Loop to update strings of configs.
765 - foreach ( $configs as $key => &$config ) {
766 - if ( ! is_array( $config ) ) {
1195 + if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
1196 + return $key;
1197 + }
1198 +
1199 + $configs = is_array( $stored_configs ) ? $stored_configs : $this->local_store->get_all();
1200 + foreach ( $configs as $config_key => $config ) {
1201 + if ( ! is_array( $config ) || ! isset( $config['hub_id'] ) ) {
767 1202 continue;
768 1203 }
769 -
770 - $config['strings'] = $this->service->import_module_strings( $config );
771 -
772 - if ( $is_remove ) {
773 - $config['is_active'] = false;
1204 + if ( (string) $config['hub_id'] === $key ) {
1205 + return (string) $config_key;
774 1206 }
775 -
776 - // Update config data.
777 - update_site_option( $key, $config );
778 1207 }
779 1208
780 - return $configs;
1209 + return $key;
781 1210 }
782 1211
783 1212 /**
784 - * Generates an error message for when there is an issue applying some tweaks from the Recommendations tab.
1213 + * Generates an error message for when there is an issue applying some tweaks from the Hardening tab.
785 1214 *
786 1215 * @return Response The response object containing the error message and fresh frontend configurations.
787 1216 */
788 1217 private function apply_config_recommendations_error_message(): Response {
789 1218 $message = sprintf(
790 - /* translators: 1: Recommendations tab, 2: wp-config.php file, 3: Documentation. */
1219 + /* translators: 1: Hardening tab, 2: wp-config.php file, 3: Documentation. */
791 1220 esc_html__(
792 1221 '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.',
793 1222 'defender-security'
794 1223 ),
795 - '<strong>' . esc_html__( 'Recommendations', 'defender-security' ) . '</strong>',
1224 + '<strong>' . esc_html__( 'Hardening', 'defender-security' ) . '</strong>',
796 1225 '<strong>' . esc_html__( 'wp-config.php', 'defender-security' ) . '</strong>',
797 1226 '<a href="' . WP_DEFENDER_DOCS_LINK . '#manually-applying-recommendations" target="_blank">' . esc_html__( 'documentation', 'defender-security' ) . '</a>'
798 1227 );
799 1228
@@ -800,25 +1229,77 @@
800 1229 return new Response(
801 1230 false,
802 1231 array(
803 1232 'message' => $message,
804 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
1233 + 'configs' => $this->local_store->get_frontend_configs(),
805 1234 )
806 1235 );
807 1236 }
808 1237
809 1238 /**
810 - * Check if the logger cron is scheduled to run.
1239 + * Prepares a config payload for download.
811 1240 *
812 - * @return void
1241 + * @param string $key Config option key.
1242 + *
1243 + * @return array|null
813 1244 */
814 - public function check_cron_schedule(): void {
815 - if ( ! wp_next_scheduled( 'wp_defender_clear_logs' ) ) {
816 - wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wp_defender_clear_logs' );
1245 + private function prepare_config_for_download( string $key ): ?array {
1246 + $config = get_site_option( $key );
1247 + if ( false === $config || ! is_array( $config ) ) {
1248 + return null;
817 1249 }
1250 +
1251 + $sample = $this->service->gather_data();
1252 + foreach ( $sample as $slug => $data ) {
1253 + foreach ( $data as $k => $val ) {
1254 + if ( ! isset( $config['configs'][ $slug ][ $k ] ) ) {
1255 + $config['configs'][ $slug ][ $k ] = null;
1256 + }
1257 + }
1258 + }
1259 +
1260 + return $config;
818 1261 }
819 1262
820 1263 /**
1264 + * Creates a unique JSON filename for a zip entry.
1265 + *
1266 + * @param string $base_name Base file name without extension.
1267 + * @param string $key Config option key.
1268 + * @param \ZipArchive $zip Active zip archive.
1269 + *
1270 + * @return string
1271 + */
1272 + private function make_unique_zip_entry_name( string $base_name, string $key, \ZipArchive $zip ): string {
1273 + $base_name = trim( $base_name );
1274 + if ( '' === $base_name ) {
1275 + $base_name = 'wp-defender-config';
1276 + }
1277 +
1278 + $try_name = $base_name . '.json';
1279 + if ( false === $zip->locateName( $try_name ) ) {
1280 + return $try_name;
1281 + }
1282 +
1283 + $key_suffix = sanitize_file_name( str_replace( 'wp_defender_config_', '', $key ) );
1284 + if ( '' !== $key_suffix ) {
1285 + $try_name = $base_name . '-' . $key_suffix . '.json';
1286 + if ( false === $zip->locateName( $try_name ) ) {
1287 + return $try_name;
1288 + }
1289 + }
1290 +
1291 + $index = 2;
1292 + while ( true ) {
1293 + $try_name = $base_name . '-' . $index . '.json';
1294 + if ( false === $zip->locateName( $try_name ) ) {
1295 + return $try_name;
1296 + }
1297 + ++$index;
1298 + }
1299 + }
1300 +
1301 + /**
821 1302 * Clear out lines that are older than 30 days.
822 1303 *
823 1304 * @return void
824 1305 */
@@ -833,9 +1314,9 @@
833 1314 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
834 1315 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
835 1316 ARRAY_A
836 1317 );
837 - while ( ! empty( $blogs ) && is_array( $blogs ) ) {
1318 + while ( is_array( $blogs ) && array() !== $blogs ) {
838 1319 foreach ( $blogs as $blog ) {
839 1320 switch_to_blog( $blog['blog_id'] );
840 1321
841 1322 $this->clear_logs_from_files( $time_limit );
@@ -940,8 +1421,8 @@
940 1421 '' !== $from
941 1422 && isset( $new_settings['usage_tracking'], $old_settings['usage_tracking'] )
942 1423 && $new_settings['usage_tracking'] !== $old_settings['usage_tracking']
943 1424 ) {
944 - $this->track_opt_toggle( ! empty( $new_settings['usage_tracking'] ), $from );
1425 + $this->track_opt_toggle( (bool) $new_settings['usage_tracking'], $from );
945 1426 }
946 1427 }
947 1428 }