settings(); if ( ! empty( $s['log_logins'] ) ) { add_action( 'wp_login', array( $this, 'on_login' ), 10, 2 ); add_action( 'wp_logout', array( $this, 'on_logout' ) ); add_action( 'wp_login_failed', array( $this, 'on_login_failed' ) ); } if ( ! empty( $s['log_post_changes'] ) ) { add_action( 'transition_post_status', array( $this, 'on_post_transition' ), 10, 3 ); add_action( 'before_delete_post', array( $this, 'on_post_delete' ), 10, 2 ); } if ( ! empty( $s['log_plugin_changes'] ) ) { add_action( 'activated_plugin', array( $this, 'on_plugin_activated' ), 10, 2 ); add_action( 'deactivated_plugin', array( $this, 'on_plugin_deactivated' ), 10, 2 ); add_action( 'upgrader_process_complete', array( $this, 'on_upgrader_complete' ), 10, 2 ); } if ( ! empty( $s['log_theme_changes'] ) ) { add_action( 'switch_theme', array( $this, 'on_theme_switch' ), 10, 3 ); } if ( ! empty( $s['log_user_changes'] ) ) { add_action( 'user_register', array( $this, 'on_user_register' ) ); add_action( 'delete_user', array( $this, 'on_user_delete' ), 10, 2 ); add_action( 'profile_update', array( $this, 'on_profile_update' ), 10, 2 ); add_action( 'set_user_role', array( $this, 'on_role_change' ), 10, 3 ); } if ( ! empty( $s['log_option_changes'] ) ) { add_action( 'admin_init', array( $this, 'detect_settings_page_save' ), 1 ); add_action( 'updated_option', array( $this, 'on_option_updated' ), 10, 3 ); add_action( 'added_option', array( $this, 'on_option_added' ), 10, 2 ); add_action( 'deleted_option', array( $this, 'on_option_deleted' ), 10, 1 ); add_action( 'shutdown', array( $this, 'finalize_settings_page_save' ), 1 ); } } /** * Track in-request state for settings-page form submissions so we * can emit ONE high-level "Settings page saved" entry per request * and tag each child option change with its parent page. * * @var array|null */ protected $settings_save_context = null; protected $settings_save_changes = array(); public function on_login( $user_login, $user ) { APWPM_Logger::log( array( 'action_type' => 'user_login', 'object_type' => 'user', 'object_id' => $user instanceof WP_User ? $user->ID : 0, 'object_title' => (string) $user_login, 'description' => sprintf( /* translators: %s: user login */ __( '%s logged in.', 'activitypilot' ), $user_login ), 'severity' => 'success', 'user_id' => $user instanceof WP_User ? $user->ID : 0, ) ); } public function on_logout() { $user = wp_get_current_user(); if ( ! $user || ! $user->ID ) { return; } APWPM_Logger::log( array( 'action_type' => 'user_logout', 'object_type' => 'user', 'object_id' => $user->ID, 'object_title' => $user->user_login, 'description' => sprintf( /* translators: %s: user login */ __( '%s logged out.', 'activitypilot' ), $user->user_login ), 'severity' => 'info', ) ); } public function on_login_failed( $username ) { APWPM_Logger::log( array( 'action_type' => 'user_login_failed', 'object_type' => 'user', 'object_title' => (string) $username, 'description' => sprintf( /* translators: %s: user login */ __( 'Failed login attempt for "%s".', 'activitypilot' ), $username ), 'severity' => 'warning', 'user_id' => 0, ) ); } public function on_post_transition( $new_status, $old_status, $post ) { if ( ! $post instanceof WP_Post ) { return; } // Filter out auto-drafts / revisions noise. if ( in_array( $post->post_status, array( 'auto-draft', 'inherit' ), true ) ) { return; } if ( wp_is_post_revision( $post ) ) { return; } if ( $new_status === $old_status ) { return; } $action = 'post_updated'; if ( 'publish' === $new_status && 'publish' !== $old_status ) { $action = 'post_published'; } elseif ( 'trash' === $new_status ) { $action = 'post_trashed'; } elseif ( 'auto-draft' === $old_status && 'draft' === $new_status ) { $action = 'post_created'; } APWPM_Logger::log( array( 'action_type' => $action, 'object_type' => $post->post_type, 'object_id' => $post->ID, 'object_title' => $post->post_title ? $post->post_title : '(no title)', 'description' => sprintf( /* translators: 1: post type, 2: old status, 3: new status */ __( '%1$s status changed from %2$s to %3$s.', 'activitypilot' ), $post->post_type, $old_status, $new_status ), 'severity' => 'info', 'context' => array( 'old_status' => $old_status, 'new_status' => $new_status, ), ) ); } public function on_post_delete( $postid, $post ) { if ( ! $post instanceof WP_Post ) { return; } if ( wp_is_post_revision( $post ) ) { return; } APWPM_Logger::log( array( 'action_type' => 'post_deleted', 'object_type' => $post->post_type, 'object_id' => $post->ID, 'object_title' => $post->post_title, 'description' => sprintf( /* translators: 1: post type, 2: title */ __( '%1$s "%2$s" permanently deleted.', 'activitypilot' ), $post->post_type, $post->post_title ), 'severity' => 'warning', ) ); } public function on_plugin_activated( $plugin, $network_wide ) { APWPM_Logger::log( array( 'action_type' => 'plugin_activated', 'object_type' => 'plugin', 'object_title' => (string) $plugin, 'description' => sprintf( /* translators: %s: plugin file */ __( 'Plugin activated: %s', 'activitypilot' ), $plugin ), 'severity' => 'success', 'context' => array( 'network_wide' => (bool) $network_wide ), ) ); } public function on_plugin_deactivated( $plugin, $network_wide ) { APWPM_Logger::log( array( 'action_type' => 'plugin_deactivated', 'object_type' => 'plugin', 'object_title' => (string) $plugin, 'description' => sprintf( /* translators: %s: plugin file */ __( 'Plugin deactivated: %s', 'activitypilot' ), $plugin ), 'severity' => 'warning', 'context' => array( 'network_wide' => (bool) $network_wide ), ) ); } public function on_upgrader_complete( $upgrader, $options ) { if ( empty( $options['type'] ) ) { return; } APWPM_Logger::log( array( 'action_type' => $options['type'] . '_upgraded', 'object_type' => $options['type'], 'object_title' => isset( $options['action'] ) ? (string) $options['action'] : '', 'description' => sprintf( /* translators: 1: type, 2: action */ __( '%1$s %2$s completed.', 'activitypilot' ), $options['type'], isset( $options['action'] ) ? $options['action'] : '' ), 'severity' => 'info', 'context' => $options, ) ); } public function on_theme_switch( $new_name, $new_theme, $old_theme ) { APWPM_Logger::log( array( 'action_type' => 'theme_switched', 'object_type' => 'theme', 'object_title' => (string) $new_name, 'description' => sprintf( /* translators: 1: old theme, 2: new theme */ __( 'Theme switched from "%1$s" to "%2$s".', 'activitypilot' ), $old_theme instanceof WP_Theme ? $old_theme->get( 'Name' ) : '', $new_name ), 'severity' => 'warning', 'context' => array( 'old_name' => $old_theme instanceof WP_Theme ? $old_theme->get( 'Name' ) : '', 'old_stylesheet' => $old_theme instanceof WP_Theme ? $old_theme->get_stylesheet() : '', 'new_stylesheet' => $new_theme instanceof WP_Theme ? $new_theme->get_stylesheet() : '', ), ) ); } public function on_user_register( $user_id ) { $u = get_userdata( $user_id ); APWPM_Logger::log( array( 'action_type' => 'user_created', 'object_type' => 'user', 'object_id' => (int) $user_id, 'object_title' => $u ? $u->user_login : '', 'description' => sprintf( /* translators: %s: login */ __( 'New user registered: %s', 'activitypilot' ), $u ? $u->user_login : $user_id ), 'severity' => 'success', ) ); } public function on_user_delete( $id, $reassign ) { $u = get_userdata( $id ); APWPM_Logger::log( array( 'action_type' => 'user_deleted', 'object_type' => 'user', 'object_id' => (int) $id, 'object_title' => $u ? $u->user_login : (string) $id, 'description' => sprintf( /* translators: %s: login */ __( 'User deleted: %s', 'activitypilot' ), $u ? $u->user_login : $id ), 'severity' => 'critical', 'context' => array( 'reassigned_to' => (int) $reassign ), ) ); } public function on_profile_update( $user_id, $old_user_data ) { $u = get_userdata( $user_id ); APWPM_Logger::log( array( 'action_type' => 'user_updated', 'object_type' => 'user', 'object_id' => (int) $user_id, 'object_title' => $u ? $u->user_login : '', 'description' => __( 'User profile updated.', 'activitypilot' ), 'severity' => 'info', ) ); } public function on_role_change( $user_id, $new_role, $old_roles ) { $u = get_userdata( $user_id ); APWPM_Logger::log( array( 'action_type' => 'user_role_changed', 'object_type' => 'user', 'object_id' => (int) $user_id, 'object_title' => $u ? $u->user_login : '', 'description' => sprintf( /* translators: 1: user login, 2: old roles, 3: new role */ __( 'Role for %1$s changed from "%2$s" to "%3$s".', 'activitypilot' ), $u ? $u->user_login : $user_id, implode( ',', (array) $old_roles ), $new_role ), 'severity' => 'warning', 'context' => array( 'old_roles' => $old_roles, 'new_role' => $new_role, ), ) ); } /** * Detect a Settings API form submission and capture context (which * settings page, who initiated it). Hooked early on admin_init so * we run before WordPress processes options.php. */ public function detect_settings_page_save() { if ( empty( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) { return; } if ( empty( $_POST['option_page'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } $page = sanitize_key( wp_unslash( (string) $_POST['option_page'] ) ); if ( empty( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( (string) $_POST['_wpnonce'] ) ), $page . '-options' ) ) { return; } $this->settings_save_context = array( 'page' => $page, 'page_name' => self::format_settings_page_name( $page ), 'request' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', ); $this->settings_save_changes = array(); } /** * After WordPress finishes processing the settings submission, emit * one consolidated "Settings page saved" entry that lists every * option that actually changed. This is much nicer than 15 * individual rows when someone saves Discussion settings. */ public function finalize_settings_page_save() { if ( empty( $this->settings_save_context ) ) { return; } if ( empty( $this->settings_save_changes ) ) { $this->settings_save_context = null; return; } $ctx = $this->settings_save_context; $changed_keys = array_map( function ( $c ) { return $c['option']; }, $this->settings_save_changes ); APWPM_Logger::log( array( 'action_type' => 'settings_page_saved', 'object_type' => 'setting', 'object_title' => $ctx['page_name'], 'description' => sprintf( /* translators: 1: settings page name, 2: comma-separated option keys */ __( '%1$s settings saved (%2$s).', 'activitypilot' ), $ctx['page_name'], implode( ', ', array_slice( $changed_keys, 0, 8 ) ) . ( count( $changed_keys ) > 8 ? '…' : '' ) ), 'severity' => 'warning', 'context' => array( 'page' => $ctx['page'], 'changes' => $this->settings_save_changes, ), ) ); $this->settings_save_context = null; $this->settings_save_changes = array(); } public function on_option_updated( $option, $old_value, $value ) { $this->record_option_change( $option, $old_value, $value, 'updated' ); } public function on_option_added( $option, $value ) { $this->record_option_change( $option, null, $value, 'added' ); } public function on_option_deleted( $option ) { $this->record_option_change( $option, null, null, 'deleted' ); } /** * Central recorder for option changes. Decides whether a change is * worth logging based on three signals: * 1. The option is in the curated high-impact list. * 2. The change is happening inside a WP Settings page submission. * 3. A developer explicitly opted in via the `apwpm_watched_options` filter. * * Internal/noise options (transients, doing_cron, autoloaded caches, * plugin-version bookkeeping…) are always ignored. */ protected function record_option_change( $option, $old_value, $value, $verb ) { $option = (string) $option; if ( self::is_noise_option( $option ) ) { return; } $watched = self::watched_options(); $is_watched = in_array( $option, $watched, true ); $in_settings = ! empty( $this->settings_save_context ); if ( ! $is_watched && ! $in_settings ) { return; // boring background option update } $row = array( 'action_type' => 'option_' . $verb, 'object_type' => 'option', 'object_title' => $option, 'description' => $in_settings ? sprintf( /* translators: 1: settings page, 2: option name, 3: verb (updated/added/deleted) */ __( '%1$s settings: option "%2$s" %3$s.', 'activitypilot' ), $this->settings_save_context['page_name'], $option, $verb ) : sprintf( /* translators: 1: option name, 2: verb */ __( 'Site option "%1$s" %2$s.', 'activitypilot' ), $option, $verb ), 'severity' => 'warning', 'context' => array( 'old' => 'deleted' === $verb ? null : self::safe_value( $old_value ), 'new' => 'deleted' === $verb ? null : self::safe_value( $value ), 'settings_page' => $in_settings ? $this->settings_save_context['page'] : null, 'source' => $in_settings ? 'settings_page' : 'programmatic', ), ); APWPM_Logger::log( $row ); if ( $in_settings ) { $this->settings_save_changes[] = array( 'option' => $option, 'verb' => $verb, 'old' => self::safe_value( $old_value ), 'new' => self::safe_value( $value ), ); } } /** * Returns the curated list of always-logged WordPress options. * * Filterable via `apwpm_watched_options` so site owners and other * plugins can extend the list. */ protected static function watched_options() { $defaults = array( // General 'siteurl', 'home', 'blogname', 'blogdescription', 'admin_email', 'users_can_register', 'default_role', 'timezone_string', 'date_format', 'time_format', 'start_of_week', 'WPLANG', // Writing 'default_category', 'default_post_format', 'mailserver_url', 'mailserver_port', 'mailserver_login', 'default_email_category', 'ping_sites', // Reading 'posts_per_page', 'posts_per_rss', 'rss_use_excerpt', 'show_on_front', 'page_on_front', 'page_for_posts', 'blog_public', // Discussion 'default_pingback_flag', 'default_ping_status', 'default_comment_status', 'require_name_email', 'comment_registration', 'close_comments_for_old_posts', 'close_comments_days_old', 'thread_comments', 'thread_comments_depth', 'page_comments', 'comments_per_page', 'default_comments_page', 'comment_order', 'comments_notify', 'moderation_notify', 'comment_moderation', 'comment_previously_approved', 'comment_max_links', 'moderation_keys', 'disallowed_keys', 'show_avatars', 'avatar_rating', 'avatar_default', // Media 'thumbnail_size_w', 'thumbnail_size_h', 'thumbnail_crop', 'medium_size_w', 'medium_size_h', 'large_size_w', 'large_size_h', 'uploads_use_yearmonth_folders', // Permalinks 'permalink_structure', 'category_base', 'tag_base', // Theme / Plugins 'template', 'stylesheet', 'current_theme', 'active_plugins', ); return APWPM_Compat::filter( 'apwpm_watched_options', 'ap_watched_options', $defaults ); } /** * Options that should always be skipped — these are internal * WordPress bookkeeping that would flood the timeline with noise. */ protected static function is_noise_option( $option ) { // Plugin's own settings — avoid recursive logging. if ( 0 === strpos( $option, 'apwpm_' ) || 0 === strpos( $option, 'ap_' ) ) { return true; } // Standard noisy prefixes. $prefixes = array( '_transient_', '_site_transient_', 'auth_key', 'auth_salt', 'logged_in_key', 'logged_in_salt', 'nonce_key', 'nonce_salt', 'secure_auth_key', 'secure_auth_salt', 'rewrite_rules', ); foreach ( $prefixes as $p ) { if ( 0 === strpos( $option, $p ) ) { return true; } } // Exact-match noise list. $exact = array( 'cron', 'doing_cron', 'recently_activated', 'rewrite_rules', 'auto_updater.lock', 'core_updater.lock', 'finished_updating_comment_type', 'db_version', 'db_upgraded', 'fresh_site', 'initial_db_version', '_site_transient_update_' . 'core', '_site_transient_update_' . 'plugins', '_site_transient_update_' . 'themes', 'theme_mods_', 'wp_user_roles', // very large, rolled up via user role hooks ); if ( in_array( $option, $exact, true ) ) { return true; } // Site-wide theme mods option name varies — covers `theme_mods_`. if ( 0 === strpos( $option, 'theme_mods_' ) ) { return true; } return false; } /** * Pretty label for a given option_page slug. */ protected static function format_settings_page_name( $page ) { $map = array( 'general' => __( 'General', 'activitypilot' ), 'writing' => __( 'Writing', 'activitypilot' ), 'reading' => __( 'Reading', 'activitypilot' ), 'discussion' => __( 'Discussion', 'activitypilot' ), 'media' => __( 'Media', 'activitypilot' ), 'permalink' => __( 'Permalinks', 'activitypilot' ), 'privacy' => __( 'Privacy', 'activitypilot' ), 'options' => __( 'Options', 'activitypilot' ), ); if ( isset( $map[ $page ] ) ) { return $map[ $page ]; } // Custom settings page registered by a plugin. return ucwords( str_replace( array( '_', '-' ), ' ', $page ) ); } /** * Convert an option value into something JSON-safe for the log * context (and therefore reversible by the rollback engine). */ protected static function safe_value( $value ) { if ( is_scalar( $value ) || is_null( $value ) ) { return $value; } $encoded = wp_json_encode( $value ); if ( false === $encoded || strlen( $encoded ) > 16384 ) { return '(complex)'; } return $value; } }