seen[ $key ] ) ) { return; } $this->seen[ $key ] = true; ( new WebStudio_Admin_Watch_Activity_Repository() )->record( $type, null === $actor ? get_current_user_id() : $actor, $object_type, $id, $label, $data ); } public function login( $id ) { $this->record( 'login_succeeded', 'user', $id, '', array(), $id ); } public function password( $id ) { $this->pending_users[ 'password:' . $id ] = array( 'password_changed', 'user', $id, '', array(), get_current_user_id() ); } public function logout( $id ) { if ( $id ) { $this->record( 'logout', 'user', $id, '', array(), $id ); } } public function failed_login( $identifier ) { ( new WebStudio_Admin_Watch_Activity_Repository() )->record( 'login_failed', 0, '', 0, '', array(), $identifier ); } public function user_created( $id ) { unset( $this->pending_users[ 'password:' . $id ], $this->pending_users[ 'role:' . $id ] ); unset( $this->role_before[ $id ] ); $this->record( 'user_created', 'user', $id ); } public function user_deleted( $id, $reassign, $user ) { // Delete failed identifiers while the core-supplied account email is still available. if ( $user instanceof WP_User ) { $repository = new WebStudio_Admin_Watch_Activity_Repository(); global $wpdb; foreach ( array( $user->user_email, $user->user_login ) as $identifier ) { $subject = $repository::subject( $identifier ); if ( '' !== $subject ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Remove only this account's failed identifiers; wpdb prepares values. $wpdb->delete( $repository::table_name(), array( 'event_type' => 'login_failed', 'aggregation_subject' => $subject ) ); } } } $this->record( 'user_deleted', 'user', $id ); } /** Snapshot roles before native capability metadata changes; never retain capabilities. */ public function before_role_update( $check, $id, $key, $value, $previous ) { global $wpdb; if ( $wpdb->get_blog_prefix() . 'capabilities' === $key && ! isset( $this->role_before[ $id ] ) ) { $user = get_userdata( $id ); if ( $user ) { $this->role_before[ $id ] = $user->roles; } } return $check; } /** Retain the first before-state and latest after-state until the request is flushed. */ private function queue_role_change( $id, $old_roles, $new_roles ) { if ( ! isset( $this->role_before[ $id ] ) ) { $this->role_before[ $id ] = $old_roles; } $old_roles = array_values( array_unique( $this->role_before[ $id ] ) ); $new_roles = array_values( array_unique( $new_roles ) ); sort( $old_roles ); sort( $new_roles ); if ( $old_roles === $new_roles ) { unset( $this->pending_users[ 'role:' . $id ] ); return; } $this->pending_users[ 'role:' . $id ] = array( 'user_role_changed', 'user', $id, '', array( 'old_roles' => implode( ', ', $old_roles ), 'new_roles' => implode( ', ', $new_roles ) ), get_current_user_id() ); } public function role_changed( $id, $role, $old_roles ) { $this->queue_role_change( $id, $old_roles, $role ? array( $role ) : array() ); } public function role_added( $id, $role ) { $user = get_userdata( $id ); if ( $user ) { $this->queue_role_change( $id, array_diff( $user->roles, array( $role ) ), $user->roles ); } } public function role_removed( $id, $role ) { $user = get_userdata( $id ); if ( $user ) { $this->queue_role_change( $id, array_merge( $user->roles, array( $role ) ), $user->roles ); } } /** Consolidate nested user hooks in memory, retaining only IDs and known role slugs. */ public function flush_user_events() { $events = $this->pending_users; $this->pending_users = array(); $this->role_before = array(); foreach ( $events as $event ) { $this->record( ...$event ); } } private function relevant_content( $post ) { $type = $post instanceof WP_Post ? get_post_type_object( $post->post_type ) : null; return $type && $type->public && 'attachment' !== $post->post_type && ! wp_is_post_revision( $post->ID ) && ! wp_is_post_autosave( $post->ID ) && ! ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) && 'auto-draft' !== $post->post_status; } public function content_saved( $id, $post, $update, $before ) { if ( ! $this->relevant_content( $post ) ) { return; } $type = 'post_updated'; if ( ! $update || ( $before && 'auto-draft' === $before->post_status ) ) { $type = 'post_created'; } elseif ( $before && 'trash' === $post->post_status && 'trash' !== $before->post_status ) { $type = 'post_trashed'; } elseif ( $before && 'trash' === $before->post_status && 'trash' !== $post->post_status ) { $type = 'post_restored'; } elseif ( $before ) { $changed = false; foreach ( array( 'post_title', 'post_content', 'post_excerpt', 'post_status', 'post_name', 'post_parent', 'menu_order', 'post_author' ) as $field ) { if ( (string) $post->$field !== (string) $before->$field ) { $changed = true; break; } } if ( ! $changed ) { return; } } $this->record( $type, $post->post_type, $id, $post->post_title ); } public function content_deleted( $id, $post ) { if ( $this->relevant_content( $post ) ) { $this->record( 'post_deleted', $post->post_type, $id, $post->post_title ); } } private function plugin_data( $file ) { if ( ! function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( 0 !== validate_file( $file ) || ! is_file( WP_PLUGIN_DIR . '/' . $file ) ) { return array( 'Name' => basename( $file ), 'Version' => '' ); } return get_plugin_data( WP_PLUGIN_DIR . '/' . $file, false, false ); } public function plugin_activated( $file ) { $this->record( 'plugin_activated', 'plugin', 0, $this->plugin_data( $file )['Name'], array( 'plugin_file' => $file ) ); } public function plugin_deactivated( $file ) { $this->record( 'plugin_deactivated', 'plugin', 0, $this->plugin_data( $file )['Name'], array( 'plugin_file' => $file ) ); } public function before_plugin_delete( $file ) { $this->before[ 'plugin:' . $file ] = $this->plugin_data( $file ); } public function plugin_deleted( $file, $deleted ) { if ( $deleted ) { $this->record( 'plugin_deleted', 'plugin', 0, $this->before[ 'plugin:' . $file ]['Name'] ?? basename( $file ), array( 'plugin_file' => $file ) ); } } public function theme_switched( $name, $theme, $old ) { $this->record( 'theme_switched', 'theme', 0, $name, array( 'old_theme' => $old instanceof WP_Theme ? $old->get( 'Name' ) : '', 'theme_slug' => $theme instanceof WP_Theme ? $theme->get_stylesheet() : '' ) ); } public function before_theme_delete( $slug ) { $this->before[ 'theme:' . $slug ] = array( 'Name' => wp_get_theme( $slug )->get( 'Name' ) ); } public function theme_deleted( $slug, $deleted ) { if ( $deleted ) { $this->record( 'theme_deleted', 'theme', 0, $this->before[ 'theme:' . $slug ]['Name'] ?? $slug, array( 'theme_slug' => $slug ) ); } } /** Keep pre-update versions in request memory only. */ public function before_upgrade( $response, $extra ) { if ( ! empty( $extra['plugin'] ) ) { $this->before[ 'plugin:' . $extra['plugin'] ] = $this->plugin_data( $extra['plugin'] ); } elseif ( ! empty( $extra['theme'] ) ) { $theme = wp_get_theme( $extra['theme'] ); $this->before[ 'theme:' . $extra['theme'] ] = array( 'Name' => $theme->get( 'Name' ), 'Version' => $theme->get( 'Version' ) ); } return $response; } /** Runs after successful installation, individually even for bulk upgrades. */ public function after_upgrade( $response, $extra, $result ) { if ( is_wp_error( $response ) || is_wp_error( $result ) || ! is_array( $result ) || empty( $result['destination_name'] ) ) { return $response; } if ( isset( $extra['plugin'] ) || isset( $extra['theme'] ) ) { $kind = isset( $extra['plugin'] ) ? 'plugin' : 'theme'; $key = $extra[ $kind ]; $old = $this->before[ $kind . ':' . $key ] ?? array(); if ( 'theme' === $kind ) { wp_clean_themes_cache( false ); } $new = 'plugin' === $kind ? $this->plugin_data( $key ) : array( 'Name' => wp_get_theme( $key )->get( 'Name' ), 'Version' => wp_get_theme( $key )->get( 'Version' ) ); $data = array(); if ( ! empty( $old['Version'] ) && ! empty( $new['Version'] ) ) { $data = array( 'old_version' => $old['Version'], 'new_version' => $new['Version'] ); } $data[ 'plugin' === $kind ? 'plugin_file' : 'theme_slug' ] = $key; $this->record( $kind . '_updated', $kind, 0, $new['Name'] ?: $key, $data ); } else { // Install hooks do not always carry a type; identify the installed package from its destination. $slug = $result['destination_name']; $parent = wp_normalize_path( untrailingslashit( $result['local_destination'] ?? dirname( untrailingslashit( $result['destination'] ) ) ) ); $directory = $parent . '/' . $slug; if ( $parent === wp_normalize_path( get_theme_root() ) && is_file( $directory . '/style.css' ) ) { wp_clean_themes_cache( false ); $this->record( 'theme_installed', 'theme', 0, wp_get_theme( $slug )->get( 'Name' ) ?: $slug, array( 'theme_slug' => $slug ) ); } elseif ( $parent === wp_normalize_path( WP_PLUGIN_DIR ) && is_dir( WP_PLUGIN_DIR . '/' . $slug ) ) { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } wp_clean_plugins_cache( false ); $plugins = get_plugins( '/' . $slug ); $plugin = reset( $plugins ); if ( $plugin ) { $this->record( 'plugin_installed', 'plugin', 0, $plugin['Name'] ?: $slug, array( 'plugin_file' => $slug . '/' . key( $plugins ) ) ); } } } return $response; } /** * Only final automatic-update results expose the core upgrader's return value. * Generic completion and _core_updated_successfully can also fire during rollback. * Manual updates without a final-result hook are deliberately omitted. * * @param array $results Native automatic updater results. */ public function core_updates_completed( $results ) { if ( ! is_array( $results ) || empty( $results['core'] ) || ! is_array( $results['core'] ) ) { return; } foreach ( $results['core'] as $update ) { // Core_Upgrader returns a nonempty version string, not true, on success. if ( is_object( $update ) && isset( $update->result ) && is_string( $update->result ) && '' !== trim( $update->result ) ) { // Neither the offered version nor the request's stale global proves a before-version. $this->record( 'wordpress_updated' ); } } } public function settings_updated( $option, $old, $new ) { if ( 'admin_watch_settings' !== $option || ! is_array( $old ) || ! is_array( $new ) ) { return; } $data = array(); foreach ( WebStudio_Admin_Watch_Activity_Events::definition( 'settings_updated' )['fields'] as $field ) { $default = 'delete_data' === $field ? 0 : ( false !== strpos( $field, 'retention' ) ? 30 : 1 ); $before = isset( $old[ $field ] ) ? (int) $old[ $field ] : $default; $after = isset( $new[ $field ] ) ? (int) $new[ $field ] : $default; if ( $before !== $after ) { $data[ $field ] = $before . ' → ' . $after; } } if ( $data ) { $this->record( 'settings_updated', '', 0, '', $data ); } } }