PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.4
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 2.9.4 2.9.3 All 86 releases
vigilante / admin / class-admin-ajax.php

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

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