post_status === 'auto-draft' || wp_is_post_revision($post_id)) { return; } // Skip navigation menu items - these should be handled by menu events setting if ($post->post_type === 'nav_menu_item') { // Check if menu event tracking is enabled for nav_menu_item posts if (get_option('actlogpro_track_menu_events', '1') !== '1') { return; } } // Skip WooCommerce post types regardless of whether the integration is active // This ensures WooCommerce events are only logged by the WooCommerce integration $woocommerce_post_types = array('product', 'product_variation', 'shop_order', 'shop_coupon', 'shop_order_refund', 'shop_order_placehold'); if (in_array($post->post_type, $woocommerce_post_types)) { return; // Let the WooCommerce integration handle this if it's active } // Skip excluded custom post types (Premium feature) $excluded_post_types = get_option('actlogpro_excluded_post_types', array()); if (!empty($excluded_post_types) && is_array($excluded_post_types) && in_array($post->post_type, $excluded_post_types, true)) { return; } self::log_activity( 'post', $post->post_type, $post_id, $post->post_title, 'create', sprintf( /* translators: %1$s: post type, %2$s: post title */ __('Created new %1$s: "%2$s"', 'activity-log-pro'), $post->post_type, $post->post_title ), array( 'post_status' => $post->post_status ) ); } /** * Log post update * * @param int $post_id Post ID * @param WP_Post $post_after Post object following the update * @param WP_Post $post_before Post object before the update */ public function log_post_update($post_id, $post_after, $post_before) { // Don't log revisions or auto-drafts if (wp_is_post_revision($post_id) || $post_after->post_status === 'auto-draft') { return; } // Don't log new posts (they're handled by log_post_creation) if ($post_before->post_status === 'auto-draft' || $post_before->post_status === 'new') { return; } // Skip navigation menu items - these should be handled by menu events setting if ($post_after->post_type === 'nav_menu_item') { // Check if menu event tracking is enabled for nav_menu_item posts if (get_option('actlogpro_track_menu_events', '1') !== '1') { return; } } // Skip WooCommerce post types regardless of whether the integration is active // This ensures WooCommerce events are only logged by the WooCommerce integration $woocommerce_post_types = array('product', 'product_variation', 'shop_order', 'shop_coupon', 'shop_order_refund', 'shop_order_placehold'); if (in_array($post_after->post_type, $woocommerce_post_types)) { return; // Let the WooCommerce integration handle this if it's active } // Skip excluded custom post types (Premium feature) $excluded_post_types = get_option('actlogpro_excluded_post_types', array()); if (!empty($excluded_post_types) && is_array($excluded_post_types) && in_array($post_after->post_type, $excluded_post_types, true)) { return; } // Check if title changed if ($post_before->post_title !== $post_after->post_title) { self::log_activity( 'post', $post_after->post_type, $post_id, $post_after->post_title, 'update', sprintf( /* translators: %1$s: old title, %2$s: new title */ __('Title changed from "%1$s" to "%2$s"', 'activity-log-pro'), $post_before->post_title, $post_after->post_title ), array( 'old_title' => $post_before->post_title, 'new_title' => $post_after->post_title, 'post_status' => $post_after->post_status ) ); } // Check if content changed if ($post_before->post_content !== $post_after->post_content) { self::log_activity( 'post', $post_after->post_type, $post_id, $post_after->post_title, 'update', sprintf( /* translators: %s: post title */ __('Content updated for "%s"', 'activity-log-pro'), $post_after->post_title ), array( 'post_type' => $post_after->post_type, 'post_status' => $post_after->post_status ) ); } } /** * Log post deletion * * @param int $post_id Post ID */ public function log_post_deletion($post_id) { $post = get_post($post_id); // Don't log if post doesn't exist or is a revision if (!$post || wp_is_post_revision($post_id)) { return; } // Skip navigation menu items - these should be handled by menu events setting if ($post->post_type === 'nav_menu_item') { // Check if menu event tracking is enabled for nav_menu_item posts if (get_option('actlogpro_track_menu_events', '1') !== '1') { return; } } // Skip WooCommerce post types regardless of whether the integration is active // This ensures WooCommerce events are only logged by the WooCommerce integration $woocommerce_post_types = array('product', 'product_variation', 'shop_order', 'shop_coupon', 'shop_order_refund', 'shop_order_placehold'); if (in_array($post->post_type, $woocommerce_post_types)) { return; // Let the WooCommerce integration handle this if it's active } // Skip attachment post type to prevent duplicate logging with media_deleted in ACTLOGPRO_Events if ($post->post_type === 'attachment') { return; // Let the ACTLOGPRO_Events class handle media deletions exclusively } self::log_activity( 'post', $post->post_type, $post_id, $post->post_title, 'delete', sprintf( /* translators: %1$s: post type, %2$s: post title */ __('Deleted %1$s: "%2$s"', 'activity-log-pro'), $post->post_type, $post->post_title ), array( 'post_status' => $post->post_status ) ); } /** * Log post status change * * @param string $new_status New post status * @param string $old_status Old post status * @param WP_Post $post Post object */ public function log_post_status_change($new_status, $old_status, $post) { // Don't log revisions if (wp_is_post_revision($post->ID)) { return; } // Skip navigation menu items - these should be handled by menu events setting if ($post->post_type === 'nav_menu_item') { // Check if menu event tracking is enabled for nav_menu_item posts if (get_option('actlogpro_track_menu_events', '1') !== '1') { return; } } // Skip WooCommerce post types regardless of whether the integration is active // This ensures WooCommerce events are only logged by the WooCommerce integration $woocommerce_post_types = array('product', 'product_variation', 'shop_order', 'shop_coupon', 'shop_order_refund', 'shop_order_placehold'); if (in_array($post->post_type, $woocommerce_post_types)) { return; // Let the WooCommerce integration handle this if it's active } // Don't log auto-drafts if ($old_status === 'auto-draft') { return; } // Don't log new posts (they're handled by log_post_creation) if ($old_status === 'new') { return; } // Only log if status actually changed if ($new_status !== $old_status) { self::log_activity( 'post', $post->post_type, $post->ID, $post->post_title, 'status', sprintf( /* translators: %1$s: old status, %2$s: new status, %3$s: post title */ __('Status changed from "%1$s" to "%2$s" for "%3$s"', 'activity-log-pro'), $old_status, $new_status, $post->post_title ), array( 'old_status' => $old_status, 'new_status' => $new_status ) ); } } /** * Log an activity with enhanced description that includes object ID * * @param string $event_type Type of event (post, user, etc.). * @param string $object_type Type of object (post, page, user, etc.). * @param int $object_id ID of the object. * @param string $object_name Name of the object. * @param string $action Action performed (created, updated, etc.). * @param string $description Description of the activity. * @param array $metadata Additional metadata. * @param bool $include_id Whether to include the object ID in the description (default: true). * @param string $severity Severity level (default: 'info'). * @return int|false The ID of the inserted log, or false on failure. */ public static function log_activity_with_id($event_type, $object_type, $object_id, $object_name, $action, $description, $metadata = array(), $include_id = true, $severity = 'info') { // Enhance description with object ID if requested and object_id is valid if ($include_id && !empty($object_id) && $object_id > 0) { // Check if description already contains the ID to avoid duplication if (!preg_match('/\(ID:\s*' . preg_quote($object_id, '/') . '\)/', $description)) { $description = $description . sprintf(' (ID: %d)', $object_id); } } return self::log_activity($event_type, $object_type, $object_id, $object_name, $action, $description, $metadata, $severity); } /** * Get network information from current request * * @return array Network information (user_agent, referer, request_method, request_uri) */ private static function get_network_info() { $network_info = array(); // User Agent - browser/client identifier if (!empty($_SERVER['HTTP_USER_AGENT'])) { $network_info['user_agent'] = sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])); } // Referer - page user came from if (!empty($_SERVER['HTTP_REFERER'])) { $network_info['referer'] = esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])); } // Request Method - HTTP method used if (!empty($_SERVER['REQUEST_METHOD'])) { $network_info['request_method'] = sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])); } // Request URI - specific URL path and query if (!empty($_SERVER['REQUEST_URI'])) { $network_info['request_uri'] = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])); } return $network_info; } /** * Determine whether the current user should be excluded from logging. * * Rules (in evaluation order): * 1. user_id = 0 (System/cron) → never excluded. * 2. Security event_type or security action → never excluded. * 3. Check all roles the user holds against the excluded-roles list. * 4. Check username and email against the excluded-users list. * * Result is cached per request to avoid repeated option reads. * * @param string $event_type Event type string (e.g. 'security', 'post'). * @param string $action Action string (e.g. 'login_failed'). * @return bool True if the event should be skipped, false if it should log. */ private static function is_user_excluded( string $event_type, string $action ): bool { // 1. Must have a real logged-in user. if ( ! function_exists( 'wp_get_current_user' ) ) { return false; } $current_user = wp_get_current_user(); // System / unauthenticated → never excluded. if ( ! $current_user->exists() || 0 === (int) $current_user->ID ) { return false; } // 2. Security events always pass through. $security_actions = array( 'login', 'logout', 'login_failed', 'login_failed_nonexistent' ); if ( 'security' === $event_type || in_array( $action, $security_actions, true ) ) { return false; } // 3 & 4. Load and cache the exclusion lists for this request. static $excluded_roles = null; static $excluded_users = null; if ( null === $excluded_roles ) { $excluded_roles = get_option( 'actlogpro_excluded_roles', array() ); if ( ! is_array( $excluded_roles ) ) { $excluded_roles = array(); } // Hard-guard: administrator role can never be excluded. $excluded_roles = array_diff( $excluded_roles, array( 'administrator' ) ); } if ( null === $excluded_users ) { $raw = get_option( 'actlogpro_excluded_users', '' ); $lines = array_filter( array_map( 'trim', explode( "\n", (string) $raw ) ) ); $excluded_users = array_map( 'strtolower', array_values( $lines ) ); } // 3. Match any of the user's roles against excluded roles. if ( ! empty( $excluded_roles ) && is_array( $current_user->roles ) ) { foreach ( $current_user->roles as $role ) { if ( in_array( $role, $excluded_roles, true ) ) { return true; } } } // 4. Match username or email (case-insensitive). if ( ! empty( $excluded_users ) ) { $user_login = strtolower( $current_user->user_login ); $user_email = strtolower( $current_user->user_email ); if ( in_array( $user_login, $excluded_users, true ) || in_array( $user_email, $excluded_users, true ) ) { return true; } } return false; } /** * Log an activity * * @param string $event_type Type of event (post, user, etc.). * @param string $object_type Type of object (post, page, user, etc.). * @param int $object_id ID of the object. * @param string $object_name Name of the object. * @param string $action Action performed (created, updated, etc.). * @param string $description Description of the activity. * @param array $metadata Additional metadata. * @param string $severity Severity level: critical|high|medium|low|info (default: 'info'). * @return int|false The ID of the inserted log, or false on failure. */ public static function log_activity($event_type, $object_type, $object_id, $object_name, $action, $description, $metadata = array(), $severity = 'info') { // Bail early if the current user is on the exclusion list. if ( self::is_user_excluded( $event_type, $action ) ) { return false; } // Enhance the description with object ID for certain object types $object_types_to_enhance = array('post', 'page', 'custom_post_type', 'user', 'media', 'plugin', 'theme', 'term'); if (in_array($object_type, $object_types_to_enhance) && !empty($object_id) && $object_id > 0) { // Check if description already contains the ID to avoid duplication if (!preg_match('/\(ID:\s*' . preg_quote($object_id, '/') . '\)/', $description)) { $description = $description . sprintf(' (ID: %d)', $object_id); } } // Check if WordPress user functions are available if (function_exists('wp_get_current_user')) { // Get current user information $current_user = wp_get_current_user(); $user_id = $current_user->ID; $user_login = $current_user->user_login; $user_display_name = $current_user->display_name; // If no user is logged in, use "System" as the user if (empty($user_id) || $user_id === 0) { $user_id = 0; $user_login = 'system'; $user_display_name = 'System'; $user_role = 'system'; } else { // Get user role for logged-in users $user_role = 'guest'; if (!empty($current_user->roles) && is_array($current_user->roles)) { $user_role = reset($current_user->roles); } } } else { // WordPress user functions not available yet (early hook), use system defaults $user_id = 0; $user_login = 'system'; $user_display_name = 'System'; $user_role = 'system'; } // Get IP address with security measures $ip_address = self::get_client_ip(); // Get network information and merge with provided metadata $network_info = self::get_network_info(); if (!empty($metadata) && is_array($metadata)) { $metadata = array_merge($metadata, $network_info); } else { $metadata = $network_info; } // Sanitize inputs $event_type = sanitize_text_field($event_type); $object_type = sanitize_text_field($object_type); $object_id = absint($object_id); $object_name = sanitize_text_field($object_name); $action = sanitize_text_field($action); $description = wp_kses_post($description); // Serialize metadata with security measures $metadata_serialized = ''; if (!empty($metadata) && is_array($metadata)) { $metadata_serialized = maybe_serialize(self::sanitize_array($metadata)); } // Prepare data for insertion $data = array( 'user_id' => $user_id, 'user_role' => $user_role, 'user_login' => $user_login, 'user_display_name' => $user_display_name, 'ip_address' => $ip_address, 'event_type' => $event_type, 'object_type' => $object_type, 'object_id' => $object_id, 'object_name' => $object_name, 'action' => $action, 'severity' => ACTLOGPRO_Severity::is_valid($severity) ? $severity : ACTLOGPRO_Severity::LEVEL_DEFAULT, 'description' => $description, 'metadata' => $metadata_serialized, 'created_at' => current_time('mysql') ); // Use the database abstraction layer to insert the log return ACTLOGPRO_Database::log_activity($data); } /** * Get client IP address with security measures * * @return string The client IP address (full or partially hidden based on settings) */ private static function get_client_ip() { $ip = ''; // Check for shared internet/ISP IP if (!empty($_SERVER['HTTP_CLIENT_IP'])) { $client_ip = sanitize_text_field(wp_unslash($_SERVER['HTTP_CLIENT_IP'])); if (self::validate_ip($client_ip)) { $ip = $client_ip; } } // Check for IPs passing through proxies elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { // Check if multiple IPs exist in HTTP_X_FORWARDED_FOR $forwarded_for = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])); $iplist = explode(',', $forwarded_for); foreach ($iplist as $possible_ip) { $possible_ip = trim($possible_ip); if (self::validate_ip($possible_ip)) { $ip = $possible_ip; break; } } } // Check for the remote address elseif (!empty($_SERVER['REMOTE_ADDR'])) { $remote_addr = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])); if (self::validate_ip($remote_addr)) { $ip = $remote_addr; } } // Check if we should hide part of the IP address for privacy // Always hide IP if premium is not enabled, or if setting is set to hide $should_hide_ip = !ACTLOGPRO_Premium_Helper::is_premium_enabled() || get_option('actlogpro_store_full_ip', '0') === '0'; if (!empty($ip) && $should_hide_ip) { // For IPv4, hide the last octet (e.g., 192.168.1.xxx) // For IPv6, hide the last 80 bits (last 20 hex chars) if (strpos($ip, ':') !== false) { // IPv6 address $ip = substr($ip, 0, strrpos($ip, ':')) . ':xxxx'; } else { // IPv4 address $ip = substr($ip, 0, strrpos($ip, '.')) . '.xxx'; } } return $ip; } /** * Public method to get client IP address * * @return string The client IP address */ public static function get_ip_address() { return self::get_client_ip(); } /** * Validate an IP address * * @param string $ip IP address to validate * @return bool True if valid, false otherwise */ private static function validate_ip($ip) { return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); } /** * Recursively sanitize an array * * @param array $array Array to sanitize * @return array Sanitized array */ private static function sanitize_array($array) { foreach ($array as $key => $value) { if (is_array($value)) { $array[$key] = self::sanitize_array($value); } else { $array[$key] = sanitize_text_field($value); } } return $array; } /** * Log an activity without requiring wp_get_current_user() * Used for early WordPress hooks where current user isn't available * * @param string $event_type Type of event (post, user, etc.). * @param string $object_type Type of object (post, page, user, etc.). * @param int $object_id ID of the object. * @param string $object_name Name of the object. * @param string $action Action performed (created, updated, etc.). * @param string $description Description of the activity. * @param array $metadata Additional metadata. * @param string $severity Severity level: critical|high|medium|low|info (default: 'info'). * @return int|false The ID of the inserted log, or false on failure. */ public static function log_activity_minimal($event_type, $object_type, $object_id, $object_name, $action, $description, $metadata = array(), $severity = 'info') { // Get IP address using the correct method name $ip_address = self::get_client_ip(); // Get network information and merge with provided metadata $network_info = self::get_network_info(); if (!empty($metadata) && is_array($metadata)) { $metadata = array_merge($metadata, $network_info); } else { $metadata = $network_info; } // Sanitize inputs $event_type = sanitize_text_field($event_type); $object_type = sanitize_text_field($object_type); $object_id = absint($object_id); $object_name = sanitize_text_field($object_name); $action = sanitize_text_field($action); $description = wp_kses_post($description); // Serialize metadata with security measures $metadata_serialized = ''; if (!empty($metadata) && is_array($metadata)) { $metadata_serialized = maybe_serialize(self::sanitize_array($metadata)); } // Prepare data for insertion $data = array( 'user_id' => 0, // System action 'user_login' => 'system', 'user_display_name' => 'System', 'user_role' => '', 'ip_address' => $ip_address, 'event_type' => $event_type, 'object_type' => $object_type, 'object_id' => $object_id, 'object_name' => $object_name, 'action' => $action, 'severity' => ACTLOGPRO_Severity::is_valid($severity) ? $severity : ACTLOGPRO_Severity::LEVEL_DEFAULT, 'description' => $description, 'metadata' => $metadata_serialized, 'created_at' => current_time('mysql') ); // Use the database abstraction layer to insert the log return ACTLOGPRO_Database::log_activity($data); } /** * Log an activity without automatic ID enhancement * Use this when you want to control the description format completely * * @param string $event_type Type of event (post, user, etc.). * @param string $object_type Type of object (post, page, user, etc.). * @param int $object_id ID of the object. * @param string $object_name Name of the object. * @param string $action Action performed (created, updated, etc.). * @param string $description Description of the activity. * @param array $metadata Additional metadata. * @param string $severity Severity level: critical|high|medium|low|info (default: 'info'). * @return int|false The ID of the inserted log, or false on failure. */ public static function log_activity_no_id($event_type, $object_type, $object_id, $object_name, $action, $description, $metadata = array(), $severity = 'info') { // Bail early if the current user is on the exclusion list. if ( self::is_user_excluded( $event_type, $action ) ) { return false; } // Get current user information $current_user = wp_get_current_user(); $user_id = $current_user->ID; $user_login = $current_user->user_login; $user_display_name = $current_user->display_name; // If no user is logged in, use "System" as the user if (empty($user_id) || $user_id === 0) { $user_id = 0; $user_login = 'system'; $user_display_name = 'System'; $user_role = 'system'; } else { // Get user role for logged-in users $user_role = 'guest'; if (!empty($current_user->roles) && is_array($current_user->roles)) { $user_role = reset($current_user->roles); } } // Get IP address with security measures $ip_address = self::get_client_ip(); // Get network information and merge with provided metadata $network_info = self::get_network_info(); if (!empty($metadata) && is_array($metadata)) { $metadata = array_merge($metadata, $network_info); } else { $metadata = $network_info; } // Sanitize inputs $event_type = sanitize_text_field($event_type); $object_type = sanitize_text_field($object_type); $object_id = absint($object_id); $object_name = sanitize_text_field($object_name); $action = sanitize_text_field($action); $description = wp_kses_post($description); // Serialize metadata with security measures $metadata_serialized = ''; if (!empty($metadata) && is_array($metadata)) { $metadata_serialized = maybe_serialize(self::sanitize_array($metadata)); } // Prepare data for insertion $data = array( 'user_id' => $user_id, 'user_role' => $user_role, 'user_login' => $user_login, 'user_display_name' => $user_display_name, 'ip_address' => $ip_address, 'event_type' => $event_type, 'object_type' => $object_type, 'object_id' => $object_id, 'object_name' => $object_name, 'action' => $action, 'severity' => ACTLOGPRO_Severity::is_valid($severity) ? $severity : ACTLOGPRO_Severity::LEVEL_DEFAULT, 'description' => $description, 'metadata' => $metadata_serialized, 'created_at' => current_time('mysql') ); // Use the database abstraction layer to insert the log return ACTLOGPRO_Database::log_activity($data); } /** * Log post activity on save (content updates). */ public function log_post_activity($post_id, $post, $update) { // Only log updates to published posts/pages if (!$update || $post->post_status !== 'publish') { return; } // Skip revisions and auto-drafts if ($post->post_type === 'revision' || $post->post_status === 'auto-draft') { return; } // Skip excluded custom post types (Premium feature) $excluded_post_types = get_option('actlogpro_excluded_post_types', array()); if (!empty($excluded_post_types) && is_array($excluded_post_types) && in_array($post->post_type, $excluded_post_types, true)) { return; } self::log_activity('post', $post->post_type, $post->ID, $post->post_title, 'updated', sprintf('Post "%s" updated', $post->post_title)); } } // Auto-instantiate to register post event hooks for content updates $actlogpro_logger = new ACTLOGPRO_Logger(); $actlogpro_logger->log_post_events();