PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.2
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / admin / class-admin-ajax.php

class-admin-ajax.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.2, at admin/class-admin-ajax.php

1,581 lines 56.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin AJAX Trait
4 *
5 * AJAX handlers and helper methods for Vigilante_Admin class
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Trait for AJAX handlers
17 * To be used in Vigilante_Admin class
18 */
19 trait Vigilante_Admin_Ajax {
20
21 /**
22 * AJAX: Apply preset
23 */
24 // ajax_apply_preset() is defined in class-admin.php directly (not in this trait)
25
26 /**
27 * AJAX: Clear lockouts
28 */
29 public function ajax_clear_lockouts() {
30 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
31
32 if ( ! current_user_can( 'manage_options' ) ) {
33 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
34 }
35
36 $ip = isset( $_POST['ip'] ) ? sanitize_text_field( wp_unslash( $_POST['ip'] ) ) : '';
37
38 if ( ! empty( $ip ) ) {
39 // Clear specific IP
40 $result = $this->database->clear_lockout( $ip );
41 } else {
42 // Clear all
43 $result = $this->database->clear_all_lockouts();
44 }
45
46 if ( $result ) {
47 wp_send_json_success( __( 'Lockouts cleared.', 'vigilante' ) );
48 } else {
49 wp_send_json_error( __( 'Failed to clear lockouts.', 'vigilante' ) );
50 }
51 }
52
53 /**
54 * AJAX: Clear logs
55 */
56 public function ajax_clear_logs() {
57 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
58
59 if ( ! current_user_can( 'manage_options' ) ) {
60 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
61 }
62
63 $result = $this->activity_log->clear_all_logs();
64
65 if ( $result ) {
66 wp_send_json_success( __( 'Logs cleared.', 'vigilante' ) );
67 } else {
68 wp_send_json_error( __( 'Failed to clear logs.', 'vigilante' ) );
69 }
70 }
71
72 /**
73 * AJAX: Run file integrity scan
74 */
75 public function ajax_run_scan() {
76 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
77
78 if ( ! current_user_can( 'manage_options' ) ) {
79 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
80 }
81
82 try {
83 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
84 require_once VIGILANTE_PLUGIN_DIR . 'includes/class-file-integrity.php';
85 }
86
87 if ( ! $this->settings ) {
88 wp_send_json_error( 'Settings not initialized' );
89 }
90
91 $activity_log = isset( $this->activity_log ) ? $this->activity_log : null;
92 $database = isset( $this->database ) ? $this->database : null;
93
94 $file_integrity = new Vigilante_File_Integrity( $this->settings, $database, $activity_log );
95 $results = $file_integrity->run_scan();
96
97 // Save results for display
98 update_option( 'vigilante_last_integrity_scan', time() );
99 update_option( 'vigilante_last_integrity_results', $results );
100
101 wp_send_json_success( array(
102 'message' => __( 'Scan completed.', 'vigilante' ),
103 'results' => $results,
104 ) );
105 } catch ( Exception $e ) {
106 wp_send_json_error( 'Exception: ' . $e->getMessage() );
107 } catch ( Error $e ) {
108 wp_send_json_error( 'PHP Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
109 }
110 }
111
112 /**
113 * AJAX: Approve a critical config file modification
114 *
115 * Updates the baseline hash for a single critical file (wp-config.php
116 * or .htaccess), accepting the current content as legitimate.
117 */
118 public function ajax_approve_critical_file() {
119 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
120
121 if ( ! current_user_can( 'manage_options' ) ) {
122 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
123 }
124
125 // The request carries an opaque key instead of the file name: hosting
126 // WAFs (e.g. ModSecurity with OWASP CRS rule 930130) reject any POST
127 // whose arguments contain the literal "wp-config.php", which made
128 // this Approve button fail with a generic AJAX error behind such
129 // firewalls. The server-side map below is the real security gate.
130 $file_keys = array(
131 'cfg' => 'wp-config.php',
132 'hta' => '.htaccess',
133 );
134 $file_key = isset( $_POST['file_key'] ) ? sanitize_key( $_POST['file_key'] ) : '';
135 $file = isset( $file_keys[ $file_key ] ) ? $file_keys[ $file_key ] : '';
136 $allowed = array( 'wp-config.php', '.htaccess' );
137 if ( ! in_array( $file, $allowed, true ) ) {
138 wp_send_json_error( __( 'Invalid file.', 'vigilante' ) );
139 }
140
141 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
142 require_once VIGILANTE_PLUGIN_DIR . 'includes/class-file-integrity.php';
143 }
144
145 $activity_log = isset( $this->activity_log ) ? $this->activity_log : null;
146 $database = isset( $this->database ) ? $this->database : null;
147
148 $fi = new Vigilante_File_Integrity( $this->settings, $database, $activity_log );
149 $result = $fi->update_critical_file_baseline( $file );
150
151 if ( $result ) {
152 // Log the approval in the activity log
153 if ( $activity_log ) {
154 $activity_log->log(
155 'file',
156 'critical_file_approved',
157 sprintf(
158 /* translators: %s: file name */
159 __( 'Critical config file modification approved: %s', 'vigilante' ),
160 $file
161 ),
162 array( 'file' => $file ),
163 'info'
164 );
165 }
166
167 // Update stored scan results to remove the approved file
168 $last_results = get_option( 'vigilante_last_integrity_results', array() );
169 if ( ! empty( $last_results['modified'] ) ) {
170 $last_results['modified'] = array_values(
171 array_filter(
172 $last_results['modified'],
173 function ( $item ) use ( $file ) {
174 return ! ( is_array( $item ) && isset( $item['file'] ) && $item['file'] === $file );
175 }
176 )
177 );
178 update_option( 'vigilante_last_integrity_results', $last_results );
179 }
180
181 wp_send_json_success( array(
182 'message' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
183 'file' => $file,
184 ) );
185 } else {
186 wp_send_json_error( __( 'Failed to update baseline.', 'vigilante' ) );
187 }
188 }
189
190 /**
191 * AJAX: Get activity logs
192 */
193 public function ajax_get_logs() {
194 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
195
196 if ( ! current_user_can( 'manage_options' ) ) {
197 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
198 }
199
200 $per_page = isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 50;
201 // Limit max to prevent memory issues
202 $per_page = min( $per_page, 10000 );
203
204 $args = array(
205 'per_page' => $per_page,
206 'page' => isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1,
207 );
208
209 if ( ! empty( $_POST['type'] ) ) {
210 $args['event_type'] = sanitize_key( $_POST['type'] );
211 }
212
213 if ( ! empty( $_POST['severity'] ) ) {
214 $args['severity'] = sanitize_key( $_POST['severity'] );
215 }
216
217 if ( ! empty( $_POST['request_method'] ) ) {
218 $args['request_method'] = sanitize_text_field( wp_unslash( $_POST['request_method'] ) );
219 }
220
221 if ( ! empty( $_POST['search'] ) ) {
222 $args['search'] = sanitize_text_field( wp_unslash( $_POST['search'] ) );
223 }
224
225 if ( ! $this->activity_log ) {
226 wp_send_json_error( 'Activity log not initialized' );
227 }
228
229 $logs = $this->activity_log->get_logs( $args );
230 $total = $this->activity_log->get_logs_count( $args );
231
232 // Attach firewall list flags so the popup can show "In whitelist"/"In blacklist"
233 // states when users paginate or filter without reloading the page.
234 $firewall_options = $this->settings->get_section( 'firewall' );
235 $ip_whitelist = $firewall_options['ip_whitelist'] ?? array();
236 $ip_blacklist = $firewall_options['ip_blacklist'] ?? array();
237 $ua_whitelist = $firewall_options['ua_whitelist'] ?? array();
238 $ua_blacklist = $firewall_options['ua_blacklist'] ?? array();
239
240 foreach ( $logs as $log ) {
241 $ip_val = (string) ( $log->ip_address ?? '' );
242 $ua_val = (string) ( $log->user_agent ?? '' );
243 $log->is_ip_whitelisted = ( '' !== $ip_val && in_array( $ip_val, $ip_whitelist, true ) );
244 $log->is_ip_blacklisted = ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) );
245 $log->is_ua_whitelisted = ( '' !== $ua_val && in_array( $ua_val, $ua_whitelist, true ) );
246 $log->is_ua_blacklisted = ( '' !== $ua_val && in_array( $ua_val, $ua_blacklist, true ) );
247 $log->request_uri = Vigilante_Activity_Log::extract_request_uri( $log->extra_data ?? '' );
248 }
249
250 wp_send_json_success( array(
251 'logs' => $logs,
252 'total' => $total,
253 ) );
254 }
255
256 /**
257 * AJAX: Add IP or User-Agent to firewall whitelist/blacklist
258 */
259 public function ajax_add_to_firewall_list() {
260 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
261
262 if ( ! current_user_can( 'manage_options' ) ) {
263 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
264 }
265
266 $value = isset( $_POST['value'] ) ? sanitize_text_field( wp_unslash( $_POST['value'] ) ) : '';
267 $list_type = isset( $_POST['list_type'] ) ? sanitize_key( $_POST['list_type'] ) : '';
268 $item_type = isset( $_POST['item_type'] ) ? sanitize_key( $_POST['item_type'] ) : '';
269
270 if ( empty( $value ) || empty( $list_type ) || empty( $item_type ) ) {
271 wp_send_json_error( __( 'Missing parameters.', 'vigilante' ) );
272 }
273
274 // Validate list_type and item_type
275 $valid_lists = array( 'whitelist', 'blacklist' );
276 $valid_items = array( 'ip', 'ua' );
277
278 if ( ! in_array( $list_type, $valid_lists, true ) || ! in_array( $item_type, $valid_items, true ) ) {
279 wp_send_json_error( __( 'Invalid parameters.', 'vigilante' ) );
280 }
281
282 // Validate IP if item_type is ip
283 if ( 'ip' === $item_type && ! filter_var( $value, FILTER_VALIDATE_IP ) ) {
284 wp_send_json_error( __( 'Invalid IP address.', 'vigilante' ) );
285 }
286
287 $option_key = $item_type . '_' . $list_type; // ip_whitelist, ip_blacklist, ua_whitelist, ua_blacklist
288 $options = $this->settings->get_section( 'firewall' );
289 $list = isset( $options[ $option_key ] ) ? (array) $options[ $option_key ] : array();
290
291 // Check if already in list
292 if ( in_array( $value, $list, true ) ) {
293 wp_send_json_error(
294 sprintf(
295 /* translators: %s: the value being added */
296 __( '%s is already in this list.', 'vigilante' ),
297 $value
298 )
299 );
300 }
301
302 // Add to list
303 $list[] = $value;
304
305 // Check opposite list and remove if present
306 $opposite_type = ( 'whitelist' === $list_type ) ? 'blacklist' : 'whitelist';
307 $opposite_key = $item_type . '_' . $opposite_type;
308 $removed_from_opposite = false;
309
310 // Save
311 $all_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
312 if ( ! isset( $all_options['firewall'] ) ) {
313 $all_options['firewall'] = array();
314 }
315 $all_options['firewall'][ $option_key ] = $list;
316
317 // Remove from opposite list if found
318 if ( ! empty( $all_options['firewall'][ $opposite_key ] ) && is_array( $all_options['firewall'][ $opposite_key ] ) ) {
319 $opposite_list = $all_options['firewall'][ $opposite_key ];
320 $filtered = array_values( array_filter( $opposite_list, function( $item ) use ( $value ) {
321 return $item !== $value;
322 } ) );
323
324 if ( count( $filtered ) < count( $opposite_list ) ) {
325 $all_options['firewall'][ $opposite_key ] = $filtered;
326 $removed_from_opposite = true;
327 }
328 }
329
330 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
331 update_option( Vigilante_Settings::OPTION_NAME, $all_options );
332 $this->settings->clear_cache();
333
334 // Whitelist entries feed the .htaccess exception conditions (Server
335 // Protection), so the block must be rewritten with the updated list.
336 // Saving from the Firewall tab does this via apply_section_changes();
337 // this handler writes the option directly, so it regenerates here.
338 if ( 'whitelist' === $list_type ) {
339 $fresh_settings = new Vigilante_Settings();
340 $sh = $fresh_settings->get_section( 'security_headers' );
341
342 $needs_htaccess_block = ! empty( $all_options['modules']['firewall'] )
343 || ! empty( $sh['hide_server_signature'] )
344 || ! empty( $sh['remove_fingerprinting_headers'] );
345
346 if ( $needs_htaccess_block ) {
347 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-protection.php';
348 $htaccess = new Vigilante_Htaccess_Protection( $fresh_settings );
349 $htaccess->apply_rules();
350 }
351 }
352
353 $list_label = ( 'whitelist' === $list_type )
354 ? __( 'whitelist', 'vigilante' )
355 : __( 'blacklist', 'vigilante' );
356
357 $message = sprintf(
358 /* translators: 1: the value added, 2: list name */
359 __( '%1$s added to %2$s.', 'vigilante' ),
360 $value,
361 $list_label
362 );
363
364 if ( $removed_from_opposite ) {
365 $opposite_label = ( 'whitelist' === $opposite_type )
366 ? __( 'whitelist', 'vigilante' )
367 : __( 'blacklist', 'vigilante' );
368
369 $message .= ' ' . sprintf(
370 /* translators: %s: opposite list name */
371 __( 'Automatically removed from %s.', 'vigilante' ),
372 $opposite_label
373 );
374 }
375
376 wp_send_json_success( $message );
377 }
378
379 /**
380 * AJAX: Test security headers
381 */
382 public function ajax_test_headers() {
383 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
384
385 if ( ! current_user_can( 'manage_options' ) ) {
386 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
387 }
388
389 $security_headers = new Vigilante_Security_Headers( $this->settings );
390 $results = $security_headers->test_headers();
391
392 wp_send_json_success( $results );
393 }
394
395 /**
396 * Sanitize activity log data
397 *
398 * @param array $data Data to sanitize.
399 * @return array
400 */
401 /**
402 * Sanitize IP list
403 *
404 * @param string|array $ips IPs as string (newline separated) or array.
405 * @return array
406 */
407 private function sanitize_ip_list( $ips ) {
408 if ( is_string( $ips ) ) {
409 $ips = array_filter( array_map( 'trim', explode( "\n", $ips ) ) );
410 }
411
412 $sanitized = array();
413
414 foreach ( (array) $ips as $ip ) {
415 $ip = trim( $ip );
416 // Validate IP or CIDR
417 if ( filter_var( $ip, FILTER_VALIDATE_IP ) || preg_match( '/^[\d\.]+\/\d{1,2}$/', $ip ) ) {
418 $sanitized[] = $ip;
419 }
420 }
421
422 return $sanitized;
423 }
424
425 /**
426 * Sanitize User-Agent list
427 *
428 * @param string|array $uas User-Agent strings (newline-separated or array).
429 * @return array
430 */
431 private function sanitize_ua_list( $uas ) {
432 if ( is_string( $uas ) ) {
433 $uas = array_filter( array_map( 'trim', explode( "\n", $uas ) ) );
434 }
435
436 $sanitized = array();
437
438 foreach ( (array) $uas as $ua ) {
439 $ua = sanitize_text_field( trim( $ua ) );
440 if ( ! empty( $ua ) ) {
441 $sanitized[] = $ua;
442 }
443 }
444
445 return array_unique( $sanitized );
446 }
447
448 /**
449 * AJAX: Search users for 2FA exclusion
450 */
451 public function ajax_search_users_2fa() {
452 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
453
454 if ( ! current_user_can( 'manage_options' ) ) {
455 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
456 }
457
458 $query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : '';
459 $exclude = isset( $_POST['exclude'] ) ? array_map( 'absint', (array) $_POST['exclude'] ) : array();
460
461 if ( strlen( $query ) < 2 ) {
462 wp_send_json_error( __( 'Query too short.', 'vigilante' ) );
463 }
464
465 // Search users by login, email, or display name
466 $users = get_users( array(
467 'search' => '*' . $query . '*',
468 'search_columns' => array( 'user_login', 'user_email', 'display_name' ),
469 'exclude' => $exclude,
470 'number' => 10,
471 'orderby' => 'display_name',
472 'order' => 'ASC',
473 ) );
474
475 $results = array();
476
477 foreach ( $users as $user ) {
478 $results[] = array(
479 'ID' => $user->ID,
480 'user_login' => $user->user_login,
481 'user_email' => $user->user_email,
482 'display_name' => $user->display_name,
483 'avatar' => get_avatar_url( $user->ID, array( 'size' => 32 ) ),
484 );
485 }
486
487 wp_send_json_success( $results );
488 }
489
490 /**
491 * AJAX: Send 2FA activation notification
492 */
493 public function ajax_send_2fa_notification() {
494 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
495
496 if ( ! current_user_can( 'manage_options' ) ) {
497 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
498 }
499
500 $mode = isset( $_POST['mode'] ) ? sanitize_key( $_POST['mode'] ) : 'all';
501 $only_new = 'new' === $mode;
502
503 // Read settings to determine active 2FA method
504 $login_security = $this->settings->get_section( 'login_security' );
505 $two_factor = isset( $login_security['two_factor'] ) ? $login_security['two_factor'] : array();
506 $method = isset( $two_factor['method'] ) ? $two_factor['method'] : 'email';
507
508 if ( 'totp' === $method ) {
509 // TOTP method: use TOTP class for styled activation emails
510 if ( ! class_exists( 'Vigilante_Two_Factor_TOTP' ) ) {
511 require_once VIGILANTE_INCLUDES_DIR . 'class-two-factor-totp.php';
512 }
513
514 $totp = new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log );
515 $roles = isset( $two_factor['enforced_roles'] ) ? $two_factor['enforced_roles'] : array( 'administrator' );
516 $excluded = isset( $two_factor['excluded_users'] ) ? array_map( 'absint', $two_factor['excluded_users'] ) : array();
517 $site_name = get_bloginfo( 'name' );
518 $from_name = ! empty( $two_factor['email_from_name'] ) ? $two_factor['email_from_name'] : $site_name;
519
520 if ( empty( $roles ) ) {
521 $roles = array( 'administrator' );
522 }
523
524 $args = array( 'role__in' => $roles );
525 if ( ! empty( $excluded ) ) {
526 // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Small excluded users list from settings.
527 $args['exclude'] = $excluded;
528 }
529 $users = get_users( $args );
530
531 $sent = 0;
532 $skipped = 0;
533 $failed = 0;
534
535 foreach ( $users as $user ) {
536 // Skip users who already have TOTP configured (unless sending to all)
537 if ( $only_new ) {
538 $totp_data = $this->database->get_totp_data( $user->ID );
539 if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
540 $skipped++;
541 continue;
542 }
543 if ( $this->database->user_was_2fa_notified( $user->ID ) ) {
544 $skipped++;
545 continue;
546 }
547 }
548
549 $email_sent = $totp->send_activation_email( $user, $site_name, $from_name );
550
551 if ( $email_sent ) {
552 $this->database->mark_2fa_notified( $user->ID );
553 $sent++;
554 } else {
555 $failed++;
556 }
557 }
558
559 wp_send_json_success( array(
560 'sent' => $sent,
561 'skipped' => $skipped,
562 'failed' => $failed,
563 ) );
564 } else {
565 // Email method: use email 2FA class
566 if ( ! class_exists( 'Vigilante_Two_Factor_Email' ) ) {
567 require_once VIGILANTE_PLUGIN_DIR . 'includes/class-two-factor-email.php';
568 }
569
570 $two_factor_email = new Vigilante_Two_Factor_Email( $this->settings, $this->database, $this->activity_log );
571 $result = $two_factor_email->send_activation_notifications( $only_new );
572
573 wp_send_json_success( $result );
574 }
575 }
576
577 /**
578 * AJAX: Search users with TOTP configured (for admin reset)
579 */
580 public function ajax_search_totp_users() {
581 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
582
583 if ( ! current_user_can( 'manage_options' ) ) {
584 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
585 }
586
587 $query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : '';
588
589 if ( strlen( $query ) < 2 ) {
590 wp_send_json_error( __( 'Query too short.', 'vigilante' ) );
591 }
592
593 $results = $this->database->search_totp_users( $query, 10 );
594
595 $users = array();
596 foreach ( $results as $row ) {
597 $avatar = get_avatar_url( $row['user_id'], array( 'size' => 32 ) );
598 $users[] = array(
599 'ID' => absint( $row['user_id'] ),
600 'display_name' => $row['display_name'],
601 'user_email' => $row['user_email'],
602 'configured_at' => $row['configured_at'],
603 'last_used_at' => $row['last_used_at'],
604 'avatar' => $avatar,
605 );
606 }
607
608 wp_send_json_success( $users );
609 }
610
611 /**
612 * AJAX: Reset TOTP for selected users (admin action)
613 */
614 public function ajax_reset_totp_users() {
615 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
616
617 if ( ! current_user_can( 'manage_options' ) ) {
618 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
619 }
620
621 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized with array_map
622 $user_ids = isset( $_POST['user_ids'] ) ? array_map( 'absint', (array) wp_unslash( $_POST['user_ids'] ) ) : array();
623
624 if ( empty( $user_ids ) ) {
625 wp_send_json_error( __( 'No users selected.', 'vigilante' ) );
626 }
627
628 if ( ! class_exists( 'Vigilante_Two_Factor_TOTP' ) ) {
629 require_once VIGILANTE_INCLUDES_DIR . 'class-two-factor-totp.php';
630 }
631
632 $totp = new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log );
633 $count = 0;
634 $skipped = 0;
635
636 foreach ( $user_ids as $uid ) {
637 if ( $uid < 1 ) {
638 continue;
639 }
640
641 // Same gate as the rest of the TOTP handlers: resetting somebody's
642 // second factor is editing their account, so ask for edit_user
643 // rather than for manage_options, which on a network is per site.
644 if ( ! current_user_can( 'edit_user', $uid ) ) {
645 $skipped++;
646 continue;
647 }
648
649 $totp->reset_user_totp( $uid );
650 $count++;
651 }
652
653 $message = sprintf(
654 /* translators: %d: Number of users reset */
655 _n( 'TOTP reset for %d user.', 'TOTP reset for %d users.', $count, 'vigilante' ),
656 $count
657 );
658
659 if ( $skipped > 0 ) {
660 $message .= ' ' . sprintf(
661 /* translators: %d: Number of users skipped because the current user cannot edit them */
662 __( '%d skipped: you cannot edit those users.', 'vigilante' ),
663 $skipped
664 );
665 }
666
667 wp_send_json_success( array(
668 'message' => $message,
669 'count' => $count,
670 ) );
671 }
672
673 /**
674 * AJAX: Get TOTP setup data (secret + QR) for user profile
675 */
676 public function ajax_totp_get_setup() {
677 check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
678
679 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
680
681 // Fallback to current user if user_id is 0
682 if ( 0 === $user_id ) {
683 $user_id = get_current_user_id();
684 }
685
686 if ( 0 === $user_id ) {
687 wp_send_json_error( __( 'Invalid user.', 'vigilante' ) );
688 }
689
690 // Permission check: own profile, or a user this one may actually edit.
691 // manage_options is held by every subsite administrator on a network.
692 if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
693 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
694 }
695
696 if ( ! class_exists( 'Vigilante_Two_Factor_TOTP' ) ) {
697 wp_send_json_error( __( 'TOTP module not available.', 'vigilante' ) );
698 }
699
700 $totp = new Vigilante_Two_Factor_TOTP( $this->settings, $this->database, $this->activity_log );
701 $data = $totp->get_setup_data( $user_id );
702
703 if ( empty( $data ) ) {
704 wp_send_json_error( __( 'Could not generate setup data. User not found.', 'vigilante' ) );
705 }
706
707 wp_send_json_success( $data );
708 }
709
710 /**
711 * AJAX: Send login URL notification to users with admin access
712 */
713 public function ajax_notify_login_url() {
714 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
715
716 if ( ! current_user_can( 'manage_options' ) ) {
717 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
718 }
719
720 $login_options = $this->settings->get_section( 'login_security' );
721 $custom_url = ! empty( $login_options['custom_login_url'] ) ? sanitize_title( $login_options['custom_login_url'] ) : '';
722
723 if ( empty( $custom_url ) ) {
724 wp_send_json_error( __( 'No custom login URL configured.', 'vigilante' ) );
725 }
726
727 $login_url = home_url( $custom_url . '/' );
728 $site_name = get_bloginfo( 'name' );
729
730 // Roles that can access wp-admin
731 $admin_roles = array( 'administrator', 'editor', 'author', 'contributor' );
732
733 $users = get_users( array(
734 'role__in' => $admin_roles,
735 ) );
736
737 if ( empty( $users ) ) {
738 wp_send_json_error( __( 'No users found.', 'vigilante' ) );
739 }
740
741 $subject = sprintf(
742 /* translators: %s: Site name */
743 __( '[%s] Your login URL has changed', 'vigilante' ),
744 $site_name
745 );
746
747 // Build email body using template
748 $body = Vigilante_Email_Template::p( __( 'The login URL for the admin area has been changed. Please save the new URL below and use it from now on.', 'vigilante' ) );
749 $body .= Vigilante_Email_Template::url_box( $login_url, __( 'Your new login URL:', 'vigilante' ) );
750 $body .= Vigilante_Email_Template::alert_box( __( 'The old login address (wp-login.php) will no longer work.', 'vigilante' ) );
751 $body .= Vigilante_Email_Template::button( $login_url, __( 'Go to login', 'vigilante' ) );
752
753 $sent = 0;
754 $failed = 0;
755
756 foreach ( $users as $user ) {
757 $result = Vigilante_Email_Template::send(
758 $user->user_email,
759 $subject,
760 __( 'Login URL changed', 'vigilante' ),
761 $body
762 );
763 if ( $result ) {
764 $sent++;
765 } else {
766 $failed++;
767 }
768 }
769
770 if ( $this->activity_log ) {
771 $this->activity_log->log(
772 'login',
773 'login_url_notified',
774 sprintf(
775 /* translators: 1: Sent count, 2: Failed count */
776 __( 'Login URL notification sent: %1$d sent, %2$d failed', 'vigilante' ),
777 $sent,
778 $failed
779 )
780 );
781 }
782
783 wp_send_json_success( array(
784 'sent' => $sent,
785 'failed' => $failed,
786 ) );
787 }
788
789 /**
790 * Sanitize 2FA data within login security
791 *
792 * @param array $two_factor 2FA data to sanitize.
793 * @return array
794 */
795 private function sanitize_two_factor_data( $two_factor ) {
796 $valid_methods = array( 'email', 'totp' );
797 $method = isset( $two_factor['method'] ) ? sanitize_key( $two_factor['method'] ) : 'email';
798
799 return array(
800 'enabled' => ! empty( $two_factor['enabled'] ),
801 'method' => in_array( $method, $valid_methods, true ) ? $method : 'email',
802 'enforced_roles' => isset( $two_factor['enforced_roles'] )
803 ? array_map( 'sanitize_key', (array) $two_factor['enforced_roles'] )
804 : array( 'administrator', 'editor' ),
805 'excluded_users' => isset( $two_factor['excluded_users'] )
806 ? array_map( 'absint', (array) $two_factor['excluded_users'] )
807 : array(),
808 'remember_device_days' => isset( $two_factor['remember_device_days'] )
809 ? absint( $two_factor['remember_device_days'] )
810 : 30,
811 'code_expiry_minutes' => isset( $two_factor['code_expiry_minutes'] )
812 ? absint( $two_factor['code_expiry_minutes'] )
813 : 10,
814 'max_attempts' => isset( $two_factor['max_attempts'] )
815 ? absint( $two_factor['max_attempts'] )
816 : 3,
817 'email_from_name' => isset( $two_factor['email_from_name'] )
818 ? sanitize_text_field( $two_factor['email_from_name'] )
819 : '',
820 'notify_on_enable' => ! empty( $two_factor['notify_on_enable'] ),
821 'grace_period_days' => isset( $two_factor['grace_period_days'] )
822 ? min( 30, absint( $two_factor['grace_period_days'] ) )
823 : 3,
824 );
825 }
826
827 /**
828 * AJAX: Search users for password reset
829 */
830 public function ajax_search_users_password_reset() {
831 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
832
833 if ( ! current_user_can( 'manage_options' ) ) {
834 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
835 }
836
837 $query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : '';
838
839 if ( strlen( $query ) < 2 ) {
840 wp_send_json_error( __( 'Query too short.', 'vigilante' ) );
841 }
842
843 // Search users by login, email, or display name
844 $users = get_users( array(
845 'search' => '*' . $query . '*',
846 'search_columns' => array( 'user_login', 'user_email', 'display_name' ),
847 'number' => 10,
848 'orderby' => 'display_name',
849 'order' => 'ASC',
850 ) );
851
852 $results = array();
853
854 foreach ( $users as $user ) {
855 $results[] = array(
856 'ID' => $user->ID,
857 'user_login' => $user->user_login,
858 'user_email' => $user->user_email,
859 'display_name' => $user->display_name,
860 'avatar' => get_avatar_url( $user->ID, array( 'size' => 32 ) ),
861 'roles' => implode( ', ', $user->roles ),
862 );
863 }
864
865 wp_send_json_success( $results );
866 }
867
868 /**
869 * AJAX: Force password reset for specific users
870 * Uses native WordPress password reset flow
871 */
872 public function ajax_force_password_reset() {
873 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
874
875 if ( ! current_user_can( 'manage_options' ) ) {
876 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
877 }
878
879 $user_ids = isset( $_POST['user_ids'] ) ? array_map( 'absint', (array) $_POST['user_ids'] ) : array();
880 $current_user_id = get_current_user_id();
881
882 if ( empty( $user_ids ) ) {
883 wp_send_json_error( __( 'No users selected.', 'vigilante' ) );
884 }
885
886 // Check if current user is resetting themselves
887 $resetting_self = in_array( $current_user_id, $user_ids, true );
888
889 // Create user security instance to use native reset
890 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
891
892 // Perform bulk reset
893 $results = $user_security->force_password_reset_bulk( $user_ids, $current_user_id );
894
895 $message = sprintf(
896 /* translators: %d: Number of users */
897 __( 'Password reset forced for %d user(s). Reset emails sent.', 'vigilante' ),
898 $results['success']
899 );
900
901 if ( $results['failed'] > 0 ) {
902 $message .= ' ' . sprintf(
903 /* translators: %d: Number of failures */
904 __( '%d failed.', 'vigilante' ),
905 $results['failed']
906 );
907 }
908
909 if ( ! empty( $results['skipped'] ) ) {
910 $message .= ' ' . sprintf(
911 /* translators: %d: Number of users skipped because the current user cannot edit them */
912 __( '%d skipped: you cannot edit those users.', 'vigilante' ),
913 $results['skipped']
914 );
915 }
916
917 wp_send_json_success( array(
918 'message' => $message,
919 'results' => $results,
920 'resetting_self' => $resetting_self,
921 ) );
922 }
923
924 /**
925 * AJAX: Force password reset for all users
926 * Uses native WordPress password reset flow
927 */
928 public function ajax_force_password_reset_all() {
929 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
930
931 if ( ! current_user_can( 'manage_options' ) ) {
932 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
933 }
934
935 $include_self = ! empty( $_POST['include_self'] );
936 $current_user_id = get_current_user_id();
937
938 // Create user security instance to use native reset
939 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
940
941 // Perform reset for all users
942 $results = $user_security->force_password_reset_all( $current_user_id, ! $include_self );
943
944 // Log the bulk action
945 if ( $this->activity_log ) {
946 $reset_by_user = get_userdata( $current_user_id );
947 $this->activity_log->log(
948 'user',
949 'force_password_reset_all',
950 sprintf(
951 /* translators: 1: Number of users, 2: Admin username */
952 __( 'Password reset forced for %1$d users by %2$s', 'vigilante' ),
953 $results['success'],
954 $reset_by_user ? $reset_by_user->user_login : __( 'System', 'vigilante' )
955 ),
956 array(
957 'count' => $results['success'],
958 'reset_by' => $current_user_id,
959 'include_self' => $include_self,
960 ),
961 'warning'
962 );
963 }
964
965 $message = sprintf(
966 /* translators: %d: Number of users */
967 __( 'Password reset forced for %d user(s). Reset emails sent.', 'vigilante' ),
968 $results['success']
969 );
970
971 if ( $results['failed'] > 0 ) {
972 $message .= ' ' . sprintf(
973 /* translators: %d: Number of failures */
974 __( '%d failed.', 'vigilante' ),
975 $results['failed']
976 );
977 }
978
979 if ( ! empty( $results['skipped'] ) ) {
980 $message .= ' ' . sprintf(
981 /* translators: %d: Number of users skipped because the current user cannot edit them */
982 __( '%d skipped: you cannot edit those users.', 'vigilante' ),
983 $results['skipped']
984 );
985 }
986
987 wp_send_json_success( array(
988 'message' => $message,
989 'results' => $results,
990 'resetting_self' => $include_self,
991 ) );
992 }
993
994 /**
995 * AJAX: Force password reset by role
996 * Resets passwords for all users with the selected roles
997 */
998 public function ajax_force_password_reset_by_role() {
999 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1000
1001 if ( ! current_user_can( 'manage_options' ) ) {
1002 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1003 }
1004
1005 $roles = isset( $_POST['roles'] ) ? array_map( 'sanitize_key', (array) $_POST['roles'] ) : array();
1006
1007 if ( empty( $roles ) ) {
1008 wp_send_json_error( __( 'No roles selected.', 'vigilante' ) );
1009 }
1010
1011 // Validate that submitted roles actually exist.
1012 $wp_roles = wp_roles();
1013 foreach ( $roles as $role ) {
1014 if ( ! isset( $wp_roles->roles[ $role ] ) ) {
1015 wp_send_json_error(
1016 sprintf(
1017 /* translators: %s: Role slug */
1018 __( 'Invalid role: %s', 'vigilante' ),
1019 $role
1020 )
1021 );
1022 }
1023 }
1024
1025 $include_self = ! empty( $_POST['include_self'] );
1026 $current_user_id = get_current_user_id();
1027
1028 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
1029
1030 $results = $user_security->force_password_reset_by_roles(
1031 $roles,
1032 $current_user_id,
1033 ! $include_self
1034 );
1035
1036 // Log the action.
1037 if ( $this->activity_log ) {
1038 $reset_by_user = get_userdata( $current_user_id );
1039 $role_names = array();
1040
1041 foreach ( $roles as $role ) {
1042 $role_names[] = isset( $wp_roles->roles[ $role ] )
1043 ? translate_user_role( $wp_roles->roles[ $role ]['name'] )
1044 : $role;
1045 }
1046
1047 $this->activity_log->log(
1048 'user',
1049 'force_password_reset_by_role',
1050 sprintf(
1051 /* translators: 1: Number of users, 2: Role names, 3: Admin username */
1052 __( 'Password reset forced for %1$d users (roles: %2$s) by %3$s', 'vigilante' ),
1053 $results['success'],
1054 implode( ', ', $role_names ),
1055 $reset_by_user ? $reset_by_user->user_login : __( 'System', 'vigilante' )
1056 ),
1057 array(
1058 'count' => $results['success'],
1059 'roles' => $roles,
1060 'reset_by' => $current_user_id,
1061 'include_self' => $include_self,
1062 ),
1063 'warning'
1064 );
1065 }
1066
1067 $message = sprintf(
1068 /* translators: %d: Number of users */
1069 __( 'Password reset forced for %d user(s). Reset emails sent.', 'vigilante' ),
1070 $results['success']
1071 );
1072
1073 if ( $results['failed'] > 0 ) {
1074 $message .= ' ' . sprintf(
1075 /* translators: %d: Number of failures */
1076 __( '%d failed.', 'vigilante' ),
1077 $results['failed']
1078 );
1079 }
1080
1081 if ( ! empty( $results['skipped'] ) ) {
1082 $message .= ' ' . sprintf(
1083 /* translators: %d: Number of users skipped because the current user cannot edit them */
1084 __( '%d skipped: you cannot edit those users.', 'vigilante' ),
1085 $results['skipped']
1086 );
1087 }
1088
1089 // Check if current user was included via role membership.
1090 $resetting_self = false;
1091 if ( $include_self ) {
1092 $current_user = wp_get_current_user();
1093 $resetting_self = ! empty( array_intersect( $roles, $current_user->roles ) );
1094 }
1095
1096 wp_send_json_success( array(
1097 'message' => $message,
1098 'results' => $results,
1099 'resetting_self' => $resetting_self,
1100 ) );
1101 }
1102
1103 /**
1104 * AJAX: Approve pending user
1105 */
1106 public function ajax_approve_user() {
1107 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1108
1109 if ( ! current_user_can( 'manage_options' ) ) {
1110 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1111 }
1112
1113 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1114
1115 if ( ! $user_id ) {
1116 wp_send_json_error( __( 'Invalid user ID.', 'vigilante' ) );
1117 }
1118
1119 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
1120 $result = $user_security->approve_user( $user_id, get_current_user_id() );
1121
1122 if ( $result ) {
1123 $user = get_userdata( $user_id );
1124 wp_send_json_success( array(
1125 'message' => sprintf(
1126 /* translators: %s: Username */
1127 __( 'User "%s" has been approved.', 'vigilante' ),
1128 $user ? $user->user_login : $user_id
1129 ),
1130 ) );
1131 } else {
1132 wp_send_json_error( __( 'Failed to approve user.', 'vigilante' ) );
1133 }
1134 }
1135
1136 /**
1137 * AJAX: Reject pending user
1138 */
1139 public function ajax_reject_user() {
1140 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1141
1142 if ( ! current_user_can( 'manage_options' ) ) {
1143 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1144 }
1145
1146 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1147 $reason = isset( $_POST['reason'] ) ? sanitize_text_field( wp_unslash( $_POST['reason'] ) ) : '';
1148
1149 if ( ! $user_id ) {
1150 wp_send_json_error( __( 'Invalid user ID.', 'vigilante' ) );
1151 }
1152
1153 $user = get_userdata( $user_id );
1154 $username = $user ? $user->user_login : $user_id;
1155
1156 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
1157 $result = $user_security->reject_user( $user_id, get_current_user_id(), $reason );
1158
1159 if ( $result ) {
1160 wp_send_json_success( array(
1161 'message' => sprintf(
1162 /* translators: %s: Username */
1163 __( 'User "%s" has been rejected and deleted.', 'vigilante' ),
1164 $username
1165 ),
1166 ) );
1167 } else {
1168 wp_send_json_error( __( 'Failed to reject user.', 'vigilante' ) );
1169 }
1170 }
1171
1172 /**
1173 * AJAX: Get user sessions
1174 */
1175 public function ajax_get_user_sessions() {
1176 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1177
1178 if ( ! current_user_can( 'manage_options' ) ) {
1179 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1180 }
1181
1182 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1183
1184 if ( ! $user_id ) {
1185 wp_send_json_error( __( 'Invalid user ID.', 'vigilante' ) );
1186 }
1187
1188 $user = get_userdata( $user_id );
1189 if ( ! $user ) {
1190 wp_send_json_error( __( 'User not found.', 'vigilante' ) );
1191 }
1192
1193 // Sessions carry IP, User-Agent and login time. manage_options alone is
1194 // not enough on a network, where it is held per subsite.
1195 if ( ! current_user_can( 'edit_user', $user_id ) ) {
1196 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1197 }
1198
1199 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
1200 $sessions = $user_security->get_user_sessions( $user_id );
1201
1202 wp_send_json_success( array(
1203 'user' => array(
1204 'ID' => $user->ID,
1205 'user_login' => $user->user_login,
1206 'display_name' => $user->display_name,
1207 ),
1208 'sessions' => $sessions,
1209 ) );
1210 }
1211
1212 /**
1213 * AJAX: Revoke specific session
1214 */
1215 public function ajax_revoke_session() {
1216 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1217
1218 if ( ! current_user_can( 'manage_options' ) ) {
1219 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1220 }
1221
1222 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1223 $token_hash = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
1224
1225 if ( ! $user_id || ! $token_hash ) {
1226 wp_send_json_error( __( 'Invalid parameters.', 'vigilante' ) );
1227 }
1228
1229 if ( ! current_user_can( 'edit_user', $user_id ) ) {
1230 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1231 }
1232
1233 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
1234 $result = $user_security->revoke_session( $user_id, $token_hash );
1235
1236 if ( $result ) {
1237 wp_send_json_success( array(
1238 'message' => __( 'Session revoked successfully.', 'vigilante' ),
1239 ) );
1240 } else {
1241 wp_send_json_error( __( 'Failed to revoke session.', 'vigilante' ) );
1242 }
1243 }
1244
1245 /**
1246 * AJAX: Revoke all sessions for a user
1247 */
1248 public function ajax_revoke_all_sessions() {
1249 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1250
1251 if ( ! current_user_can( 'manage_options' ) ) {
1252 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1253 }
1254
1255 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1256 $include_current = ! empty( $_POST['include_current'] );
1257
1258 if ( ! $user_id ) {
1259 wp_send_json_error( __( 'Invalid user ID.', 'vigilante' ) );
1260 }
1261
1262 if ( ! current_user_can( 'edit_user', $user_id ) ) {
1263 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1264 }
1265
1266 $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
1267 $count = $user_security->revoke_all_sessions( $user_id, $include_current );
1268
1269 wp_send_json_success( array(
1270 'message' => sprintf(
1271 /* translators: %d: Number of sessions */
1272 __( '%d session(s) revoked.', 'vigilante' ),
1273 $count
1274 ),
1275 'count' => $count,
1276 ) );
1277 }
1278
1279 /**
1280 * AJAX: Activate Under Attack mode
1281 */
1282 public function ajax_activate_under_attack() {
1283 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1284
1285 if ( ! current_user_can( 'manage_options' ) ) {
1286 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1287 }
1288
1289 $under_attack = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
1290
1291 if ( $under_attack->is_active() ) {
1292 wp_send_json_error( __( 'Under Attack mode is already active.', 'vigilante' ) );
1293 }
1294
1295 $result = $under_attack->activate();
1296
1297 if ( $result ) {
1298 wp_send_json_success( array(
1299 'message' => __( 'Under Attack mode activated.', 'vigilante' ),
1300 'remaining' => $under_attack->get_remaining_time(),
1301 'expires' => $under_attack->get_status()['activated_at'] + $under_attack->get_status()['duration'],
1302 ) );
1303 } else {
1304 wp_send_json_error( __( 'Failed to activate Under Attack mode.', 'vigilante' ) );
1305 }
1306 }
1307
1308 /**
1309 * AJAX: Deactivate Under Attack mode
1310 */
1311 public function ajax_deactivate_under_attack() {
1312 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1313
1314 if ( ! current_user_can( 'manage_options' ) ) {
1315 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1316 }
1317
1318 $under_attack = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
1319
1320 if ( ! $under_attack->is_active() ) {
1321 wp_send_json_error( __( 'Under Attack mode is not active.', 'vigilante' ) );
1322 }
1323
1324 $result = $under_attack->deactivate( 'manual' );
1325
1326 if ( $result ) {
1327 wp_send_json_success( __( 'Under Attack mode deactivated.', 'vigilante' ) );
1328 } else {
1329 wp_send_json_error( __( 'Failed to deactivate Under Attack mode.', 'vigilante' ) );
1330 }
1331 }
1332
1333 /**
1334 * AJAX: Get Under Attack mode status
1335 */
1336 public function ajax_under_attack_status() {
1337 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1338
1339 if ( ! current_user_can( 'manage_options' ) ) {
1340 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1341 }
1342
1343 $under_attack = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
1344
1345 wp_send_json_success( array(
1346 'active' => $under_attack->is_active(),
1347 'remaining' => $under_attack->get_remaining_time(),
1348 ) );
1349 }
1350
1351 // =========================================================================
1352 // DATABASE BACKUP AJAX HANDLERS
1353 // =========================================================================
1354
1355 /**
1356 * AJAX: Get database tables list
1357 */
1358 public function ajax_get_db_tables() {
1359 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1360
1361 if ( ! current_user_can( 'manage_options' ) ) {
1362 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1363 }
1364
1365 // The dump is taken with $wpdb->prefix, which on the main site of a
1366 // network matches every subsite table plus the global user tables, and
1367 // the options it carries include the stored copy of wp-config.php. Same
1368 // gate the rest of the network-shared operations use.
1369 if ( ! Vigilante_Settings::can_write_shared_files() ) {
1370 wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
1371 }
1372
1373 $backup = new Vigilante_Database_Backup();
1374 $tables = $backup->get_tables();
1375
1376 wp_send_json_success( $tables );
1377 }
1378
1379 /**
1380 * AJAX: Download database backup
1381 *
1382 * Streams a ZIP file directly to the browser
1383 */
1384 public function ajax_download_db_backup() {
1385 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1386
1387 if ( ! current_user_can( 'manage_options' ) ) {
1388 wp_die( esc_html__( 'Permission denied.', 'vigilante' ), 403 );
1389 }
1390
1391 // The dump is taken with $wpdb->prefix, which on the main site of a
1392 // network matches every subsite table plus the global user tables, and
1393 // the options it carries include the stored copy of wp-config.php. Same
1394 // gate the rest of the network-shared operations use.
1395 if ( ! Vigilante_Settings::can_write_shared_files() ) {
1396 wp_die( esc_html( Vigilante_Settings::get_shared_files_notice() ), 403 );
1397 }
1398
1399 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1400 $tables_raw = isset( $_POST['tables'] ) ? wp_unslash( $_POST['tables'] ) : '';
1401
1402 if ( empty( $tables_raw ) ) {
1403 wp_die( esc_html__( 'No tables selected.', 'vigilante' ), 400 );
1404 }
1405
1406 // Sanitize table names
1407 $tables = array_map( 'sanitize_key', explode( ',', $tables_raw ) );
1408 $tables = array_filter( $tables );
1409
1410 if ( empty( $tables ) ) {
1411 wp_die( esc_html__( 'No valid tables selected.', 'vigilante' ), 400 );
1412 }
1413
1414 $backup = new Vigilante_Database_Backup();
1415
1416 // Generate SQL dump
1417 $sql = $backup->generate_sql_dump( $tables );
1418 if ( is_wp_error( $sql ) ) {
1419 wp_die( esc_html( $sql->get_error_message() ), 500 );
1420 }
1421
1422 // Create ZIP
1423 $zip_path = $backup->create_zip( $sql );
1424 if ( is_wp_error( $zip_path ) ) {
1425 wp_die( esc_html( $zip_path->get_error_message() ), 500 );
1426 }
1427
1428 // Log the backup
1429 if ( $this->activity_log ) {
1430 $this->activity_log->log(
1431 'system',
1432 'database_backup',
1433 sprintf(
1434 /* translators: %d: Number of tables */
1435 __( 'Database backup created (%d tables)', 'vigilante' ),
1436 count( $tables )
1437 ),
1438 array( 'tables' => $tables ),
1439 'info'
1440 );
1441 }
1442
1443 // Stream download
1444 $backup->stream_download( $zip_path );
1445 }
1446
1447 // =========================================================================
1448 // DATABASE PREFIX AJAX HANDLERS
1449 // =========================================================================
1450
1451 /**
1452 * AJAX: Generate a new random prefix
1453 */
1454 public function ajax_generate_prefix() {
1455 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1456
1457 if ( ! current_user_can( 'manage_options' ) ) {
1458 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1459 }
1460
1461 $db_prefix = new Vigilante_Database_Prefix();
1462 $prefix = $db_prefix->generate_prefix();
1463
1464 wp_send_json_success( array( 'prefix' => $prefix ) );
1465 }
1466
1467 /**
1468 * AJAX: Change the database prefix
1469 */
1470 public function ajax_change_prefix() {
1471 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1472
1473 if ( ! current_user_can( 'manage_options' ) ) {
1474 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1475 }
1476
1477 $new_prefix = isset( $_POST['prefix'] ) ? sanitize_key( $_POST['prefix'] ) : '';
1478
1479 // Restore the underscore that sanitize_key might not strip but ensure it ends with one
1480 if ( ! empty( $new_prefix ) && substr( $new_prefix, -1 ) !== '_' ) {
1481 $new_prefix .= '_';
1482 }
1483
1484 if ( empty( $new_prefix ) ) {
1485 wp_send_json_error( __( 'Invalid prefix provided.', 'vigilante' ) );
1486 }
1487
1488 $db_prefix = new Vigilante_Database_Prefix();
1489
1490 // On a network the prefix is shared by every site: main site + network admin only
1491 $allowed = $db_prefix->can_change_prefix();
1492 if ( is_wp_error( $allowed ) ) {
1493 wp_send_json_error( $allowed->get_error_message() );
1494 }
1495
1496 // Validate first
1497 $valid = $db_prefix->validate_prefix( $new_prefix );
1498 if ( is_wp_error( $valid ) ) {
1499 wp_send_json_error( $valid->get_error_message() );
1500 }
1501
1502 // Log before changing (since after change, the log table will have new prefix)
1503 $old_prefix = $db_prefix->get_current_prefix();
1504
1505 // Execute the change
1506 $result = $db_prefix->change_prefix( $new_prefix );
1507
1508 if ( is_wp_error( $result ) ) {
1509 wp_send_json_error( $result->get_error_message() );
1510 }
1511
1512 // Log success (table has already been renamed, but the activity log object may still work for this request)
1513 if ( $this->activity_log ) {
1514 $this->activity_log->log(
1515 'system',
1516 'prefix_changed',
1517 sprintf(
1518 /* translators: 1: Old prefix, 2: New prefix */
1519 __( 'Database prefix changed from %1$s to %2$s', 'vigilante' ),
1520 $old_prefix,
1521 $new_prefix
1522 ),
1523 array(
1524 'old_prefix' => $old_prefix,
1525 'new_prefix' => $new_prefix,
1526 ),
1527 'warning'
1528 );
1529 }
1530
1531 wp_send_json_success( array(
1532 'message' => __( 'Database prefix changed successfully.', 'vigilante' ),
1533 'old_prefix' => $old_prefix,
1534 'new_prefix' => $new_prefix,
1535 ) );
1536 }
1537
1538 /**
1539 * AJAX: Unblock an IP from firewall rate limiting
1540 */
1541 public function ajax_unblock_firewall_ip() {
1542 check_ajax_referer( 'vigilante_admin_nonce', 'nonce' );
1543
1544 if ( ! current_user_can( 'manage_options' ) ) {
1545 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1546 }
1547
1548 $ip = isset( $_POST['ip'] ) ? sanitize_text_field( wp_unslash( $_POST['ip'] ) ) : '';
1549
1550 if ( empty( $ip ) ) {
1551 wp_send_json_error( __( 'No IP address provided.', 'vigilante' ) );
1552 }
1553
1554 $result = Vigilante_Firewall::unblock_ip( $ip );
1555
1556 if ( $result ) {
1557 // Log the manual unblock
1558 if ( $this->activity_log ) {
1559 $this->activity_log->log(
1560 'firewall',
1561 'unblocked',
1562 sprintf(
1563 /* translators: %s: IP address */
1564 __( 'IP %s manually unblocked from rate limiting', 'vigilante' ),
1565 $ip
1566 ),
1567 array( 'ip' => $ip ),
1568 'info'
1569 );
1570 }
1571 wp_send_json_success( sprintf(
1572 /* translators: %s: IP address */
1573 __( 'IP %s has been unblocked.', 'vigilante' ),
1574 $ip
1575 ) );
1576 } else {
1577 wp_send_json_error( __( 'IP not found in active blocks.', 'vigilante' ) );
1578 }
1579 }
1580
1581 }