table = $wpdb->prefix . 'fedistream_followers'; // Register inbox handlers for follow activities. add_action( 'activitypub_inbox_follow', array( $this, 'handle_follow' ), 10, 2 ); add_action( 'activitypub_inbox_undo', array( $this, 'handle_undo' ), 10, 2 ); } /** * Handle incoming Follow activity. * * @param array $activity The activity data. * @param int $user_id The local user ID (if applicable). * @return void */ public function handle_follow( array $activity, int $user_id ): void { $actor = $activity['actor'] ?? ''; $object = $activity['object'] ?? ''; if ( ! $actor || ! $object ) { return; } // Find the artist being followed. $artist_id = $this->get_artist_from_object( $object ); if ( ! $artist_id ) { return; } // Add follower. $result = $this->add_follower( $artist_id, $actor, $activity ); if ( $result ) { // Send Accept activity. $this->send_accept( $artist_id, $activity ); } } /** * Handle incoming Undo activity (for Unfollow). * * @param array $activity The activity data. * @param int $user_id The local user ID (if applicable). * @return void */ public function handle_undo( array $activity, int $user_id ): void { $actor = $activity['actor'] ?? ''; $object = $activity['object'] ?? array(); if ( ! $actor || ! is_array( $object ) ) { return; } // Check if this is an Undo Follow. $type = $object['type'] ?? ''; if ( 'Follow' !== $type ) { return; } $follow_object = $object['object'] ?? ''; if ( ! $follow_object ) { return; } // Find the artist being unfollowed. $artist_id = $this->get_artist_from_object( $follow_object ); if ( ! $artist_id ) { return; } // Remove follower. $this->remove_follower( $artist_id, $actor ); } /** * Get artist ID from an object URI. * * @param string $object The object URI. * @return int|null Artist ID or null. */ private function get_artist_from_object( string $object ): ?int { // Try to find by permalink. $post_id = url_to_postid( $object ); if ( $post_id ) { $post = get_post( $post_id ); if ( $post && 'fedistream_artist' === $post->post_type ) { return $post_id; } } // Try to find by artist handle pattern. if ( preg_match( '/artist-(\d+)/', $object, $matches ) ) { $artist_id = absint( $matches[1] ); $post = get_post( $artist_id ); if ( $post && 'fedistream_artist' === $post->post_type ) { return $artist_id; } } return null; } /** * Add a follower. * * @param int $artist_id The artist ID. * @param string $follower_uri The follower's actor URI. * @param array $activity_data The original activity data. * @return bool True on success. */ public function add_follower( int $artist_id, string $follower_uri, array $activity_data = array() ): bool { global $wpdb; // Check if already following. if ( $this->is_following( $artist_id, $follower_uri ) ) { return true; } // Fetch follower info. $follower_info = $this->fetch_actor( $follower_uri ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $result = $wpdb->insert( $this->table, array( 'artist_id' => $artist_id, 'follower_uri' => $follower_uri, 'follower_name' => $follower_info['name'] ?? '', 'follower_icon' => $follower_info['icon']['url'] ?? '', 'inbox' => $follower_info['inbox'] ?? '', 'shared_inbox' => $follower_info['endpoints']['sharedInbox'] ?? $follower_info['sharedInbox'] ?? '', 'activity_data' => wp_json_encode( $activity_data ), 'followed_at' => current_time( 'mysql' ), ), array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); if ( $result ) { // Update follower count. $count = $this->get_follower_count( $artist_id ); update_post_meta( $artist_id, '_fedistream_follower_count', $count ); do_action( 'fedistream_artist_followed', $artist_id, $follower_uri, $follower_info ); } return (bool) $result; } /** * Remove a follower. * * @param int $artist_id The artist ID. * @param string $follower_uri The follower's actor URI. * @return bool True on success. */ public function remove_follower( int $artist_id, string $follower_uri ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $result = $wpdb->delete( $this->table, array( 'artist_id' => $artist_id, 'follower_uri' => $follower_uri, ), array( '%d', '%s' ) ); if ( $result ) { // Update follower count. $count = $this->get_follower_count( $artist_id ); update_post_meta( $artist_id, '_fedistream_follower_count', $count ); do_action( 'fedistream_artist_unfollowed', $artist_id, $follower_uri ); } return (bool) $result; } /** * Check if an actor is following an artist. * * @param int $artist_id The artist ID. * @param string $follower_uri The follower's actor URI. * @return bool */ public function is_following( int $artist_id, string $follower_uri ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$this->table} WHERE artist_id = %d AND follower_uri = %s", $artist_id, $follower_uri ) ); return $exists > 0; } /** * Get follower count for an artist. * * @param int $artist_id The artist ID. * @return int */ public function get_follower_count( int $artist_id ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$this->table} WHERE artist_id = %d", $artist_id ) ); return (int) $count; } /** * Get followers for an artist. * * @param int $artist_id The artist ID. * @param int $limit Maximum number to return. * @param int $offset Offset for pagination. * @return array */ public function get_followers( int $artist_id, int $limit = 20, int $offset = 0 ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $followers = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$this->table} WHERE artist_id = %d ORDER BY followed_at DESC LIMIT %d OFFSET %d", $artist_id, $limit, $offset ) ); return $followers ?: array(); } /** * Get all follower inboxes for an artist. * * @param int $artist_id The artist ID. * @return array Array of inbox URLs, with shared inboxes deduplicated. */ public function get_follower_inboxes( int $artist_id ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_results( $wpdb->prepare( "SELECT inbox, shared_inbox FROM {$this->table} WHERE artist_id = %d", $artist_id ) ); $inboxes = array(); $shared_inboxes = array(); foreach ( $results as $row ) { // Prefer shared inbox for efficiency. if ( ! empty( $row->shared_inbox ) ) { $shared_inboxes[ $row->shared_inbox ] = true; } elseif ( ! empty( $row->inbox ) ) { $inboxes[ $row->inbox ] = true; } } // Return unique inboxes (shared inboxes + individual inboxes). return array_merge( array_keys( $shared_inboxes ), array_keys( $inboxes ) ); } /** * Send Accept activity in response to Follow. * * @param int $artist_id The artist ID. * @param array $activity The original Follow activity. * @return bool True on success. */ private function send_accept( int $artist_id, array $activity ): bool { $artist = get_post( $artist_id ); if ( ! $artist ) { return false; } $actor = new ArtistActor( $artist ); $follower_uri = $activity['actor'] ?? ''; if ( ! $follower_uri ) { return false; } // Create Accept activity. $accept = array( '@context' => 'https://www.w3.org/ns/activitystreams', 'type' => 'Accept', 'id' => $actor->get_id() . '#accept-' . time(), 'actor' => $actor->get_id(), 'object' => $activity, ); // Get follower's inbox. $follower_info = $this->fetch_actor( $follower_uri ); $inbox = $follower_info['inbox'] ?? ''; if ( ! $inbox ) { return false; } // Send the Accept activity. return $this->send_activity( $accept, $inbox, $artist_id ); } /** * Fetch an actor from a remote server. * * @param string $actor_uri The actor URI. * @return array The actor data. */ private function fetch_actor( string $actor_uri ): array { $response = wp_remote_get( $actor_uri, array( 'headers' => array( 'Accept' => 'application/activity+json, application/ld+json', ), 'timeout' => 10, ) ); if ( is_wp_error( $response ) ) { return array(); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); return is_array( $data ) ? $data : array(); } /** * Send an activity to an inbox. * * @param array $activity The activity to send. * @param string $inbox The inbox URL. * @param int $artist_id The artist ID (for signing). * @return bool True on success. */ private function send_activity( array $activity, string $inbox, int $artist_id ): bool { $artist = get_post( $artist_id ); if ( ! $artist ) { return false; } // Get the artist's private key for signing. $private_key = get_post_meta( $artist_id, '_fedistream_activitypub_private_key', true ); if ( ! $private_key ) { // Generate keys if not exists. $actor = new ArtistActor( $artist ); $actor->get_public_key(); // This triggers key generation. $private_key = get_post_meta( $artist_id, '_fedistream_activitypub_private_key', true ); } if ( ! $private_key ) { return false; } $body = wp_json_encode( $activity ); $date = gmdate( 'D, d M Y H:i:s T' ); // Parse inbox URL. $parsed = wp_parse_url( $inbox ); $host = $parsed['host'] ?? ''; $path = $parsed['path'] ?? '/'; // Create signature string. $string_to_sign = "(request-target): post {$path}\n"; $string_to_sign .= "host: {$host}\n"; $string_to_sign .= "date: {$date}\n"; $string_to_sign .= 'digest: SHA-256=' . base64_encode( hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode // Sign the string. $signature = ''; openssl_sign( $string_to_sign, $signature, $private_key, OPENSSL_ALGO_SHA256 ); $signature_b64 = base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode // Build signature header. $actor = new ArtistActor( $artist ); $key_id = $actor->get_id() . '#main-key'; $signature_header = sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature_b64 ); // Send the request. $response = wp_remote_post( $inbox, array( 'headers' => array( 'Content-Type' => 'application/activity+json', 'Accept' => 'application/activity+json', 'Host' => $host, 'Date' => $date, 'Digest' => 'SHA-256=' . base64_encode( hash( 'sha256', $body, true ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode 'Signature' => $signature_header, ), 'body' => $body, 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { return false; } $code = wp_remote_retrieve_response_code( $response ); // Accept responses: 200, 201, 202 are all valid. return $code >= 200 && $code < 300; } /** * Broadcast an activity to all followers. * * @param int $artist_id The artist ID. * @param array $activity The activity to broadcast. * @return array Results with inbox => success/failure. */ public function broadcast_activity( int $artist_id, array $activity ): array { $inboxes = $this->get_follower_inboxes( $artist_id ); $results = array(); foreach ( $inboxes as $inbox ) { $results[ $inbox ] = $this->send_activity( $activity, $inbox, $artist_id ); } return $results; } }