You've already forked wp-fedistream
WP FediStream - Stream music over ActivityPub Features: - Custom post types: Artist, Album, Track, Playlist - Custom taxonomies: Genre, Mood, License - User roles: Artist, Label - Admin dashboard with statistics - Frontend templates and shortcodes - Audio player with queue management - ActivityPub integration with actor support - WooCommerce product types for albums/tracks - User library with favorites and history - Notification system (in-app and email) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
478 lines
12 KiB
PHP
478 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Follower Handler for ActivityPub.
|
|
*
|
|
* @package WP_FediStream
|
|
*/
|
|
|
|
namespace WP_FediStream\ActivityPub;
|
|
|
|
// Prevent direct file access.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Handles ActivityPub follower management.
|
|
*/
|
|
class FollowerHandler {
|
|
|
|
/**
|
|
* The table name.
|
|
*
|
|
* @var string
|
|
*/
|
|
private string $table;
|
|
|
|
/**
|
|
* Constructor.
|
|
*/
|
|
public function __construct() {
|
|
global $wpdb;
|
|
$this->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;
|
|
}
|
|
}
|