__( 'New user registration', 'pingpush' ), 'group' => 'core', 'title' => __( 'New user: {user_login}', 'pingpush' ), 'message' => __( '{user_name} ({user_email}) registered as {user_role}.', 'pingpush' ), 'enabled' => true, 'placeholders' => $user_ph, ) ); PingPush_Events::register( 'core_new_comment', array( 'label' => __( 'New comment', 'pingpush' ), 'description' => __( 'Spam and trashed comments never notify.', 'pingpush' ), 'group' => 'core', 'title' => __( 'New comment on {post_title}', 'pingpush' ), 'message' => __( "{comment_author} ({comment_status}):\n{comment_excerpt}", 'pingpush' ), 'enabled' => true, 'placeholders' => array( 'post_title', 'comment_author', 'comment_author_email', 'comment_excerpt', 'comment_status' ), 'options' => array( 'scope' => array( 'type' => 'select', 'label' => __( 'Notify for', 'pingpush' ), 'choices' => array( 'all' => __( 'Approved and pending comments', 'pingpush' ), 'approved' => __( 'Approved comments only (pending ones notify once approved)', 'pingpush' ), ), 'default' => 'all', ), ), ) ); PingPush_Events::register( 'core_password_reset', array( 'label' => __( 'Password reset requested', 'pingpush' ), 'group' => 'core', 'title' => __( 'Password reset requested', 'pingpush' ), 'message' => __( 'A password reset was requested for {user_login} ({user_email}) from {ip}.', 'pingpush' ), 'placeholders' => $user_ph, ) ); $post_ph = array( 'post_title', 'post_type', 'post_url', 'author', 'old_status', 'new_status' ); PingPush_Events::register( 'core_post_published', array( 'label' => __( 'Post published', 'pingpush' ), 'group' => 'core', 'title' => __( '{post_type} published', 'pingpush' ), 'message' => __( '"{post_title}" by {author}', 'pingpush' ), 'placeholders' => $post_ph, 'options' => array( 'post_types' => array( 'type' => 'checkboxes', 'label' => __( 'Post types', 'pingpush' ), 'choices' => array( __CLASS__, 'post_type_choices' ), 'default' => array( 'post' ), ), ), ) ); PingPush_Events::register( 'core_post_status', array( 'label' => __( 'Post status changed', 'pingpush' ), 'description' => __( 'For example, a post submitted for review.', 'pingpush' ), 'group' => 'core', 'title' => __( '{post_type} is now {new_status}', 'pingpush' ), 'message' => __( '"{post_title}" by {author}: {old_status} → {new_status}', 'pingpush' ), 'placeholders' => $post_ph, 'options' => array( 'statuses' => array( 'type' => 'checkboxes', 'label' => __( 'When the new status is', 'pingpush' ), 'choices' => array( __CLASS__, 'status_choices' ), 'default' => array( 'pending' ), ), 'post_types' => array( 'type' => 'checkboxes', 'label' => __( 'Post types', 'pingpush' ), 'choices' => array( __CLASS__, 'post_type_choices' ), 'default' => array( 'post' ), ), ), ) ); PingPush_Events::register( 'core_login', array( 'label' => __( 'Successful login', 'pingpush' ), 'group' => 'core', 'title' => __( 'Login: {user_login}', 'pingpush' ), 'message' => __( '{user_name} ({user_role}) logged in from {ip}.', 'pingpush' ), 'priority' => -1, 'placeholders' => $user_ph, 'options' => array( 'scope' => array( 'type' => 'select', 'label' => __( 'Notify for', 'pingpush' ), 'choices' => array( 'admins' => __( 'Administrators only', 'pingpush' ), 'all' => __( 'All users', 'pingpush' ), ), 'default' => 'admins', ), ), ) ); PingPush_Events::register( 'core_login_failed', array( 'label' => __( 'Failed login attempt', 'pingpush' ), 'group' => 'core', 'title' => __( 'Failed login', 'pingpush' ), 'message' => __( 'Failed login for "{username}" from {ip}.', 'pingpush' ), 'priority' => -1, 'placeholders' => array( 'username', 'ip' ), 'options' => array( 'throttle' => array( 'type' => 'select', 'label' => __( 'At most one alert per IP address every', 'pingpush' ), 'choices' => array( '5' => __( '5 minutes', 'pingpush' ), '15' => __( '15 minutes', 'pingpush' ), '60' => __( '1 hour', 'pingpush' ), '1440' => __( '24 hours', 'pingpush' ), ), 'default' => '15', ), ), ) ); PingPush_Events::register( 'core_plugin_toggled', array( 'label' => __( 'Plugin activated or deactivated', 'pingpush' ), 'group' => 'core', 'title' => __( 'Plugin {action}', 'pingpush' ), 'message' => __( '{plugin_name} was {action} by {user_name}.', 'pingpush' ), 'placeholders' => array( 'plugin_name', 'plugin_file', 'action', 'user_name' ), ) ); PingPush_Events::register( 'core_role_changed', array( 'label' => __( 'User role changed', 'pingpush' ), 'group' => 'core', 'title' => __( 'Role changed: {user_login}', 'pingpush' ), 'message' => __( '{user_name}: {old_role} → {new_role}', 'pingpush' ), 'placeholders' => array( 'user_login', 'user_name', 'user_email', 'old_role', 'new_role' ), ) ); PingPush_Events::register( 'core_mail_failed', array( 'label' => __( 'Site email failed to send', 'pingpush' ), 'group' => 'core', 'title' => __( 'Email failed to send', 'pingpush' ), 'message' => __( "To: {to}\nSubject: {subject}\nError: {error}", 'pingpush' ), 'enabled' => true, 'placeholders' => array( 'to', 'subject', 'error' ), ) ); PingPush_Events::register( 'core_fatal_error', array( 'label' => __( 'Site error (fatal error / recovery mode)', 'pingpush' ), 'description' => __( 'Sent when WordPress detects a fatal error and enters recovery mode. WordPress limits this to about once a day per problem.', 'pingpush' ), 'group' => 'core', 'title' => __( 'Site error detected', 'pingpush' ), 'message' => __( "{error_message}\nFile: {error_file}:{error_line}", 'pingpush' ), 'priority' => 1, 'enabled' => true, 'placeholders' => array( 'error_message', 'error_file', 'error_line' ), ) ); PingPush_Events::register( 'core_updates', array( 'label' => __( 'Updates available', 'pingpush' ), 'description' => __( 'Checked twice a day. Each new version is reported only once.', 'pingpush' ), 'group' => 'core', 'title' => __( '{count} update(s) available', 'pingpush' ), 'message' => '{updates}', 'priority' => -1, 'enabled' => true, 'placeholders' => array( 'count', 'updates' ), 'options' => array( 'types' => array( 'type' => 'checkboxes', 'label' => __( 'Include', 'pingpush' ), 'choices' => array( 'core' => __( 'WordPress core', 'pingpush' ), 'plugins' => __( 'Plugins', 'pingpush' ), 'themes' => __( 'Themes', 'pingpush' ), ), 'default' => array( 'core', 'plugins', 'themes' ), ), ), ) ); } /** * Hooks. */ public function hooks() { add_action( 'user_register', array( $this, 'user_register' ), 20, 1 ); add_action( 'comment_post', array( $this, 'comment_post' ), 20, 2 ); add_action( 'comment_unapproved_to_approved', array( $this, 'comment_approved' ), 20, 1 ); add_action( 'retrieve_password', array( $this, 'retrieve_password' ), 20, 1 ); add_action( 'transition_post_status', array( $this, 'transition_post_status' ), 20, 3 ); add_action( 'wp_login', array( $this, 'wp_login' ), 20, 2 ); add_action( 'wp_login_failed', array( $this, 'wp_login_failed' ), 20, 1 ); add_action( 'activated_plugin', array( $this, 'plugin_activated' ), 20, 1 ); add_action( 'deactivated_plugin', array( $this, 'plugin_deactivated' ), 20, 1 ); add_action( 'set_user_role', array( $this, 'set_user_role' ), 20, 3 ); add_action( 'wp_mail_failed', array( $this, 'wp_mail_failed' ), 20, 1 ); add_filter( 'recovery_mode_email', array( $this, 'recovery_mode_email' ), 20, 1 ); add_action( 'pingpush_check_updates', array( $this, 'check_updates' ) ); add_action( 'init', array( $this, 'ensure_update_cron' ) ); } /* ---------- Choices ---------- */ /** * Post types with an admin UI, including custom post types. * * @return array */ public static function post_type_choices() { $out = array(); foreach ( get_post_types( array( 'show_ui' => true ), 'objects' ) as $type ) { if ( in_array( $type->name, array( 'attachment', 'wp_block', 'wp_navigation', 'wp_template', 'wp_template_part', 'wp_font_family', 'wp_font_face', 'shop_order', 'shop_order_refund', 'shop_coupon' ), true ) ) { continue; } $out[ $type->name ] = $type->labels->singular_name; } return $out; } /** * Post statuses. * * @return array */ public static function status_choices() { return array( 'pending' => __( 'Pending review', 'pingpush' ), 'draft' => __( 'Draft', 'pingpush' ), 'future' => __( 'Scheduled', 'pingpush' ), 'publish' => __( 'Published', 'pingpush' ), 'private' => __( 'Private', 'pingpush' ), 'trash' => __( 'Trash', 'pingpush' ), ); } /* ---------- Helpers ---------- */ /** * Visitor IP (REMOTE_ADDR only; proxy headers can be spoofed). * * @return string */ public static function ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : __( 'unknown IP', 'pingpush' ); } /** * Translated role name. * * @param string $role Role slug. * @return string */ private function role_name( $role ) { $roles = wp_roles()->get_names(); return isset( $roles[ $role ] ) ? translate_user_role( $roles[ $role ] ) : ( $role ? $role : __( 'none', 'pingpush' ) ); } /** * User placeholders. * * @param WP_User $user User. * @return array */ private function user_data( $user ) { return array( 'user_login' => $user->user_login, 'user_name' => $user->display_name ? $user->display_name : $user->user_login, 'user_email' => $user->user_email, 'user_role' => implode( ', ', array_map( array( $this, 'role_name' ), (array) $user->roles ) ), 'ip' => self::ip(), ); } /** * Settings for an event. * * @param string $id Event id. * @return array */ private function opt( $id ) { return PingPush_Events::settings_for( $id ); } /* ---------- Handlers ---------- */ /** * New user. * * @param int $user_id User id. */ public function user_register( $user_id ) { $user = get_userdata( $user_id ); if ( ! $user ) { return; } PingPush_Events::trigger( 'core_user_register', $this->user_data( $user ), array( 'url' => admin_url( 'user-edit.php?user_id=' . (int) $user_id ), 'url_title' => __( 'View user', 'pingpush' ), 'dedupe' => (string) $user_id, ) ); } /** * New comment. * * @param int $comment_id Comment id. * @param int|string $approved 1, 0, 'spam' or 'trash'. */ public function comment_post( $comment_id, $approved ) { if ( 'spam' === $approved || 'trash' === $approved ) { return; } $is_approved = 1 === (int) $approved; $scope = $this->opt( 'core_new_comment' )['scope'] ?? 'all'; if ( ! $is_approved && 'approved' === $scope ) { return; // Will notify on approval. } $this->notify_comment( $comment_id, $is_approved ); } /** * Pending comment approved. * * @param WP_Comment $comment Comment. */ public function comment_approved( $comment ) { if ( ! $comment instanceof WP_Comment ) { return; } $scope = $this->opt( 'core_new_comment' )['scope'] ?? 'all'; if ( 'approved' !== $scope ) { return; // Already notified when it was posted. } $this->notify_comment( (int) $comment->comment_ID, true ); } /** * Send comment notification. * * @param int $comment_id Comment id. * @param bool $approved Approved. */ private function notify_comment( $comment_id, $approved ) { $comment = get_comment( $comment_id ); if ( ! $comment || in_array( $comment->comment_type, array( 'order_note', 'webhook_delivery', 'action_log' ), true ) ) { return; } PingPush_Events::trigger( 'core_new_comment', array( 'post_title' => get_the_title( (int) $comment->comment_post_ID ), 'comment_author' => $comment->comment_author ? $comment->comment_author : __( 'Anonymous', 'pingpush' ), 'comment_author_email' => $comment->comment_author_email, 'comment_excerpt' => wp_trim_words( wp_strip_all_tags( $comment->comment_content ), 40 ), 'comment_status' => $approved ? __( 'approved', 'pingpush' ) : __( 'pending', 'pingpush' ), ), array( 'url' => $approved ? get_comment_link( $comment ) : admin_url( 'comment.php?action=editcomment&c=' . (int) $comment_id ), 'url_title' => $approved ? __( 'View comment', 'pingpush' ) : __( 'Moderate comment', 'pingpush' ), 'dedupe' => (string) $comment_id, 'dedupe_ttl' => DAY_IN_SECONDS, ) ); } /** * Password reset requested. * * @param string $user_login Login. */ public function retrieve_password( $user_login ) { $user = get_user_by( 'login', $user_login ); if ( ! $user ) { return; } PingPush_Events::trigger( 'core_password_reset', $this->user_data( $user ), array( 'url' => admin_url( 'user-edit.php?user_id=' . (int) $user->ID ), 'url_title' => __( 'View user', 'pingpush' ), 'dedupe' => (string) $user->ID, ) ); } /** * Post status transitions. * * @param string $new_status New. * @param string $old_status Old. * @param WP_Post $post Post. */ public function transition_post_status( $new_status, $old_status, $post ) { if ( ! $post instanceof WP_Post || $new_status === $old_status || wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) { return; } if ( in_array( $new_status, array( 'auto-draft', 'inherit' ), true ) ) { return; } $type_obj = get_post_type_object( $post->post_type ); $author = get_userdata( (int) $post->post_author ); $data = array( 'post_title' => get_the_title( $post ), 'post_type' => $type_obj ? $type_obj->labels->singular_name : $post->post_type, 'post_url' => get_permalink( $post ), 'author' => $author ? $author->display_name : __( 'unknown', 'pingpush' ), 'old_status' => $this->status_label( $old_status ), 'new_status' => $this->status_label( $new_status ), ); $edit_url = admin_url( 'post.php?post=' . (int) $post->ID . '&action=edit' ); if ( 'publish' === $new_status ) { $opt = $this->opt( 'core_post_published' ); if ( in_array( $post->post_type, (array) ( $opt['post_types'] ?? array() ), true ) ) { PingPush_Events::trigger( 'core_post_published', $data, array( 'url' => (string) get_permalink( $post ), 'url_title' => __( 'View post', 'pingpush' ), 'dedupe' => (string) $post->ID, 'dedupe_ttl' => 300, ) ); } } $opt = $this->opt( 'core_post_status' ); if ( in_array( $new_status, (array) ( $opt['statuses'] ?? array() ), true ) && in_array( $post->post_type, (array) ( $opt['post_types'] ?? array() ), true ) ) { PingPush_Events::trigger( 'core_post_status', $data, array( 'url' => $edit_url, 'url_title' => __( 'Edit post', 'pingpush' ), 'dedupe' => $post->ID . ':' . $new_status, ) ); } } /** * Status label. * * @param string $status Status. * @return string */ private function status_label( $status ) { if ( 'new' === $status || 'auto-draft' === $status ) { return __( 'new', 'pingpush' ); } $obj = get_post_status_object( $status ); return $obj ? $obj->label : $status; } /** * Successful login. * * @param string $user_login Login. * @param WP_User $user User. */ public function wp_login( $user_login, $user ) { if ( ! $user instanceof WP_User ) { return; } $scope = $this->opt( 'core_login' )['scope'] ?? 'admins'; if ( 'admins' === $scope && ! user_can( $user, 'manage_options' ) ) { return; } PingPush_Events::trigger( 'core_login', $this->user_data( $user ), array( 'url' => admin_url( 'user-edit.php?user_id=' . (int) $user->ID ), 'url_title' => __( 'View user', 'pingpush' ), 'dedupe' => $user->ID . ':' . self::ip(), ) ); } /** * Failed login, throttled per IP. * * @param string $username Username tried. */ public function wp_login_failed( $username ) { $minutes = max( 1, (int) ( $this->opt( 'core_login_failed' )['throttle'] ?? 15 ) ); PingPush_Events::trigger( 'core_login_failed', array( 'username' => sanitize_user( (string) $username ), 'ip' => self::ip(), ), array( 'url' => admin_url( 'users.php' ), 'url_title' => __( 'Users', 'pingpush' ), 'dedupe' => self::ip(), 'dedupe_ttl' => $minutes * MINUTE_IN_SECONDS, ) ); } /** * Plugin activated. * * @param string $plugin Plugin file. */ public function plugin_activated( $plugin ) { $this->plugin_toggled( $plugin, __( 'activated', 'pingpush' ) ); } /** * Plugin deactivated. * * @param string $plugin Plugin file. */ public function plugin_deactivated( $plugin ) { if ( plugin_basename( PINGPUSH_FILE ) === $plugin ) { return; // Our queue is being torn down. } $this->plugin_toggled( $plugin, __( 'deactivated', 'pingpush' ) ); } /** * Plugin toggled. * * @param string $plugin Plugin file. * @param string $action Action label. */ private function plugin_toggled( $plugin, $action ) { $name = $plugin; $path = WP_PLUGIN_DIR . '/' . $plugin; if ( file_exists( $path ) ) { if ( ! function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $info = get_plugin_data( $path, false, false ); if ( ! empty( $info['Name'] ) ) { $name = $info['Name']; } } $current = wp_get_current_user(); PingPush_Events::trigger( 'core_plugin_toggled', array( 'plugin_name' => $name, 'plugin_file' => $plugin, 'action' => $action, 'user_name' => $current->exists() ? $current->display_name : __( 'the system', 'pingpush' ), ), array( 'url' => admin_url( 'plugins.php' ), 'url_title' => __( 'Plugins', 'pingpush' ), 'dedupe' => $plugin . ':' . $action, 'dedupe_ttl' => 30, ) ); } /** * Role changed. Skips new users (no old roles). * * @param int $user_id User id. * @param string $role New role. * @param string[] $old_roles Old roles. */ public function set_user_role( $user_id, $role, $old_roles ) { if ( empty( $old_roles ) ) { return; } $user = get_userdata( $user_id ); if ( ! $user ) { return; } PingPush_Events::trigger( 'core_role_changed', array( 'user_login' => $user->user_login, 'user_name' => $user->display_name, 'user_email' => $user->user_email, 'old_role' => implode( ', ', array_map( array( $this, 'role_name' ), (array) $old_roles ) ), 'new_role' => $this->role_name( $role ), ), array( 'url' => admin_url( 'user-edit.php?user_id=' . (int) $user_id ), 'url_title' => __( 'View user', 'pingpush' ), 'dedupe' => $user_id . ':' . $role, ) ); } /** * Mail failed. * * @param WP_Error $error Error. */ public function wp_mail_failed( $error ) { if ( ! is_wp_error( $error ) ) { return; } $data = (array) $error->get_error_data(); $to = isset( $data['to'] ) ? implode( ', ', (array) $data['to'] ) : ''; $subject = isset( $data['subject'] ) ? (string) $data['subject'] : ''; $message = $error->get_error_message(); PingPush_Events::trigger( 'core_mail_failed', array( 'to' => $to, 'subject' => $subject, 'error' => $message, ), array( 'url' => admin_url(), 'url_title' => __( 'Open dashboard', 'pingpush' ), 'dedupe' => md5( $message . $to ), 'dedupe_ttl' => 10 * MINUTE_IN_SECONDS, ) ); } /** * Recovery mode email is about to be sent: a fatal error happened. * Sends synchronously because the request is ending in an error. * * @param array $email Email args (returned unchanged). * @return array */ public function recovery_mode_email( $email ) { $error = error_get_last(); $file = isset( $error['file'] ) ? str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( (string) $error['file'] ) ) : ''; $msg = isset( $error['message'] ) ? strtok( (string) $error['message'], "\n" ) : __( 'A fatal error occurred.', 'pingpush' ); PingPush_Events::trigger( 'core_fatal_error', array( 'error_message' => $msg, 'error_file' => $file, 'error_line' => isset( $error['line'] ) ? (string) $error['line'] : '', ), array( 'url' => admin_url( 'site-health.php' ), 'url_title' => __( 'Site Health', 'pingpush' ), 'dedupe' => md5( $file . $msg ), 'dedupe_ttl' => HOUR_IN_SECONDS, 'sync' => true, ) ); return $email; } /** * Make sure the update check is scheduled (e.g. after a plugin update that skipped activation). */ public function ensure_update_cron() { if ( ! wp_next_scheduled( 'pingpush_check_updates' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'twicedaily', 'pingpush_check_updates' ); } } /** * Report new updates, each version only once. */ public function check_updates() { if ( ! PingPush_Events::is_enabled( 'core_updates' ) ) { return; } $types = (array) ( $this->opt( 'core_updates' )['types'] ?? array() ); $found = $this->available_updates( $types ); $notified = get_option( 'pingpush_notified_updates', array() ); $notified = is_array( $notified ) ? $notified : array(); $new = array(); foreach ( $found as $key => $item ) { if ( ( $notified[ $key ] ?? '' ) !== $item['version'] ) { $new[ $key ] = $item; } } // Remember what is currently available; forget items that were updated. $remember = array(); foreach ( $found as $key => $item ) { $remember[ $key ] = $item['version']; } update_option( 'pingpush_notified_updates', $remember, false ); if ( empty( $new ) ) { return; } $lines = array(); foreach ( $new as $item ) { $lines[] = $item['line']; } PingPush_Events::trigger( 'core_updates', array( 'count' => (string) count( $new ), 'updates' => implode( "\n", $lines ), ), array( 'url' => admin_url( 'update-core.php' ), 'url_title' => __( 'Open Updates', 'pingpush' ), ) ); } /** * Read available updates from WordPress' update transients. * * @param string[] $types core|plugins|themes. * @return array */ public function available_updates( array $types ) { $out = array(); if ( in_array( 'core', $types, true ) ) { $core = get_site_transient( 'update_core' ); if ( is_object( $core ) && ! empty( $core->updates ) ) { foreach ( $core->updates as $update ) { if ( isset( $update->response, $update->version ) && 'upgrade' === $update->response ) { $out['core'] = array( 'version' => (string) $update->version, /* translators: 1: new version, 2: current version. */ 'line' => sprintf( __( 'WordPress %1$s (installed: %2$s)', 'pingpush' ), $update->version, get_bloginfo( 'version' ) ), ); break; } } } } if ( in_array( 'plugins', $types, true ) ) { $plugins = get_site_transient( 'update_plugins' ); if ( is_object( $plugins ) && ! empty( $plugins->response ) ) { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $installed = get_plugins(); foreach ( $plugins->response as $file => $update ) { $version = is_object( $update ) ? (string) ( $update->new_version ?? '' ) : ''; if ( '' === $version ) { continue; } $name = $installed[ $file ]['Name'] ?? $file; $current = $installed[ $file ]['Version'] ?? '?'; $out[ 'plugin:' . $file ] = array( 'version' => $version, 'line' => sprintf( '%s %s → %s', $name, $current, $version ), ); } } } if ( in_array( 'themes', $types, true ) ) { $themes = get_site_transient( 'update_themes' ); if ( is_object( $themes ) && ! empty( $themes->response ) ) { foreach ( $themes->response as $stylesheet => $update ) { $version = is_array( $update ) ? (string) ( $update['new_version'] ?? '' ) : ''; if ( '' === $version ) { continue; } $theme = wp_get_theme( $stylesheet ); if ( ! $theme->exists() ) { continue; // Avoid alerts for themes that are not installed. } $out[ 'theme:' . $stylesheet ] = array( 'version' => $version, /* translators: 1: theme name, 2: current version, 3: new version. */ 'line' => sprintf( __( 'Theme %1$s %2$s → %3$s', 'pingpush' ), $theme->get( 'Name' ), $theme->get( 'Version' ), $version ), ); } } } return $out; } }