PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.76
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.76
51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / extensions / Activity_Log / Activity_Log.php

Activity_Log.php in King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder 51.1.76, at includes/extensions/Activity_Log/Activity_Log.php

1,725 lines 55.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Activity Log extension.
4 *
5 * @package King_Addons
6 */
7
8 namespace King_Addons\Activity_Log;
9
10 use WP_Post;
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 require_once __DIR__ . '/Activity_Log_DB.php';
17
18 class Activity_Log
19 {
20 private const OPTION_NAME = 'king_addons_activity_log_settings';
21
22 private const CORE_SETTINGS_OPTIONS_ALLOWLIST = [
23 'blogname',
24 'blogdescription',
25 'siteurl',
26 'home',
27 'admin_email',
28 'users_can_register',
29 'default_role',
30 'timezone_string',
31 'gmt_offset',
32 'date_format',
33 'time_format',
34 'start_of_week',
35 'permalink_structure',
36 'category_base',
37 'tag_base',
38 'posts_per_page',
39 'show_on_front',
40 'page_on_front',
41 'page_for_posts',
42 'thumbnail_size_w',
43 'thumbnail_size_h',
44 'thumbnail_crop',
45 'medium_size_w',
46 'medium_size_h',
47 'large_size_w',
48 'large_size_h',
49 'uploads_use_yearmonth_folders',
50 ];
51
52 private static ?Activity_Log $instance = null;
53
54 /**
55 * Cached settings.
56 *
57 * @var array<string, mixed>
58 */
59 private array $settings = [];
60
61 public static function instance(): Activity_Log
62 {
63 if (self::$instance === null) {
64 self::$instance = new self();
65 }
66
67 return self::$instance;
68 }
69
70 private function __construct()
71 {
72 $this->settings = $this->get_settings();
73
74 add_action('init', [$this, 'maybe_create_table']);
75 add_action('init', [$this, 'schedule_purge']);
76 add_action('kng_activity_log_purge', [$this, 'purge_old_logs']);
77
78 add_action('admin_menu', [$this, 'register_admin_menu']);
79 add_action('admin_init', [$this, 'register_settings']);
80 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
81
82 add_action('admin_post_kng_activity_log_export', [$this, 'handle_export']);
83 add_action('admin_post_kng_activity_log_purge', [$this, 'handle_manual_purge']);
84 add_action('admin_post_kng_activity_log_save_alerts', [$this, 'handle_save_alerts']);
85
86 // Settings changes + King Addons option changes (Pro modules).
87 add_action('updated_option', [$this, 'log_option_updated'], 10, 3);
88 add_action('added_option', [$this, 'log_option_added'], 10, 2);
89 add_action('deleted_option', [$this, 'log_option_deleted'], 10, 1);
90
91 // WooCommerce events (Pro module).
92 add_action('plugins_loaded', [$this, 'register_woocommerce_hooks'], 20);
93
94 add_action('wp_login', [$this, 'log_login'], 10, 2);
95 add_action('wp_login_failed', [$this, 'log_failed_login']);
96 add_action('wp_logout', [$this, 'log_logout']);
97 add_action('user_register', [$this, 'log_user_created']);
98 add_action('profile_update', [$this, 'log_user_updated'], 10, 2);
99 add_action('delete_user', [$this, 'log_user_deleted']);
100 add_action('set_user_role', [$this, 'log_user_role_changed'], 10, 3);
101
102 add_action('save_post', [$this, 'log_post_saved'], 10, 3);
103 add_action('wp_trash_post', [$this, 'log_post_trashed']);
104 add_action('untrash_post', [$this, 'log_post_restored']);
105 add_action('before_delete_post', [$this, 'log_post_deleted']);
106
107 add_action('activated_plugin', [$this, 'log_plugin_activated'], 10, 2);
108 add_action('deactivated_plugin', [$this, 'log_plugin_deactivated'], 10, 2);
109 add_action('upgrader_process_complete', [$this, 'log_plugin_updated'], 10, 2);
110 add_action('switch_theme', [$this, 'log_theme_switched'], 10, 3);
111
112 add_action('kng_activity_log/event', [$this, 'handle_custom_event']);
113 }
114
115 public function register_woocommerce_hooks(): void
116 {
117 if (!function_exists('wc_get_order')) {
118 return;
119 }
120
121 add_action('woocommerce_new_order', [$this, 'log_wc_order_created'], 10, 1);
122 add_action('woocommerce_order_status_changed', [$this, 'log_wc_order_status_changed'], 10, 4);
123 add_action('woocommerce_product_set_stock', [$this, 'log_wc_product_stock_changed'], 10, 1);
124 }
125
126 public function log_wc_order_created(int $order_id): void
127 {
128 if (!function_exists('wc_get_order')) {
129 return;
130 }
131
132 $order = wc_get_order($order_id);
133 if (!$order) {
134 return;
135 }
136
137 $user_id = (int) $order->get_user_id();
138 $user_login = '';
139 if ($user_id > 0) {
140 $user = get_userdata($user_id);
141 $user_login = $user ? (string) $user->user_login : '';
142 }
143
144 $this->log_event([
145 'event_key' => 'woocommerce.order.created',
146 'severity' => 'notice',
147 'user_id' => $user_id ?: null,
148 'user_login' => $user_login,
149 'object_type' => 'shop_order',
150 'object_id' => (string) $order_id,
151 'object_title' => 'Order #' . $order->get_order_number(),
152 'source' => 'woocommerce',
153 'message' => __('Order created.', 'king-addons'),
154 'data' => [
155 'status' => $order->get_status(),
156 'total' => $order->get_total(),
157 'currency' => $order->get_currency(),
158 'payment_method' => $order->get_payment_method_title(),
159 ],
160 ]);
161 }
162
163 public function log_wc_order_status_changed(int $order_id, string $old_status, string $new_status, $order): void
164 {
165 if (!function_exists('wc_get_order')) {
166 return;
167 }
168
169 if (!$order) {
170 $order = wc_get_order($order_id);
171 }
172
173 if (!$order) {
174 return;
175 }
176
177 $user_id = (int) $order->get_user_id();
178 $user_login = '';
179 if ($user_id > 0) {
180 $user = get_userdata($user_id);
181 $user_login = $user ? (string) $user->user_login : '';
182 }
183
184 $this->log_event([
185 'event_key' => 'woocommerce.order.status_changed',
186 'severity' => 'notice',
187 'user_id' => $user_id ?: null,
188 'user_login' => $user_login,
189 'object_type' => 'shop_order',
190 'object_id' => (string) $order_id,
191 'object_title' => 'Order #' . $order->get_order_number(),
192 'source' => 'woocommerce',
193 'message' => __('Order status changed.', 'king-addons'),
194 'data' => [
195 'from' => $old_status,
196 'to' => $new_status,
197 ],
198 ]);
199 }
200
201 public function log_wc_product_stock_changed($product): void
202 {
203 if (!is_object($product) || !method_exists($product, 'get_id')) {
204 return;
205 }
206
207 $product_id = (int) $product->get_id();
208 if ($product_id <= 0) {
209 return;
210 }
211
212 $this->log_event([
213 'event_key' => 'woocommerce.product.stock_changed',
214 'severity' => 'notice',
215 'object_type' => 'product',
216 'object_id' => (string) $product_id,
217 'object_title' => method_exists($product, 'get_name') ? (string) $product->get_name() : ('#' . $product_id),
218 'source' => 'woocommerce',
219 'message' => __('Product stock updated.', 'king-addons'),
220 'data' => [
221 'stock_status' => method_exists($product, 'get_stock_status') ? (string) $product->get_stock_status() : '',
222 'stock_quantity' => method_exists($product, 'get_stock_quantity') ? $product->get_stock_quantity() : null,
223 ],
224 ]);
225 }
226
227 public function log_option_updated(string $option, $old_value, $value): void
228 {
229 if (!$this->should_log_option_change($option)) {
230 return;
231 }
232
233 if ($old_value === $value) {
234 return;
235 }
236
237 $event_key = $this->is_king_addons_option($option) ? 'kng.option.updated' : 'settings.option.updated';
238
239 $data = [
240 'option' => $option,
241 'old' => $this->sanitize_option_value_for_log($option, $old_value),
242 'new' => $this->sanitize_option_value_for_log($option, $value),
243 ];
244
245 if (is_array($old_value) && is_array($value)) {
246 $changed_keys = [];
247 foreach (array_unique(array_merge(array_keys($old_value), array_keys($value))) as $key) {
248 $old = $old_value[$key] ?? null;
249 $new = $value[$key] ?? null;
250 if ($old !== $new) {
251 $changed_keys[] = (string) $key;
252 }
253 }
254
255 $data['changed_count'] = count($changed_keys);
256 $data['changed_keys'] = array_slice($changed_keys, 0, 20);
257 }
258
259 $this->log_event([
260 'event_key' => $event_key,
261 'severity' => 'notice',
262 'object_type' => 'option',
263 'object_id' => $option,
264 'object_title' => $option,
265 'source' => 'core',
266 'message' => __('Option updated.', 'king-addons'),
267 'data' => $data,
268 ]);
269 }
270
271 public function log_option_added(string $option, $value): void
272 {
273 if (!$this->should_log_option_change($option)) {
274 return;
275 }
276
277 $event_key = $this->is_king_addons_option($option) ? 'kng.option.added' : 'settings.option.added';
278
279 $this->log_event([
280 'event_key' => $event_key,
281 'severity' => 'notice',
282 'object_type' => 'option',
283 'object_id' => $option,
284 'object_title' => $option,
285 'source' => 'core',
286 'message' => __('Option added.', 'king-addons'),
287 'data' => [
288 'option' => $option,
289 'new' => $this->sanitize_option_value_for_log($option, $value),
290 ],
291 ]);
292 }
293
294 public function log_option_deleted(string $option): void
295 {
296 if (!$this->should_log_option_change($option)) {
297 return;
298 }
299
300 $event_key = $this->is_king_addons_option($option) ? 'kng.option.deleted' : 'settings.option.deleted';
301
302 $this->log_event([
303 'event_key' => $event_key,
304 'severity' => 'warning',
305 'object_type' => 'option',
306 'object_id' => $option,
307 'object_title' => $option,
308 'source' => 'core',
309 'message' => __('Option deleted.', 'king-addons'),
310 'data' => [
311 'option' => $option,
312 ],
313 ]);
314 }
315
316 private function should_log_option_change(string $option): bool
317 {
318 if ($option === '' || $option === self::OPTION_NAME) {
319 return false;
320 }
321
322 if (strpos($option, '_transient_') === 0 || strpos($option, '_site_transient_') === 0) {
323 return false;
324 }
325
326 // Avoid recursion & internal runtime counters.
327 if (strpos($option, 'king_addons_activity_log_') === 0) {
328 return false;
329 }
330
331 if ($this->is_king_addons_option($option)) {
332 if ($option === 'king_addons_options') {
333 return true;
334 }
335
336 if (substr($option, -9) === '_settings') {
337 return true;
338 }
339
340 return false;
341 }
342
343 return in_array($option, self::CORE_SETTINGS_OPTIONS_ALLOWLIST, true);
344 }
345
346 private function is_king_addons_option(string $option): bool
347 {
348 return strpos($option, 'king_addons_') === 0;
349 }
350
351 private function sanitize_option_value_for_log(string $option, $value): string
352 {
353 $lower = strtolower($option);
354 if (preg_match('/(pass|password|secret|token|key|salt|nonce|license)/', $lower)) {
355 return '[redacted]';
356 }
357
358 if (is_null($value)) {
359 return 'null';
360 }
361
362 if (is_bool($value)) {
363 return $value ? 'true' : 'false';
364 }
365
366 if (is_int($value) || is_float($value)) {
367 return (string) $value;
368 }
369
370 if (is_string($value)) {
371 $trimmed = trim($value);
372 if ($trimmed === '') {
373 return '';
374 }
375
376 if (strlen($trimmed) > 120) {
377 return substr($trimmed, 0, 120) . '';
378 }
379
380 return $trimmed;
381 }
382
383 if (is_array($value)) {
384 return '[array:' . count($value) . ']';
385 }
386
387 if (is_object($value)) {
388 return '[object:' . get_class($value) . ']';
389 }
390
391 return '[unknown]';
392 }
393
394 public function register_admin_menu(): void
395 {
396 $view_logs_cap = $this->get_view_logs_capability();
397
398 // Optional logs-only entry for non-admin roles (Pro).
399 // Admins can still access logs from the main Activity Log page.
400 if ($view_logs_cap !== 'manage_options') {
401 add_submenu_page(
402 'king-addons',
403 __('Activity Logs', 'king-addons'),
404 __('Activity Logs', 'king-addons'),
405 $view_logs_cap,
406 'king-addons-activity-log-logs',
407 [$this, 'render_admin_logs_page']
408 );
409 }
410
411 add_submenu_page(
412 'king-addons',
413 __('Activity Log', 'king-addons'),
414 __('Activity Log', 'king-addons'),
415 'manage_options',
416 'king-addons-activity-log',
417 [$this, 'render_admin_page']
418 );
419 }
420
421 public function register_settings(): void
422 {
423 register_setting(
424 'king_addons_activity_log',
425 self::OPTION_NAME,
426 [
427 'type' => 'array',
428 'sanitize_callback' => [$this, 'sanitize_settings'],
429 'default' => $this->get_default_settings(),
430 ]
431 );
432 }
433
434 public function enqueue_admin_assets(string $hook): void
435 {
436 if (!in_array($hook, ['king-addons_page_king-addons-activity-log', 'king-addons_page_king-addons-activity-log-logs'], true)) {
437 return;
438 }
439
440 $shared_css = KING_ADDONS_URL . 'includes/admin/layouts/shared/admin-v3-styles.css';
441 $shared_path = KING_ADDONS_PATH . 'includes/admin/layouts/shared/admin-v3-styles.css';
442 $shared_version = file_exists($shared_path) ? filemtime($shared_path) : KING_ADDONS_VERSION;
443 wp_enqueue_style('king-addons-admin-v3', $shared_css, [], $shared_version);
444
445 $admin_css = KING_ADDONS_URL . 'includes/extensions/Activity_Log/assets/admin.css';
446 $admin_path = KING_ADDONS_PATH . 'includes/extensions/Activity_Log/assets/admin.css';
447 $admin_version = file_exists($admin_path) ? filemtime($admin_path) : KING_ADDONS_VERSION;
448 wp_enqueue_style('king-addons-activity-log-admin', $admin_css, ['king-addons-admin-v3'], $admin_version);
449
450 $admin_js = KING_ADDONS_URL . 'includes/extensions/Activity_Log/assets/admin.js';
451 $admin_path_js = KING_ADDONS_PATH . 'includes/extensions/Activity_Log/assets/admin.js';
452 $admin_js_version = file_exists($admin_path_js) ? filemtime($admin_path_js) : KING_ADDONS_VERSION;
453 wp_enqueue_script('king-addons-activity-log-admin', $admin_js, ['jquery'], $admin_js_version, true);
454
455 wp_localize_script('king-addons-activity-log-admin', 'KNGActivityLog', [
456 'ajaxUrl' => admin_url('admin-ajax.php'),
457 'themeNonce' => wp_create_nonce('king_addons_dashboard_ui'),
458 ]);
459 }
460
461 public function render_admin_page(): void
462 {
463 if (!current_user_can('manage_options')) {
464 return;
465 }
466
467 $view = isset($_GET['view']) ? sanitize_key($_GET['view']) : 'dashboard';
468 $is_pro = $this->is_pro();
469 $settings = $this->get_settings();
470
471 include __DIR__ . '/templates/admin-page.php';
472 }
473
474 public function render_admin_logs_page(): void
475 {
476 $capability = $this->get_view_logs_capability();
477 if (!current_user_can($capability)) {
478 return;
479 }
480
481 $view = 'logs';
482 $is_pro = $this->is_pro();
483 $settings = $this->get_settings();
484
485 include __DIR__ . '/templates/admin-page.php';
486 }
487
488 public function maybe_create_table(): void
489 {
490 Activity_Log_DB::maybe_create_table();
491 }
492
493 public function schedule_purge(): void
494 {
495 if (!wp_next_scheduled('kng_activity_log_purge')) {
496 wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', 'kng_activity_log_purge');
497 }
498 }
499
500 public function purge_old_logs(): void
501 {
502 global $wpdb;
503
504 $days = $this->get_retention_days();
505 $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
506
507 $table = Activity_Log_DB::get_table();
508 $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE created_at < %s", $cutoff));
509 }
510
511 public function handle_manual_purge(): void
512 {
513 if (!current_user_can('manage_options')) {
514 wp_die(esc_html__('Unauthorized request.', 'king-addons'));
515 }
516
517 check_admin_referer('kng_activity_log_purge');
518 $this->purge_old_logs();
519
520 $this->redirect_with_message('tools', 'purged');
521 }
522
523 public function handle_export(): void
524 {
525 if (!current_user_can('manage_options')) {
526 wp_die(esc_html__('Unauthorized request.', 'king-addons'));
527 }
528
529 check_admin_referer('kng_activity_log_export');
530
531 $filters = $this->get_filters_from_request();
532 $filters = $this->apply_retention_limit($filters);
533 $logs = $this->get_logs($filters, 1, 0);
534
535 header('Content-Type: text/csv; charset=utf-8');
536 header('Content-Disposition: attachment; filename=activity-log-' . gmdate('Y-m-d') . '.csv');
537
538 $output = fopen('php://output', 'w');
539 fputcsv($output, [
540 'Time',
541 'Event',
542 'Severity',
543 'User',
544 'Role',
545 'IP',
546 'Object',
547 'Source',
548 'Message',
549 ]);
550
551 foreach ($logs as $log) {
552 $object = trim($log->object_type . ' ' . $log->object_title);
553 $row = [
554 $this->format_time($log->created_at),
555 $log->event_key,
556 $log->severity,
557 $log->user_login ?: 'Guest',
558 $log->user_role ?: '',
559 $log->ip ?: '',
560 trim($object),
561 $log->source,
562 $log->message,
563 ];
564 $row = array_map([$this, 'escape_csv_value'], $row);
565 fputcsv($output, $row);
566 }
567
568 fclose($output);
569 exit;
570 }
571
572 public function handle_save_alerts(): void
573 {
574 if (!current_user_can('manage_options')) {
575 wp_die(esc_html__('Unauthorized request.', 'king-addons'));
576 }
577
578 check_admin_referer('kng_activity_log_alerts');
579
580 $settings = $this->get_settings();
581 $alerts = [
582 'failed_login_enabled' => !empty($_POST['failed_login_enabled']),
583 'failed_login_threshold' => absint($_POST['failed_login_threshold'] ?? 5),
584 'failed_login_window' => absint($_POST['failed_login_window'] ?? 10),
585 'failed_login_emails' => sanitize_text_field(wp_unslash($_POST['failed_login_emails'] ?? '')),
586 ];
587
588 $settings['alerts'] = $this->sanitize_alerts($alerts);
589 update_option(self::OPTION_NAME, $settings);
590
591 $this->redirect_with_message('alerts', 'alerts_saved');
592 }
593
594 public function log_login(string $user_login, \WP_User $user): void
595 {
596 $this->log_event([
597 'event_key' => 'auth.login.success',
598 'severity' => 'info',
599 'user_id' => $user->ID,
600 'user_login' => $user_login,
601 'user_role' => $this->get_user_role($user),
602 'object_type' => 'user',
603 'object_id' => (string) $user->ID,
604 'object_title' => $user_login,
605 'source' => 'core',
606 'message' => __('User logged in.', 'king-addons'),
607 ]);
608 }
609
610 public function log_failed_login(string $username): void
611 {
612 $user = get_user_by('login', $username);
613 $user_id = $user ? $user->ID : 0;
614 $user_role = $user ? $this->get_user_role($user) : '';
615
616 $this->log_event([
617 'event_key' => 'auth.login.failed',
618 'severity' => 'warning',
619 'user_id' => $user_id,
620 'user_login' => $username,
621 'user_role' => $user_role,
622 'object_type' => 'user',
623 'object_id' => $user_id ? (string) $user_id : '',
624 'object_title' => $username,
625 'source' => 'core',
626 'message' => __('Failed login attempt.', 'king-addons'),
627 'data' => [
628 'login' => $username,
629 ],
630 ]);
631
632 $this->maybe_send_failed_login_alert();
633 }
634
635 public function log_logout(): void
636 {
637 $user = wp_get_current_user();
638 if (!$user || !$user->ID) {
639 return;
640 }
641
642 $this->log_event([
643 'event_key' => 'auth.logout',
644 'severity' => 'info',
645 'user_id' => $user->ID,
646 'user_login' => $user->user_login,
647 'user_role' => $this->get_user_role($user),
648 'object_type' => 'user',
649 'object_id' => (string) $user->ID,
650 'object_title' => $user->user_login,
651 'source' => 'core',
652 'message' => __('User logged out.', 'king-addons'),
653 ]);
654 }
655
656 public function log_user_created(int $user_id): void
657 {
658 $user = get_userdata($user_id);
659 if (!$user) {
660 return;
661 }
662
663 $this->log_event([
664 'event_key' => 'user.created',
665 'severity' => 'notice',
666 'user_id' => $user_id,
667 'user_login' => $user->user_login,
668 'user_role' => $this->get_user_role($user),
669 'object_type' => 'user',
670 'object_id' => (string) $user_id,
671 'object_title' => $user->user_login,
672 'source' => 'core',
673 'message' => __('User account created.', 'king-addons'),
674 ]);
675 }
676
677 public function log_user_updated(int $user_id, $old_user_data): void
678 {
679 $user = get_userdata($user_id);
680 if (!$user) {
681 return;
682 }
683
684 $this->log_event([
685 'event_key' => 'user.updated',
686 'severity' => 'notice',
687 'user_id' => $user_id,
688 'user_login' => $user->user_login,
689 'user_role' => $this->get_user_role($user),
690 'object_type' => 'user',
691 'object_id' => (string) $user_id,
692 'object_title' => $user->user_login,
693 'source' => 'core',
694 'message' => __('User profile updated.', 'king-addons'),
695 ]);
696 }
697
698 public function log_user_deleted(int $user_id): void
699 {
700 $user = get_userdata($user_id);
701 $user_login = $user ? $user->user_login : '';
702
703 $this->log_event([
704 'event_key' => 'user.deleted',
705 'severity' => 'warning',
706 'user_id' => $user_id,
707 'user_login' => $user_login,
708 'user_role' => $user ? $this->get_user_role($user) : '',
709 'object_type' => 'user',
710 'object_id' => (string) $user_id,
711 'object_title' => $user_login,
712 'source' => 'core',
713 'message' => __('User account deleted.', 'king-addons'),
714 ]);
715 }
716
717 public function log_user_role_changed(int $user_id, string $role, array $old_roles): void
718 {
719 $user = get_userdata($user_id);
720 if (!$user) {
721 return;
722 }
723
724 $severity = 'notice';
725 if ($role === 'administrator' && !in_array('administrator', $old_roles, true)) {
726 $severity = 'critical';
727 }
728
729 $this->log_event([
730 'event_key' => 'user.role_changed',
731 'severity' => $severity,
732 'user_id' => $user_id,
733 'user_login' => $user->user_login,
734 'user_role' => $this->get_user_role($user),
735 'object_type' => 'user',
736 'object_id' => (string) $user_id,
737 'object_title' => $user->user_login,
738 'source' => 'core',
739 'message' => __('User role changed.', 'king-addons'),
740 'data' => [
741 'old_roles' => $old_roles,
742 'new_role' => $role,
743 ],
744 ]);
745 }
746
747 public function log_post_saved(int $post_id, WP_Post $post, bool $update): void
748 {
749 if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
750 return;
751 }
752
753 if ($post->post_status === 'auto-draft') {
754 return;
755 }
756
757 $event_key = $update ? 'content.updated' : 'content.created';
758 $severity = $update ? 'notice' : 'info';
759
760 $this->log_event([
761 'event_key' => $event_key,
762 'severity' => $severity,
763 'object_type' => $post->post_type,
764 'object_id' => (string) $post_id,
765 'object_title' => $post->post_title ?: ('#' . $post_id),
766 'source' => 'core',
767 'message' => $update ? __('Content updated.', 'king-addons') : __('Content created.', 'king-addons'),
768 'data' => [
769 'status' => $post->post_status,
770 ],
771 ]);
772 }
773
774 public function log_post_trashed(int $post_id): void
775 {
776 $post = get_post($post_id);
777 if (!$post) {
778 return;
779 }
780
781 $this->log_event([
782 'event_key' => 'content.trashed',
783 'severity' => 'notice',
784 'object_type' => $post->post_type,
785 'object_id' => (string) $post_id,
786 'object_title' => $post->post_title ?: ('#' . $post_id),
787 'source' => 'core',
788 'message' => __('Content moved to trash.', 'king-addons'),
789 ]);
790 }
791
792 public function log_post_restored(int $post_id): void
793 {
794 $post = get_post($post_id);
795 if (!$post) {
796 return;
797 }
798
799 $this->log_event([
800 'event_key' => 'content.restored',
801 'severity' => 'notice',
802 'object_type' => $post->post_type,
803 'object_id' => (string) $post_id,
804 'object_title' => $post->post_title ?: ('#' . $post_id),
805 'source' => 'core',
806 'message' => __('Content restored from trash.', 'king-addons'),
807 ]);
808 }
809
810 public function log_post_deleted(int $post_id): void
811 {
812 $post = get_post($post_id);
813 if (!$post) {
814 return;
815 }
816
817 $this->log_event([
818 'event_key' => 'content.deleted',
819 'severity' => 'warning',
820 'object_type' => $post->post_type,
821 'object_id' => (string) $post_id,
822 'object_title' => $post->post_title ?: ('#' . $post_id),
823 'source' => 'core',
824 'message' => __('Content deleted permanently.', 'king-addons'),
825 ]);
826 }
827
828 public function log_plugin_activated(string $plugin, bool $network_wide): void
829 {
830 $plugin_name = $this->get_plugin_name($plugin);
831
832 $this->log_event([
833 'event_key' => 'plugin.activated',
834 'severity' => 'notice',
835 'object_type' => 'plugin',
836 'object_id' => $plugin,
837 'object_title' => $plugin_name,
838 'source' => 'core',
839 'message' => __('Plugin activated.', 'king-addons'),
840 ]);
841 }
842
843 public function log_plugin_deactivated(string $plugin, bool $network_wide): void
844 {
845 $plugin_name = $this->get_plugin_name($plugin);
846
847 $this->log_event([
848 'event_key' => 'plugin.deactivated',
849 'severity' => 'notice',
850 'object_type' => 'plugin',
851 'object_id' => $plugin,
852 'object_title' => $plugin_name,
853 'source' => 'core',
854 'message' => __('Plugin deactivated.', 'king-addons'),
855 ]);
856 }
857
858 public function log_plugin_updated($upgrader, array $hook_extra): void
859 {
860 if (($hook_extra['action'] ?? '') !== 'update' || ($hook_extra['type'] ?? '') !== 'plugin') {
861 return;
862 }
863
864 $plugins = $hook_extra['plugins'] ?? [];
865 if (!is_array($plugins)) {
866 return;
867 }
868
869 foreach ($plugins as $plugin) {
870 $plugin_name = $this->get_plugin_name($plugin);
871 $this->log_event([
872 'event_key' => 'plugin.updated',
873 'severity' => 'notice',
874 'object_type' => 'plugin',
875 'object_id' => (string) $plugin,
876 'object_title' => $plugin_name,
877 'source' => 'core',
878 'message' => __('Plugin updated.', 'king-addons'),
879 ]);
880 }
881 }
882
883 public function log_theme_switched(string $new_name, \WP_Theme $new_theme, \WP_Theme $old_theme): void
884 {
885 $this->log_event([
886 'event_key' => 'theme.switched',
887 'severity' => 'notice',
888 'object_type' => 'theme',
889 'object_id' => (string) $new_theme->get_stylesheet(),
890 'object_title' => $new_name,
891 'source' => 'core',
892 'message' => __('Theme switched.', 'king-addons'),
893 'data' => [
894 'previous' => $old_theme->get_stylesheet(),
895 ],
896 ]);
897 }
898
899 public function handle_custom_event(array $event): void
900 {
901 if (empty($event['event_key'])) {
902 return;
903 }
904
905 $this->log_event($event);
906 }
907
908 private function log_event(array $event): void
909 {
910 if (empty($this->settings['enabled'])) {
911 return;
912 }
913
914 $event_key = $event['event_key'] ?? '';
915 if ($event_key === '' || !$this->is_event_allowed($event_key, (int) ($event['user_id'] ?? 0))) {
916 return;
917 }
918
919 $user = null;
920 if (!empty($event['user_id'])) {
921 $user = get_userdata((int) $event['user_id']);
922 }
923
924 if (!$user) {
925 $user = wp_get_current_user();
926 }
927
928 $user_id = $event['user_id'] ?? ($user && $user->ID ? $user->ID : null);
929 $user_login = $event['user_login'] ?? ($user && $user->ID ? $user->user_login : '');
930 $user_role = $event['user_role'] ?? ($user && $user->ID ? $this->get_user_role($user) : '');
931
932 $data = $event['data'] ?? [];
933 if (!is_array($data)) {
934 $data = [];
935 }
936
937 $context = $event['context'] ?? $this->get_context();
938 $source = $event['source'] ?? 'core';
939
940 $log = [
941 'created_at' => current_time('mysql', true),
942 'event_key' => sanitize_text_field($event_key),
943 'severity' => $this->sanitize_severity($event['severity'] ?? 'info'),
944 'user_id' => $user_id ? (int) $user_id : null,
945 'user_login' => $user_login ? sanitize_text_field($user_login) : null,
946 'user_role' => $user_role ? sanitize_text_field($user_role) : null,
947 'ip' => $this->get_ip_for_storage(),
948 'user_agent' => $this->settings['store_user_agent'] ? $this->get_user_agent() : null,
949 'object_type' => sanitize_key($event['object_type'] ?? ''),
950 'object_id' => isset($event['object_id']) ? sanitize_text_field((string) $event['object_id']) : null,
951 'object_title' => isset($event['object_title']) ? sanitize_text_field((string) $event['object_title']) : null,
952 'source' => sanitize_text_field($source),
953 'context' => sanitize_key($context),
954 'message' => isset($event['message']) ? sanitize_text_field((string) $event['message']) : '',
955 'data' => wp_json_encode($data),
956 'checksum' => null,
957 'chain_prev_checksum' => null,
958 ];
959
960 global $wpdb;
961
962 $table = Activity_Log_DB::get_table();
963 $wpdb->insert($table, $log, [
964 '%s',
965 '%s',
966 '%s',
967 '%d',
968 '%s',
969 '%s',
970 '%s',
971 '%s',
972 '%s',
973 '%s',
974 '%s',
975 '%s',
976 '%s',
977 '%s',
978 '%s',
979 '%s',
980 '%s',
981 ]);
982 }
983
984 private function is_event_allowed(string $event_key, int $user_id): bool
985 {
986 $settings = $this->settings;
987
988 if ($user_id > 0 && !empty($settings['exclude_user_ids']) && in_array($user_id, $settings['exclude_user_ids'], true)) {
989 return false;
990 }
991
992 if (!empty($settings['exclude_roles']) && $user_id > 0) {
993 $user = get_userdata($user_id);
994 $role = $user ? $this->get_user_role($user) : '';
995 if ($role !== '' && in_array($role, $settings['exclude_roles'], true)) {
996 return false;
997 }
998 }
999
1000 if (!empty($settings['exclude_event_keys']) && in_array($event_key, $settings['exclude_event_keys'], true)) {
1001 return false;
1002 }
1003
1004 $modules = $settings['modules'] ?? [];
1005 $prefix = strstr($event_key, '.', true);
1006
1007 $module_map = [
1008 'auth' => 'auth',
1009 'content' => 'content',
1010 'user' => 'users',
1011 'plugin' => 'plugins_themes',
1012 'theme' => 'plugins_themes',
1013 'settings' => 'settings',
1014 'woocommerce' => 'woocommerce',
1015 'kng' => 'king_addons',
1016 ];
1017
1018 if ($prefix && isset($module_map[$prefix])) {
1019 $module_key = $module_map[$prefix];
1020 if (isset($modules[$module_key]) && !$modules[$module_key]) {
1021 return false;
1022 }
1023 }
1024
1025 return true;
1026 }
1027
1028 private function maybe_send_failed_login_alert(): void
1029 {
1030 $alerts = $this->settings['alerts'] ?? [];
1031 if (empty($alerts['failed_login_enabled'])) {
1032 return;
1033 }
1034
1035 $threshold = max(1, (int) ($alerts['failed_login_threshold'] ?? 5));
1036 $window = max(1, (int) ($alerts['failed_login_window'] ?? 10));
1037 $emails = trim((string) ($alerts['failed_login_emails'] ?? ''));
1038 if ($emails === '') {
1039 $emails = get_option('admin_email');
1040 }
1041
1042 $last_sent = (int) get_option('king_addons_activity_log_failed_login_last', 0);
1043 if ($last_sent > 0 && (time() - $last_sent) < ($window * 60)) {
1044 return;
1045 }
1046
1047 global $wpdb;
1048 $table = Activity_Log_DB::get_table();
1049 $cutoff = gmdate('Y-m-d H:i:s', time() - ($window * 60));
1050 $count = (int) $wpdb->get_var($wpdb->prepare(
1051 "SELECT COUNT(*) FROM {$table} WHERE event_key = %s AND created_at >= %s",
1052 'auth.login.failed',
1053 $cutoff
1054 ));
1055
1056 if ($count < $threshold) {
1057 return;
1058 }
1059
1060 $subject = __('Failed login alert', 'king-addons');
1061 $message = sprintf(
1062 __('There have been %d failed login attempts in the last %d minutes.', 'king-addons'),
1063 $count,
1064 $window
1065 );
1066
1067 wp_mail($emails, $subject, $message);
1068 update_option('king_addons_activity_log_failed_login_last', time());
1069 }
1070
1071 public function get_logs(array $filters, int $page, int $per_page): array
1072 {
1073 global $wpdb;
1074
1075 $table = Activity_Log_DB::get_table();
1076 [$where_sql, $params] = $this->build_where_clause($filters);
1077
1078 $sql = "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC";
1079
1080 if ($per_page > 0) {
1081 $offset = max(0, ($page - 1) * $per_page);
1082 $sql .= $wpdb->prepare(' LIMIT %d OFFSET %d', $per_page, $offset);
1083 }
1084
1085 if (!empty($params)) {
1086 $sql = $wpdb->prepare($sql, $params);
1087 }
1088
1089 return $wpdb->get_results($sql);
1090 }
1091
1092 public function get_logs_count(array $filters): int
1093 {
1094 global $wpdb;
1095
1096 $table = Activity_Log_DB::get_table();
1097 [$where_sql, $params] = $this->build_where_clause($filters);
1098
1099 $sql = "SELECT COUNT(*) FROM {$table} {$where_sql}";
1100 if (!empty($params)) {
1101 $sql = $wpdb->prepare($sql, $params);
1102 }
1103
1104 return (int) $wpdb->get_var($sql);
1105 }
1106
1107 public function get_dashboard_stats(): array
1108 {
1109 global $wpdb;
1110
1111 $table = Activity_Log_DB::get_table();
1112 $now = time();
1113
1114 $last_24 = gmdate('Y-m-d H:i:s', $now - DAY_IN_SECONDS);
1115 $last_7 = gmdate('Y-m-d H:i:s', $now - (7 * DAY_IN_SECONDS));
1116
1117 $total_24h = (int) $wpdb->get_var($wpdb->prepare(
1118 "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s",
1119 $last_24
1120 ));
1121
1122 $failed_24h = (int) $wpdb->get_var($wpdb->prepare(
1123 "SELECT COUNT(*) FROM {$table} WHERE event_key = %s AND created_at >= %s",
1124 'auth.login.failed',
1125 $last_24
1126 ));
1127
1128 $critical_7d = (int) $wpdb->get_var($wpdb->prepare(
1129 "SELECT COUNT(*) FROM {$table} WHERE severity = %s AND created_at >= %s",
1130 'critical',
1131 $last_7
1132 ));
1133
1134 $unique_users_7d = (int) $wpdb->get_var($wpdb->prepare(
1135 "SELECT COUNT(DISTINCT user_id) FROM {$table} WHERE created_at >= %s AND user_id IS NOT NULL",
1136 $last_7
1137 ));
1138
1139 return [
1140 'total_24h' => $total_24h,
1141 'failed_24h' => $failed_24h,
1142 'critical_7d' => $critical_7d,
1143 'unique_users_7d' => $unique_users_7d,
1144 ];
1145 }
1146
1147 public function get_events_over_time(int $days = 14): array
1148 {
1149 global $wpdb;
1150
1151 $table = Activity_Log_DB::get_table();
1152 $end = strtotime('today', time());
1153 $start = $end - (($days - 1) * DAY_IN_SECONDS);
1154
1155 $start_sql = gmdate('Y-m-d H:i:s', $start);
1156 $rows = $wpdb->get_results($wpdb->prepare(
1157 "SELECT DATE(created_at) AS day, COUNT(*) AS total
1158 FROM {$table}
1159 WHERE created_at >= %s
1160 GROUP BY day
1161 ORDER BY day ASC",
1162 $start_sql
1163 ));
1164
1165 $map = [];
1166 foreach ($rows as $row) {
1167 $map[$row->day] = (int) $row->total;
1168 }
1169
1170 $series = [];
1171 for ($i = 0; $i < $days; $i++) {
1172 $day = gmdate('Y-m-d', $start + ($i * DAY_IN_SECONDS));
1173 $series[] = [
1174 'date' => $day,
1175 'count' => $map[$day] ?? 0,
1176 ];
1177 }
1178
1179 return $series;
1180 }
1181
1182 public function get_top_events(int $limit = 6): array
1183 {
1184 global $wpdb;
1185
1186 $table = Activity_Log_DB::get_table();
1187 $last_7 = gmdate('Y-m-d H:i:s', time() - (7 * DAY_IN_SECONDS));
1188
1189 return $wpdb->get_results($wpdb->prepare(
1190 "SELECT event_key, COUNT(*) AS total
1191 FROM {$table}
1192 WHERE created_at >= %s
1193 GROUP BY event_key
1194 ORDER BY total DESC
1195 LIMIT %d",
1196 $last_7,
1197 $limit
1198 ));
1199 }
1200
1201 public function get_top_users(int $limit = 6): array
1202 {
1203 global $wpdb;
1204
1205 $table = Activity_Log_DB::get_table();
1206 $last_7 = gmdate('Y-m-d H:i:s', time() - (7 * DAY_IN_SECONDS));
1207
1208 return $wpdb->get_results($wpdb->prepare(
1209 "SELECT user_login, COUNT(*) AS total
1210 FROM {$table}
1211 WHERE created_at >= %s AND user_login IS NOT NULL AND user_login != ''
1212 GROUP BY user_login
1213 ORDER BY total DESC
1214 LIMIT %d",
1215 $last_7,
1216 $limit
1217 ));
1218 }
1219
1220 public function get_recent_users_for_filter(): array
1221 {
1222 global $wpdb;
1223
1224 $table = Activity_Log_DB::get_table();
1225 $rows = $wpdb->get_results(
1226 "SELECT DISTINCT user_id, user_login
1227 FROM {$table}
1228 WHERE user_id IS NOT NULL AND user_login IS NOT NULL
1229 ORDER BY created_at DESC
1230 LIMIT 50"
1231 );
1232
1233 $users = [];
1234 foreach ($rows as $row) {
1235 $users[$row->user_id] = $row->user_login;
1236 }
1237
1238 return $users;
1239 }
1240
1241 private function build_where_clause(array $filters): array
1242 {
1243 global $wpdb;
1244
1245 $where = 'WHERE 1=1';
1246 $params = [];
1247
1248 if (!empty($filters['search'])) {
1249 $like = '%' . $wpdb->esc_like($filters['search']) . '%';
1250 $where .= " AND (event_key LIKE %s OR message LIKE %s OR object_title LIKE %s OR user_login LIKE %s OR ip LIKE %s)";
1251 $params[] = $like;
1252 $params[] = $like;
1253 $params[] = $like;
1254 $params[] = $like;
1255 $params[] = $like;
1256 }
1257
1258 if (!empty($filters['event_key'])) {
1259 $where .= ' AND event_key = %s';
1260 $params[] = $filters['event_key'];
1261 }
1262
1263 if (!empty($filters['severity'])) {
1264 $where .= ' AND severity = %s';
1265 $params[] = $filters['severity'];
1266 }
1267
1268 if (!empty($filters['user_id'])) {
1269 $where .= ' AND user_id = %d';
1270 $params[] = (int) $filters['user_id'];
1271 }
1272
1273 if (!empty($filters['date_from'])) {
1274 $where .= ' AND created_at >= %s';
1275 $params[] = $filters['date_from'];
1276 }
1277
1278 if (!empty($filters['date_to'])) {
1279 $where .= ' AND created_at <= %s';
1280 $params[] = $filters['date_to'];
1281 }
1282
1283 if (!empty($filters['ip']) && $this->is_pro()) {
1284 $where .= ' AND ip = %s';
1285 $params[] = $filters['ip'];
1286 }
1287
1288 return [$where, $params];
1289 }
1290
1291 private function get_filters_from_request(): array
1292 {
1293 $filters = [
1294 'search' => isset($_GET['s']) ? sanitize_text_field(wp_unslash($_GET['s'])) : '',
1295 'event_key' => isset($_GET['event_key']) ? sanitize_text_field(wp_unslash($_GET['event_key'])) : '',
1296 'severity' => isset($_GET['severity']) ? sanitize_text_field(wp_unslash($_GET['severity'])) : '',
1297 'user_id' => isset($_GET['user_id']) ? absint($_GET['user_id']) : 0,
1298 'date_from' => isset($_GET['date_from']) ? sanitize_text_field(wp_unslash($_GET['date_from'])) : '',
1299 'date_to' => isset($_GET['date_to']) ? sanitize_text_field(wp_unslash($_GET['date_to'])) : '',
1300 'ip' => isset($_GET['ip']) ? sanitize_text_field(wp_unslash($_GET['ip'])) : '',
1301 ];
1302
1303 if ($filters['date_from'] !== '') {
1304 $filters['date_from'] = $this->normalize_date($filters['date_from'], false);
1305 }
1306
1307 if ($filters['date_to'] !== '') {
1308 $filters['date_to'] = $this->normalize_date($filters['date_to'], true);
1309 }
1310
1311 return $filters;
1312 }
1313
1314 private function apply_retention_limit(array $filters): array
1315 {
1316 $days = $this->get_retention_days();
1317 $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
1318
1319 if (empty($filters['date_from']) || strtotime($filters['date_from']) < strtotime($cutoff)) {
1320 $filters['date_from'] = $cutoff;
1321 }
1322
1323 return $filters;
1324 }
1325
1326 private function normalize_date(string $date, bool $end_of_day): string
1327 {
1328 $date = preg_replace('/[^0-9\\-]/', '', $date);
1329 $time = $end_of_day ? '23:59:59' : '00:00:00';
1330 $local = $date . ' ' . $time;
1331 return get_gmt_from_date($local, 'Y-m-d H:i:s');
1332 }
1333
1334 private function get_context(): string
1335 {
1336 if (defined('WP_CLI') && WP_CLI) {
1337 return 'wp_cli';
1338 }
1339
1340 if (defined('REST_REQUEST') && REST_REQUEST) {
1341 return 'rest';
1342 }
1343
1344 if (wp_doing_cron()) {
1345 return 'cron';
1346 }
1347
1348 return is_admin() ? 'admin' : 'frontend';
1349 }
1350
1351 private function get_user_role($user): string
1352 {
1353 $roles = $user->roles ?? [];
1354 return $roles ? (string) $roles[0] : '';
1355 }
1356
1357 private function get_user_agent(): ?string
1358 {
1359 $agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
1360 return $agent !== '' ? $agent : null;
1361 }
1362
1363 private function get_ip_for_storage(): ?string
1364 {
1365 $ip = $this->get_client_ip();
1366 if ($ip === '') {
1367 return null;
1368 }
1369
1370 $storage = $this->settings['ip_storage'] ?? 'full';
1371 if ($storage === 'masked') {
1372 return $this->mask_ip($ip);
1373 }
1374
1375 if ($storage === 'hashed') {
1376 return hash('sha256', $ip);
1377 }
1378
1379 return $ip;
1380 }
1381
1382 private function get_client_ip(): string
1383 {
1384 $ip = '';
1385
1386 if (!empty($this->settings['trust_proxy_headers']) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1387 $forwarded = explode(',', (string) wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
1388 foreach ($forwarded as $candidate) {
1389 $candidate = trim($candidate);
1390 if (filter_var($candidate, FILTER_VALIDATE_IP)) {
1391 $ip = $candidate;
1392 break;
1393 }
1394 }
1395 }
1396
1397 if ($ip === '' && !empty($_SERVER['REMOTE_ADDR']) && filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP)) {
1398 $ip = (string) $_SERVER['REMOTE_ADDR'];
1399 }
1400
1401 return $ip;
1402 }
1403
1404 private function mask_ip(string $ip): string
1405 {
1406 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
1407 $parts = explode('.', $ip);
1408 $parts[3] = '0';
1409 return implode('.', $parts);
1410 }
1411
1412 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
1413 $parts = explode(':', $ip);
1414 $parts = array_pad($parts, 8, '0000');
1415 $parts[7] = '0000';
1416 $parts[6] = '0000';
1417 return implode(':', $parts);
1418 }
1419
1420 return $ip;
1421 }
1422
1423 private function sanitize_severity(string $severity): string
1424 {
1425 $allowed = ['info', 'notice', 'warning', 'critical'];
1426 return in_array($severity, $allowed, true) ? $severity : 'info';
1427 }
1428
1429 public function sanitize_settings($settings): array
1430 {
1431 $defaults = $this->get_default_settings();
1432
1433 if (!is_array($settings)) {
1434 $settings = [];
1435 }
1436
1437 $modules = $settings['modules'] ?? [];
1438 $clean_modules = [];
1439 foreach ($defaults['modules'] as $key => $value) {
1440 $clean_modules[$key] = !empty($modules[$key]);
1441 }
1442
1443 if (!$this->is_pro()) {
1444 $clean_modules['settings'] = false;
1445 $clean_modules['woocommerce'] = false;
1446 $clean_modules['king_addons'] = false;
1447 }
1448
1449 $clean = [
1450 'enabled' => !empty($settings['enabled']),
1451 'timezone' => in_array($settings['timezone'] ?? 'site', ['site', 'utc'], true) ? $settings['timezone'] : 'site',
1452 'rows_per_page' => max(10, min(200, absint($settings['rows_per_page'] ?? $defaults['rows_per_page']))),
1453 'retention_days' => max(1, absint($settings['retention_days'] ?? $defaults['retention_days'])),
1454 'ip_storage' => in_array($settings['ip_storage'] ?? 'full', ['full', 'masked', 'hashed'], true) ? $settings['ip_storage'] : 'full',
1455 'store_user_agent' => !empty($settings['store_user_agent']),
1456 'trust_proxy_headers' => !empty($settings['trust_proxy_headers']),
1457 'view_logs_capability' => $this->sanitize_view_logs_capability($settings['view_logs_capability'] ?? $defaults['view_logs_capability']),
1458 'modules' => $clean_modules,
1459 'exclude_roles' => $this->sanitize_list($settings['exclude_roles'] ?? ''),
1460 'exclude_user_ids' => $this->sanitize_id_list($settings['exclude_user_ids'] ?? ''),
1461 'exclude_event_keys' => $this->sanitize_event_keys_list($settings['exclude_event_keys'] ?? ''),
1462 'alerts' => $this->sanitize_alerts($settings['alerts'] ?? []),
1463 ];
1464
1465 if (!$this->is_pro() && $clean['retention_days'] > 14) {
1466 $clean['retention_days'] = 14;
1467 }
1468
1469 if (!$this->is_pro()) {
1470 $clean['view_logs_capability'] = 'manage_options';
1471 }
1472
1473 return $clean;
1474 }
1475
1476 private function sanitize_view_logs_capability($capability): string
1477 {
1478 $capability = sanitize_key((string) $capability);
1479
1480 $allowed = [
1481 'manage_options',
1482 'edit_pages',
1483 'edit_posts',
1484 'read',
1485 ];
1486
1487 return in_array($capability, $allowed, true) ? $capability : 'manage_options';
1488 }
1489
1490 private function sanitize_alerts(array $alerts): array
1491 {
1492 return [
1493 'failed_login_enabled' => !empty($alerts['failed_login_enabled']),
1494 'failed_login_threshold' => max(1, min(50, absint($alerts['failed_login_threshold'] ?? 5))),
1495 'failed_login_window' => max(1, min(60, absint($alerts['failed_login_window'] ?? 10))),
1496 'failed_login_emails' => sanitize_text_field((string) ($alerts['failed_login_emails'] ?? '')),
1497 ];
1498 }
1499
1500 private function sanitize_list($value): array
1501 {
1502 if (is_array($value)) {
1503 $items = $value;
1504 } else {
1505 $items = explode(',', (string) $value);
1506 }
1507
1508 $items = array_map('trim', $items);
1509 $items = array_filter($items);
1510 $items = array_map('sanitize_key', $items);
1511 $items = array_filter($items);
1512
1513 return array_values(array_unique($items));
1514 }
1515
1516 private function sanitize_id_list($value): array
1517 {
1518 if (is_array($value)) {
1519 $items = $value;
1520 } else {
1521 $items = explode(',', (string) $value);
1522 }
1523
1524 $ids = array_map('absint', $items);
1525 $ids = array_filter($ids);
1526
1527 return array_values(array_unique($ids));
1528 }
1529
1530 private function sanitize_event_keys_list($value): array
1531 {
1532 if (is_array($value)) {
1533 $items = $value;
1534 } else {
1535 $items = explode(',', (string) $value);
1536 }
1537
1538 $items = array_map('trim', $items);
1539 $items = array_filter($items);
1540 $items = array_map(function ($item) {
1541 $item = strtolower($item);
1542 return preg_replace('/[^a-z0-9._-]/', '', $item);
1543 }, $items);
1544 $items = array_filter($items);
1545
1546 return array_values(array_unique($items));
1547 }
1548
1549 public function get_default_settings(): array
1550 {
1551 return [
1552 'enabled' => false,
1553 'timezone' => 'site',
1554 'rows_per_page' => 20,
1555 'retention_days' => 14,
1556 'ip_storage' => 'full',
1557 'store_user_agent' => true,
1558 'trust_proxy_headers' => false,
1559 'view_logs_capability' => 'manage_options',
1560 'modules' => [
1561 'auth' => true,
1562 'content' => true,
1563 'users' => true,
1564 'plugins_themes' => true,
1565 'settings' => false,
1566 'woocommerce' => false,
1567 'king_addons' => false,
1568 ],
1569 'exclude_roles' => [],
1570 'exclude_user_ids' => [],
1571 'exclude_event_keys' => [],
1572 'alerts' => [
1573 'failed_login_enabled' => false,
1574 'failed_login_threshold' => 5,
1575 'failed_login_window' => 10,
1576 'failed_login_emails' => '',
1577 ],
1578 ];
1579 }
1580
1581 private function get_view_logs_capability(): string
1582 {
1583 $capability = $this->settings['view_logs_capability'] ?? 'manage_options';
1584 return $this->sanitize_view_logs_capability($capability);
1585 }
1586
1587 public function get_settings(): array
1588 {
1589 $defaults = $this->get_default_settings();
1590 $saved = get_option(self::OPTION_NAME, []);
1591
1592 $settings = wp_parse_args($saved, $defaults);
1593 $settings['modules'] = wp_parse_args($settings['modules'] ?? [], $defaults['modules']);
1594
1595 if (!isset($settings['exclude_roles']) || !is_array($settings['exclude_roles'])) {
1596 $settings['exclude_roles'] = $defaults['exclude_roles'];
1597 }
1598 if (!isset($settings['exclude_user_ids']) || !is_array($settings['exclude_user_ids'])) {
1599 $settings['exclude_user_ids'] = $defaults['exclude_user_ids'];
1600 }
1601 if (!isset($settings['exclude_event_keys']) || !is_array($settings['exclude_event_keys'])) {
1602 $settings['exclude_event_keys'] = $defaults['exclude_event_keys'];
1603 }
1604 if (!isset($settings['alerts']) || !is_array($settings['alerts'])) {
1605 $settings['alerts'] = $defaults['alerts'];
1606 }
1607
1608 return $settings;
1609 }
1610
1611 public function format_time(string $gmt): string
1612 {
1613 $timestamp = strtotime($gmt . ' UTC');
1614 if (($this->settings['timezone'] ?? 'site') === 'utc') {
1615 return gmdate('Y-m-d H:i:s', $timestamp);
1616 }
1617
1618 return wp_date('Y-m-d H:i:s', $timestamp);
1619 }
1620
1621 public function get_event_labels(): array
1622 {
1623 return [
1624 'auth.login.success' => __('Login success', 'king-addons'),
1625 'auth.login.failed' => __('Login failed', 'king-addons'),
1626 'auth.logout' => __('Logout', 'king-addons'),
1627 'user.created' => __('User created', 'king-addons'),
1628 'user.updated' => __('User updated', 'king-addons'),
1629 'user.deleted' => __('User deleted', 'king-addons'),
1630 'user.role_changed' => __('Role changed', 'king-addons'),
1631 'content.created' => __('Content created', 'king-addons'),
1632 'content.updated' => __('Content updated', 'king-addons'),
1633 'content.trashed' => __('Content trashed', 'king-addons'),
1634 'content.restored' => __('Content restored', 'king-addons'),
1635 'content.deleted' => __('Content deleted', 'king-addons'),
1636 'plugin.activated' => __('Plugin activated', 'king-addons'),
1637 'plugin.deactivated' => __('Plugin deactivated', 'king-addons'),
1638 'plugin.updated' => __('Plugin updated', 'king-addons'),
1639 'theme.switched' => __('Theme switched', 'king-addons'),
1640 'settings.option.updated' => __('Setting updated', 'king-addons'),
1641 'settings.option.added' => __('Setting added', 'king-addons'),
1642 'settings.option.deleted' => __('Setting deleted', 'king-addons'),
1643 'woocommerce.order.created' => __('Order created', 'king-addons'),
1644 'woocommerce.order.status_changed' => __('Order status changed', 'king-addons'),
1645 'woocommerce.product.stock_changed' => __('Product stock updated', 'king-addons'),
1646 'kng.option.updated' => __('King Addons setting updated', 'king-addons'),
1647 'kng.option.added' => __('King Addons setting added', 'king-addons'),
1648 'kng.option.deleted' => __('King Addons setting deleted', 'king-addons'),
1649 ];
1650 }
1651
1652 private function get_plugin_name(string $plugin): string
1653 {
1654 if (!function_exists('get_plugin_data')) {
1655 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1656 }
1657
1658 $file = WP_PLUGIN_DIR . '/' . $plugin;
1659 if (file_exists($file)) {
1660 $data = get_plugin_data($file, false, false);
1661 if (!empty($data['Name'])) {
1662 return $data['Name'];
1663 }
1664 }
1665
1666 return $plugin;
1667 }
1668
1669 private function get_retention_days(): int
1670 {
1671 $days = (int) ($this->settings['retention_days'] ?? 14);
1672 if (!$this->is_pro()) {
1673 $days = min($days, 14);
1674 }
1675
1676 return max(1, $days);
1677 }
1678
1679 private function escape_csv_value(string $value): string
1680 {
1681 if (preg_match('/^[=+\\-@]/', $value)) {
1682 return "'" . $value;
1683 }
1684 return $value;
1685 }
1686
1687 private function is_pro(): bool
1688 {
1689 if (function_exists('king_addons_can_use_pro')) {
1690 return king_addons_can_use_pro();
1691 }
1692
1693 if (!function_exists('king_addons_freemius')) {
1694 return false;
1695 }
1696
1697 $fs = king_addons_freemius();
1698 if (!is_object($fs)) {
1699 return false;
1700 }
1701
1702 if (method_exists($fs, 'can_use_premium_code__premium_only')) {
1703 return (bool) $fs->can_use_premium_code__premium_only();
1704 }
1705
1706 if (method_exists($fs, 'can_use_premium_code')) {
1707 return (bool) $fs->can_use_premium_code();
1708 }
1709
1710 return false;
1711 }
1712
1713 private function redirect_with_message(string $view, string $message): void
1714 {
1715 $args = [
1716 'page' => 'king-addons-activity-log',
1717 'view' => $view,
1718 'message' => $message,
1719 ];
1720
1721 wp_safe_redirect(add_query_arg($args, admin_url('admin.php')));
1722 exit;
1723 }
1724 }
1725