PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.6
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.6
3.0.1 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 All 89 releases
vigilante / admin / class-admin-ajax.php

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

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