Files

478 lines
12 KiB
PHP
Raw Permalink Normal View History

<?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;
}
}