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

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

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