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

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

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