feat: Initial release v0.1.0

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>
This commit is contained in:
2026-01-28 23:23:05 +01:00
commit 4a5d7b9f4d
91 changed files with 22750 additions and 0 deletions

View File

@@ -0,0 +1,433 @@
<?php
/**
* Album Transformer for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Transforms Album posts to ActivityPub Collection objects.
*/
class AlbumTransformer {
/**
* The album post.
*
* @var \WP_Post
*/
protected \WP_Post $post;
/**
* Constructor.
*
* @param \WP_Post $post The album post.
*/
public function __construct( \WP_Post $post ) {
$this->post = $post;
}
/**
* Get the ActivityPub object type.
*
* @return string
*/
public function get_type(): string {
return 'Collection';
}
/**
* Get the object ID (URI).
*
* @return string
*/
public function get_id(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the object name (title).
*
* @return string
*/
public function get_name(): string {
$album_type = get_post_meta( $this->post->ID, '_fedistream_album_type', true );
$type_label = '';
switch ( $album_type ) {
case 'ep':
$type_label = ' (EP)';
break;
case 'single':
$type_label = ' (Single)';
break;
case 'compilation':
$type_label = ' (Compilation)';
break;
}
return $this->post->post_title . $type_label;
}
/**
* Get the content (liner notes/description).
*
* @return string
*/
public function get_content(): string {
$content = $this->post->post_content;
// Apply content filters for proper formatting.
$content = apply_filters( 'the_content', $content );
return wp_kses_post( $content );
}
/**
* Get the summary (excerpt).
*
* @return string
*/
public function get_summary(): string {
if ( ! empty( $this->post->post_excerpt ) ) {
return wp_strip_all_tags( $this->post->post_excerpt );
}
// Build summary from album info.
$artist_id = get_post_meta( $this->post->ID, '_fedistream_album_artist', true );
$artist = $artist_id ? get_post( $artist_id ) : null;
$release_date = get_post_meta( $this->post->ID, '_fedistream_release_date', true );
$track_count = (int) get_post_meta( $this->post->ID, '_fedistream_total_tracks', true );
$summary = '';
if ( $artist ) {
$summary .= sprintf( __( 'By %s', 'wp-fedistream' ), $artist->post_title );
}
if ( $release_date ) {
/* translators: %s: release date */
$summary .= ' ' . sprintf( __( '- Released %s', 'wp-fedistream' ), $release_date );
}
if ( $track_count ) {
/* translators: %d: number of tracks */
$summary .= ' ' . sprintf( _n( '- %d track', '- %d tracks', $track_count, 'wp-fedistream' ), $track_count );
}
return trim( $summary );
}
/**
* Get the URL (permalink).
*
* @return string
*/
public function get_url(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the attributed actor.
*
* @return string Artist URI.
*/
public function get_attributed_to(): string {
$artist_id = get_post_meta( $this->post->ID, '_fedistream_album_artist', true );
if ( ! $artist_id ) {
return '';
}
return get_permalink( $artist_id );
}
/**
* Get the published date.
*
* @return string ISO 8601 date.
*/
public function get_published(): string {
// Use release date if available, otherwise post date.
$release_date = get_post_meta( $this->post->ID, '_fedistream_release_date', true );
if ( $release_date ) {
$date = \DateTime::createFromFormat( 'Y-m-d', $release_date );
if ( $date ) {
return $date->format( 'c' );
}
}
return get_the_date( 'c', $this->post );
}
/**
* Get the updated date.
*
* @return string ISO 8601 date.
*/
public function get_updated(): string {
return get_the_modified_date( 'c', $this->post );
}
/**
* Get the total duration.
*
* @return string ISO 8601 duration.
*/
public function get_duration(): string {
$seconds = (int) get_post_meta( $this->post->ID, '_fedistream_total_duration', true );
if ( ! $seconds ) {
// Calculate from tracks.
$tracks = $this->get_tracks();
foreach ( $tracks as $track ) {
$seconds += (int) get_post_meta( $track->ID, '_fedistream_duration', true );
}
}
if ( ! $seconds ) {
return '';
}
return $this->format_duration_iso8601( $seconds );
}
/**
* Get the tracks in this album.
*
* @return array Array of WP_Post objects.
*/
public function get_tracks(): array {
$args = array(
'post_type' => 'fedistream_track',
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_key' => '_fedistream_track_number', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'orderby' => 'meta_value_num',
'order' => 'ASC',
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => '_fedistream_album_id',
'value' => $this->post->ID,
),
),
);
$query = new \WP_Query( $args );
return $query->posts;
}
/**
* Get the total item count.
*
* @return int
*/
public function get_total_items(): int {
$count = (int) get_post_meta( $this->post->ID, '_fedistream_total_tracks', true );
if ( ! $count ) {
$count = count( $this->get_tracks() );
}
return $count;
}
/**
* Get the collection items (track URIs).
*
* @return array
*/
public function get_items(): array {
$tracks = $this->get_tracks();
$items = array();
foreach ( $tracks as $track ) {
$transformer = new TrackTransformer( $track );
$items[] = $transformer->to_object();
}
return $items;
}
/**
* Get the image/artwork attachment.
*
* @return array|null
*/
public function get_image_attachment(): ?array {
$thumbnail_id = get_post_thumbnail_id( $this->post->ID );
if ( ! $thumbnail_id ) {
return null;
}
$image = wp_get_attachment_image_src( $thumbnail_id, 'medium' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get tags (genres).
*
* @return array
*/
public function get_tags(): array {
$tags = array();
// Get genres.
$genres = get_the_terms( $this->post->ID, 'fedistream_genre' );
if ( $genres && ! is_wp_error( $genres ) ) {
foreach ( $genres as $genre ) {
$tags[] = array(
'type' => 'Hashtag',
'name' => '#' . sanitize_title( $genre->name ),
'href' => get_term_link( $genre ),
);
}
}
return $tags;
}
/**
* Transform to ActivityPub object array.
*
* @return array
*/
public function to_object(): array {
$object = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => $this->get_type(),
'id' => $this->get_id(),
'name' => $this->get_name(),
'summary' => $this->get_summary(),
'content' => $this->get_content(),
'url' => $this->get_url(),
'attributedTo' => $this->get_attributed_to(),
'published' => $this->get_published(),
'updated' => $this->get_updated(),
'totalItems' => $this->get_total_items(),
'items' => $this->get_items(),
);
// Add duration.
$duration = $this->get_duration();
if ( $duration ) {
$object['duration'] = $duration;
}
// Add image.
$image = $this->get_image_attachment();
if ( $image ) {
$object['image'] = $image;
}
// Add tags.
$tags = $this->get_tags();
if ( ! empty( $tags ) ) {
$object['tag'] = $tags;
}
// Add album-specific metadata.
$album_type = get_post_meta( $this->post->ID, '_fedistream_album_type', true );
if ( $album_type ) {
$object['albumType'] = $album_type;
}
$upc = get_post_meta( $this->post->ID, '_fedistream_upc', true );
if ( $upc ) {
$object['upc'] = $upc;
}
$catalog = get_post_meta( $this->post->ID, '_fedistream_catalog_number', true );
if ( $catalog ) {
$object['catalogNumber'] = $catalog;
}
$release_date = get_post_meta( $this->post->ID, '_fedistream_release_date', true );
if ( $release_date ) {
$object['releaseDate'] = $release_date;
}
return $object;
}
/**
* Create a Create activity for this album.
*
* @return array
*/
public function to_create_activity(): array {
$actor = $this->get_attributed_to();
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Create',
'id' => $this->get_id() . '#activity-create',
'actor' => $actor,
'published' => $this->get_published(),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor . '/followers' ),
'object' => $this->to_object(),
);
}
/**
* Create an Announce activity for this album.
*
* @param string $actor_uri The actor announcing the album.
* @return array
*/
public function to_announce_activity( string $actor_uri ): array {
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Announce',
'id' => $this->get_id() . '#activity-announce-' . time(),
'actor' => $actor_uri,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor_uri . '/followers' ),
'object' => $this->get_id(),
);
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
}

View File

@@ -0,0 +1,614 @@
<?php
/**
* Artist ActivityPub Actor.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Represents an artist as an ActivityPub actor.
*/
class ArtistActor {
/**
* The artist post.
*
* @var \WP_Post
*/
private \WP_Post $artist;
/**
* Constructor.
*
* @param \WP_Post $artist The artist post.
*/
public function __construct( \WP_Post $artist ) {
$this->artist = $artist;
}
/**
* Get the actor ID (URI).
*
* @return string
*/
public function get_id(): string {
return get_permalink( $this->artist->ID );
}
/**
* Get the actor type.
*
* @return string Person for solo, Group for bands.
*/
public function get_type(): string {
$artist_type = get_post_meta( $this->artist->ID, '_fedistream_artist_type', true );
// Bands, duos, collectives are Groups.
if ( in_array( $artist_type, array( 'band', 'duo', 'collective', 'orchestra' ), true ) ) {
return 'Group';
}
return 'Person';
}
/**
* Get the preferred username.
*
* @return string
*/
public function get_preferred_username(): string {
return 'artist-' . $this->artist->ID;
}
/**
* Get the display name.
*
* @return string
*/
public function get_name(): string {
return $this->artist->post_title;
}
/**
* Get the actor summary/bio.
*
* @return string
*/
public function get_summary(): string {
if ( ! empty( $this->artist->post_excerpt ) ) {
return wp_kses_post( $this->artist->post_excerpt );
}
// Use first paragraph of content as summary.
$content = wp_strip_all_tags( $this->artist->post_content );
$parts = explode( "\n\n", $content, 2 );
return ! empty( $parts[0] ) ? wp_trim_words( $parts[0], 50 ) : '';
}
/**
* Get the actor URL (profile page).
*
* @return string
*/
public function get_url(): string {
return get_permalink( $this->artist->ID );
}
/**
* Get the inbox URL.
*
* @return string
*/
public function get_inbox(): string {
return trailingslashit( get_permalink( $this->artist->ID ) ) . 'inbox';
}
/**
* Get the outbox URL.
*
* @return string
*/
public function get_outbox(): string {
return trailingslashit( get_permalink( $this->artist->ID ) ) . 'outbox';
}
/**
* Get the followers URL.
*
* @return string
*/
public function get_followers(): string {
return trailingslashit( get_permalink( $this->artist->ID ) ) . 'followers';
}
/**
* Get the following URL.
*
* @return string
*/
public function get_following(): string {
return trailingslashit( get_permalink( $this->artist->ID ) ) . 'following';
}
/**
* Get the avatar/icon.
*
* @return array|null
*/
public function get_icon(): ?array {
$thumbnail_id = get_post_thumbnail_id( $this->artist->ID );
if ( ! $thumbnail_id ) {
return null;
}
$image = wp_get_attachment_image_src( $thumbnail_id, 'thumbnail' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get the header/banner image.
*
* @return array|null
*/
public function get_image(): ?array {
$banner_id = get_post_meta( $this->artist->ID, '_fedistream_artist_banner', true );
if ( ! $banner_id ) {
// Fall back to featured image at larger size.
$banner_id = get_post_thumbnail_id( $this->artist->ID );
}
if ( ! $banner_id ) {
return null;
}
$image = wp_get_attachment_image_src( $banner_id, 'large' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $banner_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get the public key.
*
* @return array
*/
public function get_public_key(): array {
$key = get_post_meta( $this->artist->ID, '_fedistream_activitypub_public_key', true );
if ( ! $key ) {
// Generate key pair if not exists.
$this->generate_keys();
$key = get_post_meta( $this->artist->ID, '_fedistream_activitypub_public_key', true );
}
return array(
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
'publicKeyPem' => $key,
);
}
/**
* Generate RSA key pair for the artist.
*
* @return bool True on success.
*/
private function generate_keys(): bool {
$config = array(
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
);
$resource = openssl_pkey_new( $config );
if ( ! $resource ) {
return false;
}
// Export private key.
openssl_pkey_export( $resource, $private_key );
// Get public key.
$details = openssl_pkey_get_details( $resource );
$public_key = $details['key'] ?? '';
if ( ! $private_key || ! $public_key ) {
return false;
}
update_post_meta( $this->artist->ID, '_fedistream_activitypub_private_key', $private_key );
update_post_meta( $this->artist->ID, '_fedistream_activitypub_public_key', $public_key );
return true;
}
/**
* Get attachment properties (social links, etc.).
*
* @return array
*/
public function get_attachment(): array {
$attachments = array();
// Add website.
$website = get_post_meta( $this->artist->ID, '_fedistream_artist_website', true );
if ( $website ) {
$attachments[] = array(
'type' => 'PropertyValue',
'name' => __( 'Website', 'wp-fedistream' ),
'value' => sprintf( '<a href="%s" rel="me nofollow noopener" target="_blank">%s</a>', esc_url( $website ), esc_html( $website ) ),
);
}
// Add location.
$location = get_post_meta( $this->artist->ID, '_fedistream_artist_location', true );
if ( $location ) {
$attachments[] = array(
'type' => 'PropertyValue',
'name' => __( 'Location', 'wp-fedistream' ),
'value' => esc_html( $location ),
);
}
// Add social links.
$social_links = get_post_meta( $this->artist->ID, '_fedistream_artist_social_links', true );
if ( is_array( $social_links ) ) {
foreach ( $social_links as $link ) {
$platform = $link['platform'] ?? '';
$url = $link['url'] ?? '';
if ( $platform && $url ) {
$attachments[] = array(
'type' => 'PropertyValue',
'name' => esc_html( ucfirst( $platform ) ),
'value' => sprintf( '<a href="%s" rel="me nofollow noopener" target="_blank">%s</a>', esc_url( $url ), esc_html( $url ) ),
);
}
}
}
// Add formed date.
$formed = get_post_meta( $this->artist->ID, '_fedistream_artist_formed_date', true );
if ( $formed ) {
$attachments[] = array(
'type' => 'PropertyValue',
'name' => __( 'Active Since', 'wp-fedistream' ),
'value' => esc_html( $formed ),
);
}
return $attachments;
}
/**
* Get the full actor object as an array.
*
* @return array
*/
public function to_array(): array {
$actor = array(
'@context' => array(
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
array(
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
'PropertyValue' => 'schema:PropertyValue',
'value' => 'schema:value',
),
),
'id' => $this->get_id(),
'type' => $this->get_type(),
'preferredUsername' => $this->get_preferred_username(),
'name' => $this->get_name(),
'summary' => $this->get_summary(),
'url' => $this->get_url(),
'inbox' => $this->get_inbox(),
'outbox' => $this->get_outbox(),
'followers' => $this->get_followers(),
'following' => $this->get_following(),
'publicKey' => $this->get_public_key(),
'manuallyApprovesFollowers' => false,
'published' => get_the_date( 'c', $this->artist ),
'updated' => get_the_modified_date( 'c', $this->artist ),
);
// Add icon if available.
$icon = $this->get_icon();
if ( $icon ) {
$actor['icon'] = $icon;
}
// Add image if available.
$image = $this->get_image();
if ( $image ) {
$actor['image'] = $image;
}
// Add attachments.
$attachments = $this->get_attachment();
if ( ! empty( $attachments ) ) {
$actor['attachment'] = $attachments;
}
return $actor;
}
/**
* Get webfinger data for the artist.
*
* @return array
*/
public function get_webfinger(): array {
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$resource = 'acct:' . $this->get_preferred_username() . '@' . $host;
return array(
'subject' => $resource,
'aliases' => array(
$this->get_id(),
$this->get_url(),
),
'links' => array(
array(
'rel' => 'self',
'type' => 'application/activity+json',
'href' => $this->get_id(),
),
array(
'rel' => 'http://webfinger.net/rel/profile-page',
'type' => 'text/html',
'href' => $this->get_url(),
),
),
);
}
/**
* Get the outbox collection.
*
* @param int $page The page number (0 for summary).
* @param int $per_page Items per page.
* @return array
*/
public function get_outbox_collection( int $page = 0, int $per_page = 20 ): array {
// Get all tracks and albums by this artist.
$args = array(
'post_type' => array( 'fedistream_track', 'fedistream_album' ),
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'relation' => 'OR',
array(
'key' => '_fedistream_album_artist',
'value' => $this->artist->ID,
),
array(
'key' => '_fedistream_artist_ids',
'value' => $this->artist->ID,
'compare' => 'LIKE',
),
),
);
$query = new \WP_Query( $args );
$total = $query->found_posts;
// Return summary if page 0.
if ( $page === 0 ) {
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $this->get_outbox(),
'type' => 'OrderedCollection',
'totalItems' => $total,
'first' => $this->get_outbox() . '?page=1',
'last' => $this->get_outbox() . '?page=' . max( 1, ceil( $total / $per_page ) ),
);
}
// Get paginated items.
$args['posts_per_page'] = $per_page;
$args['paged'] = $page;
$args['orderby'] = 'date';
$args['order'] = 'DESC';
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->post_to_create_activity( $post );
}
$collection = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $this->get_outbox() . '?page=' . $page,
'type' => 'OrderedCollectionPage',
'partOf' => $this->get_outbox(),
'orderedItems' => $items,
);
// Add prev/next links.
if ( $page > 1 ) {
$collection['prev'] = $this->get_outbox() . '?page=' . ( $page - 1 );
}
$total_pages = ceil( $total / $per_page );
if ( $page < $total_pages ) {
$collection['next'] = $this->get_outbox() . '?page=' . ( $page + 1 );
}
return $collection;
}
/**
* Convert a post to a Create activity.
*
* @param \WP_Post $post The post.
* @return array
*/
private function post_to_create_activity( \WP_Post $post ): array {
$object = array(
'type' => 'fedistream_track' === $post->post_type ? 'Audio' : 'Collection',
'id' => get_permalink( $post->ID ),
'name' => $post->post_title,
'content' => wp_strip_all_tags( $post->post_content ),
'attributedTo' => $this->get_id(),
'published' => get_the_date( 'c', $post ),
'url' => get_permalink( $post->ID ),
);
// Add track-specific properties.
if ( 'fedistream_track' === $post->post_type ) {
$duration = get_post_meta( $post->ID, '_fedistream_duration', true );
if ( $duration ) {
$object['duration'] = $this->format_duration_iso8601( (int) $duration );
}
$audio_id = get_post_meta( $post->ID, '_fedistream_audio_file', true );
$audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : '';
if ( $audio_url ) {
$object['attachment'] = array(
'type' => 'Audio',
'mediaType' => 'audio/mpeg',
'url' => $audio_url,
);
}
}
// Add thumbnail.
$thumbnail_id = get_post_thumbnail_id( $post->ID );
if ( $thumbnail_id ) {
$image = wp_get_attachment_image_src( $thumbnail_id, 'medium' );
if ( $image ) {
$object['image'] = array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
);
}
}
return array(
'type' => 'Create',
'id' => get_permalink( $post->ID ) . '#activity-create',
'actor' => $this->get_id(),
'published' => get_the_date( 'c', $post ),
'object' => $object,
);
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
/**
* Get the followers collection.
*
* @param int $page The page number (0 for summary).
* @param int $per_page Items per page.
* @return array
*/
public function get_followers_collection( int $page = 0, int $per_page = 20 ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_followers';
// Get total count.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE artist_id = %d",
$this->artist->ID
)
);
// Return summary if page 0.
if ( $page === 0 ) {
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $this->get_followers(),
'type' => 'OrderedCollection',
'totalItems' => $total,
'first' => $this->get_followers() . '?page=1',
);
}
// Get paginated items.
$offset = ( $page - 1 ) * $per_page;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$followers = $wpdb->get_col(
$wpdb->prepare(
"SELECT follower_uri FROM {$table} WHERE artist_id = %d ORDER BY followed_at DESC LIMIT %d OFFSET %d",
$this->artist->ID,
$per_page,
$offset
)
);
$collection = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $this->get_followers() . '?page=' . $page,
'type' => 'OrderedCollectionPage',
'partOf' => $this->get_followers(),
'orderedItems' => $followers ?: array(),
);
// Add prev/next links.
if ( $page > 1 ) {
$collection['prev'] = $this->get_followers() . '?page=' . ( $page - 1 );
}
$total_pages = ceil( $total / $per_page );
if ( $page < $total_pages ) {
$collection['next'] = $this->get_followers() . '?page=' . ( $page + 1 );
}
return $collection;
}
}

View File

@@ -0,0 +1,477 @@
<?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;
}
}

View File

@@ -0,0 +1,480 @@
<?php
/**
* ActivityPub integration main class.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Main ActivityPub integration class.
*
* Integrates WP FediStream with the WordPress ActivityPub plugin.
*/
class Integration {
/**
* Whether the ActivityPub plugin is active.
*
* @var bool
*/
private bool $activitypub_active = false;
/**
* Constructor.
*/
public function __construct() {
// Check if ActivityPub plugin is available.
add_action( 'plugins_loaded', array( $this, 'check_activitypub' ), 5 );
// Initialize integration after ActivityPub is loaded.
add_action( 'plugins_loaded', array( $this, 'init' ), 20 );
}
/**
* Check if the ActivityPub plugin is active.
*
* @return void
*/
public function check_activitypub(): void {
$this->activitypub_active = defined( 'ACTIVITYPUB_PLUGIN_VERSION' )
|| class_exists( '\Activitypub\Activitypub' );
}
/**
* Initialize the ActivityPub integration.
*
* @return void
*/
public function init(): void {
if ( ! $this->activitypub_active ) {
return;
}
// Register custom post types for ActivityPub.
add_filter( 'activitypub_post_types', array( $this, 'register_post_types' ) );
// Register custom transformers.
add_filter( 'activitypub_transformers', array( $this, 'register_transformers' ) );
// Register artist actors.
add_action( 'init', array( $this, 'register_artist_actors' ), 20 );
// Add custom properties to ActivityPub objects.
add_filter( 'activitypub_activity_object_array', array( $this, 'add_audio_properties' ), 10, 3 );
// Handle incoming activities.
add_action( 'activitypub_inbox_like', array( $this, 'handle_like' ), 10, 2 );
add_action( 'activitypub_inbox_announce', array( $this, 'handle_announce' ), 10, 2 );
add_action( 'activitypub_inbox_create', array( $this, 'handle_create' ), 10, 2 );
// Add hooks for publishing.
add_action( 'publish_fedistream_track', array( $this, 'on_publish_track' ), 10, 2 );
add_action( 'publish_fedistream_album', array( $this, 'on_publish_album' ), 10, 2 );
}
/**
* Check if ActivityPub is active.
*
* @return bool
*/
public function is_active(): bool {
return $this->activitypub_active;
}
/**
* Register FediStream post types with ActivityPub.
*
* @param array $post_types The registered post types.
* @return array Modified post types.
*/
public function register_post_types( array $post_types ): array {
$post_types[] = 'fedistream_track';
$post_types[] = 'fedistream_album';
$post_types[] = 'fedistream_playlist';
return array_unique( $post_types );
}
/**
* Register custom transformers for FediStream post types.
*
* @param array $transformers The registered transformers.
* @return array Modified transformers.
*/
public function register_transformers( array $transformers ): array {
$transformers['fedistream_track'] = TrackTransformer::class;
$transformers['fedistream_album'] = AlbumTransformer::class;
$transformers['fedistream_playlist'] = PlaylistTransformer::class;
return $transformers;
}
/**
* Register artist actors for ActivityPub.
*
* @return void
*/
public function register_artist_actors(): void {
// Hook into ActivityPub actor discovery.
add_filter( 'activitypub_actor', array( $this, 'get_artist_actor' ), 10, 2 );
// Add artist webfinger handler.
add_filter( 'webfinger_data', array( $this, 'add_artist_webfinger' ), 10, 2 );
}
/**
* Get artist actor for ActivityPub.
*
* @param mixed $actor The current actor.
* @param string $id The actor ID or handle.
* @return mixed The actor object or original.
*/
public function get_artist_actor( $actor, string $id ) {
// Check if this is an artist handle.
if ( strpos( $id, 'artist-' ) === 0 ) {
$artist_id = absint( str_replace( 'artist-', '', $id ) );
$artist = get_post( $artist_id );
if ( $artist && 'fedistream_artist' === $artist->post_type ) {
return new ArtistActor( $artist );
}
}
return $actor;
}
/**
* Add artist to webfinger data.
*
* @param array $data The webfinger data.
* @param string $resource The requested resource.
* @return array Modified webfinger data.
*/
public function add_artist_webfinger( array $data, string $resource ): array {
// Parse acct: URI.
if ( preg_match( '/^acct:artist-(\d+)@/', $resource, $matches ) ) {
$artist_id = absint( $matches[1] );
$artist = get_post( $artist_id );
if ( $artist && 'fedistream_artist' === $artist->post_type ) {
$actor = new ArtistActor( $artist );
$data = $actor->get_webfinger();
}
}
return $data;
}
/**
* Add audio-specific properties to ActivityPub objects.
*
* @param array $array The object array.
* @param object $object The ActivityPub object.
* @param int $post_id The post ID.
* @return array Modified array.
*/
public function add_audio_properties( array $array, $object, int $post_id ): array {
$post = get_post( $post_id );
if ( ! $post ) {
return $array;
}
// Add audio-specific properties for tracks.
if ( 'fedistream_track' === $post->post_type ) {
$duration = get_post_meta( $post_id, '_fedistream_duration', true );
if ( $duration ) {
$array['duration'] = $this->format_duration_iso8601( (int) $duration );
}
$audio_id = get_post_meta( $post_id, '_fedistream_audio_file', true );
$audio_url = $audio_id ? wp_get_attachment_url( $audio_id ) : '';
if ( $audio_url ) {
$array['attachment'][] = array(
'type' => 'Audio',
'mediaType' => 'audio/mpeg',
'url' => $audio_url,
'name' => $post->post_title,
'duration' => $this->format_duration_iso8601( (int) $duration ),
);
}
}
return $array;
}
/**
* Handle incoming Like activity.
*
* @param array $activity The activity data.
* @param int $user_id The user ID (actor).
* @return void
*/
public function handle_like( array $activity, int $user_id ): void {
$object_id = $activity['object'] ?? '';
if ( ! $object_id ) {
return;
}
// Find local post from object ID.
$post_id = url_to_postid( $object_id );
if ( ! $post_id ) {
return;
}
$post = get_post( $post_id );
if ( ! $post || ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album' ), true ) ) {
return;
}
// Record the like.
$this->record_reaction( $post_id, $activity['actor'] ?? '', 'like', $activity );
}
/**
* Handle incoming Announce (boost) activity.
*
* @param array $activity The activity data.
* @param int $user_id The user ID (actor).
* @return void
*/
public function handle_announce( array $activity, int $user_id ): void {
$object_id = $activity['object'] ?? '';
if ( ! $object_id ) {
return;
}
// Find local post from object ID.
$post_id = url_to_postid( $object_id );
if ( ! $post_id ) {
return;
}
$post = get_post( $post_id );
if ( ! $post || ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album' ), true ) ) {
return;
}
// Record the boost.
$this->record_reaction( $post_id, $activity['actor'] ?? '', 'boost', $activity );
}
/**
* Handle incoming Create activity (comments/replies).
*
* @param array $activity The activity data.
* @param int $user_id The user ID (actor).
* @return void
*/
public function handle_create( array $activity, int $user_id ): void {
$object = $activity['object'] ?? array();
if ( empty( $object ) ) {
return;
}
// Check if this is a reply to our content.
$in_reply_to = $object['inReplyTo'] ?? '';
if ( ! $in_reply_to ) {
return;
}
// Find local post from reply target.
$post_id = url_to_postid( $in_reply_to );
if ( ! $post_id ) {
return;
}
$post = get_post( $post_id );
if ( ! $post || ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album' ), true ) ) {
return;
}
// Record the reply.
$this->record_reaction( $post_id, $activity['actor'] ?? '', 'reply', $activity );
}
/**
* Record a reaction from the Fediverse.
*
* @param int $post_id The post ID.
* @param string $actor The actor URI.
* @param string $type The reaction type (like, boost, reply).
* @param array $activity The full activity data.
* @return bool True on success.
*/
private function record_reaction( int $post_id, string $actor, string $type, array $activity ): bool {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_reactions';
// Check if table exists.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$table_exists = $wpdb->get_var(
$wpdb->prepare(
'SHOW TABLES LIKE %s',
$table
)
);
if ( ! $table_exists ) {
$this->create_reactions_table();
}
// Insert reaction.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->insert(
$table,
array(
'post_id' => $post_id,
'actor_uri' => $actor,
'reaction_type' => $type,
'activity_data' => wp_json_encode( $activity ),
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%s', '%s', '%s' )
);
// Update reaction count meta.
if ( $result ) {
$meta_key = '_fedistream_' . $type . '_count';
$count = (int) get_post_meta( $post_id, $meta_key, true );
update_post_meta( $post_id, $meta_key, $count + 1 );
}
return (bool) $result;
}
/**
* Create the reactions table.
*
* @return void
*/
private function create_reactions_table(): void {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_reactions';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
actor_uri varchar(2083) NOT NULL,
reaction_type varchar(50) NOT NULL,
activity_data longtext,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id),
KEY actor_uri (actor_uri(191)),
KEY reaction_type (reaction_type)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
/**
* Handle track publishing.
*
* @param int $post_id The post ID.
* @param \WP_Post $post The post object.
* @return void
*/
public function on_publish_track( int $post_id, \WP_Post $post ): void {
// The ActivityPub plugin will handle the publishing automatically.
// This hook is for any additional custom logic.
do_action( 'fedistream_track_published_activitypub', $post_id, $post );
}
/**
* Handle album publishing.
*
* @param int $post_id The post ID.
* @param \WP_Post $post The post object.
* @return void
*/
public function on_publish_album( int $post_id, \WP_Post $post ): void {
// The ActivityPub plugin will handle the publishing automatically.
// This hook is for any additional custom logic.
do_action( 'fedistream_album_published_activitypub', $post_id, $post );
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
/**
* Get reactions for a post.
*
* @param int $post_id The post ID.
* @param string $type Optional reaction type filter.
* @return array The reactions.
*/
public function get_reactions( int $post_id, string $type = '' ): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_reactions';
if ( $type ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE post_id = %d AND reaction_type = %s ORDER BY created_at DESC",
$post_id,
$type
)
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE post_id = %d ORDER BY created_at DESC",
$post_id
)
);
}
return $results ?: array();
}
/**
* Get reaction counts for a post.
*
* @param int $post_id The post ID.
* @return array The reaction counts.
*/
public function get_reaction_counts( int $post_id ): array {
return array(
'likes' => (int) get_post_meta( $post_id, '_fedistream_like_count', true ),
'boosts' => (int) get_post_meta( $post_id, '_fedistream_boost_count', true ),
'replies' => (int) get_post_meta( $post_id, '_fedistream_reply_count', true ),
);
}
}

View File

@@ -0,0 +1,415 @@
<?php
/**
* Outbox handler for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles publishing activities to followers.
*/
class Outbox {
/**
* The follower handler.
*
* @var FollowerHandler
*/
private FollowerHandler $follower_handler;
/**
* Constructor.
*/
public function __construct() {
$this->follower_handler = new FollowerHandler();
// Hook into post publishing.
add_action( 'transition_post_status', array( $this, 'on_post_status_change' ), 10, 3 );
// Hook into post update.
add_action( 'post_updated', array( $this, 'on_post_updated' ), 10, 3 );
}
/**
* Handle post status changes.
*
* @param string $new_status The new post status.
* @param string $old_status The old post status.
* @param \WP_Post $post The post object.
* @return void
*/
public function on_post_status_change( string $new_status, string $old_status, \WP_Post $post ): void {
// Only handle FediStream post types.
if ( ! in_array( $post->post_type, array( 'fedistream_track', 'fedistream_album', 'fedistream_playlist' ), true ) ) {
return;
}
// Check if publishing is enabled for this post.
$enabled = get_post_meta( $post->ID, '_fedistream_activitypub_publish', true );
if ( ! $enabled && $enabled !== '' ) {
// Default to enabled for new posts.
if ( $old_status === 'new' || $old_status === 'auto-draft' ) {
update_post_meta( $post->ID, '_fedistream_activitypub_publish', '1' );
$enabled = '1';
} else {
return;
}
}
// Publish on status change to 'publish'.
if ( 'publish' === $new_status && 'publish' !== $old_status ) {
$this->publish_create( $post );
}
}
/**
* Handle post updates.
*
* @param int $post_id The post ID.
* @param \WP_Post $post_after The post object after update.
* @param \WP_Post $post_before The post object before update.
* @return void
*/
public function on_post_updated( int $post_id, \WP_Post $post_after, \WP_Post $post_before ): void {
// Only handle published FediStream post types.
if ( $post_after->post_status !== 'publish' ) {
return;
}
if ( ! in_array( $post_after->post_type, array( 'fedistream_track', 'fedistream_album', 'fedistream_playlist' ), true ) ) {
return;
}
// Check if already published to ActivityPub.
$published = get_post_meta( $post_id, '_fedistream_activitypub_published', true );
if ( ! $published ) {
return;
}
// Check if update publishing is enabled.
$publish_updates = get_post_meta( $post_id, '_fedistream_activitypub_publish_updates', true );
if ( ! $publish_updates ) {
return;
}
// Only publish updates for significant changes.
if ( $this->has_significant_changes( $post_before, $post_after ) ) {
$this->publish_update( $post_after );
}
}
/**
* Check if a post has significant changes.
*
* @param \WP_Post $before The post before update.
* @param \WP_Post $after The post after update.
* @return bool
*/
private function has_significant_changes( \WP_Post $before, \WP_Post $after ): bool {
// Check title change.
if ( $before->post_title !== $after->post_title ) {
return true;
}
// Check content change.
if ( $before->post_content !== $after->post_content ) {
return true;
}
// Check for audio file change (tracks).
if ( 'fedistream_track' === $after->post_type ) {
$audio_before = get_post_meta( $after->ID, '_fedistream_audio_file_previous', true );
$audio_after = get_post_meta( $after->ID, '_fedistream_audio_file', true );
if ( $audio_before !== $audio_after ) {
return true;
}
}
return false;
}
/**
* Publish a Create activity for a post.
*
* @param \WP_Post $post The post to publish.
* @return bool True on success.
*/
public function publish_create( \WP_Post $post ): bool {
$transformer = $this->get_transformer( $post );
if ( ! $transformer ) {
return false;
}
$activity = $transformer->to_create_activity();
// Get the artist for this content.
$artist_id = $this->get_artist_for_post( $post );
if ( ! $artist_id ) {
return false;
}
// Broadcast to followers.
$results = $this->follower_handler->broadcast_activity( $artist_id, $activity );
// Mark as published.
update_post_meta( $post->ID, '_fedistream_activitypub_published', current_time( 'mysql' ) );
// Log results.
$success_count = count( array_filter( $results ) );
$total_count = count( $results );
do_action( 'fedistream_activitypub_published', $post->ID, $success_count, $total_count );
return $success_count > 0 || $total_count === 0;
}
/**
* Publish an Update activity for a post.
*
* @param \WP_Post $post The post to update.
* @return bool True on success.
*/
public function publish_update( \WP_Post $post ): bool {
$transformer = $this->get_transformer( $post );
if ( ! $transformer ) {
return false;
}
// Build Update activity.
$object = $transformer->to_object();
$actor = $transformer->get_attributed_to();
$actor = is_array( $actor ) ? $actor[0] : $actor;
$activity = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Update',
'id' => get_permalink( $post->ID ) . '#activity-update-' . time(),
'actor' => $actor,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor . '/followers' ),
'object' => $object,
);
// Get the artist for this content.
$artist_id = $this->get_artist_for_post( $post );
if ( ! $artist_id ) {
return false;
}
// Broadcast to followers.
$results = $this->follower_handler->broadcast_activity( $artist_id, $activity );
// Update timestamp.
update_post_meta( $post->ID, '_fedistream_activitypub_updated', current_time( 'mysql' ) );
$success_count = count( array_filter( $results ) );
return $success_count > 0 || count( $results ) === 0;
}
/**
* Publish an Announce (boost) activity.
*
* @param int $artist_id The artist ID doing the announcing.
* @param string $object_uri The object URI to announce.
* @return bool True on success.
*/
public function publish_announce( int $artist_id, string $object_uri ): bool {
$artist = get_post( $artist_id );
if ( ! $artist || 'fedistream_artist' !== $artist->post_type ) {
return false;
}
$actor = new ArtistActor( $artist );
$activity = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Announce',
'id' => $actor->get_id() . '#announce-' . time(),
'actor' => $actor->get_id(),
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor->get_followers() ),
'object' => $object_uri,
);
// Broadcast to followers.
$results = $this->follower_handler->broadcast_activity( $artist_id, $activity );
return count( array_filter( $results ) ) > 0 || count( $results ) === 0;
}
/**
* Publish a Delete activity for a post.
*
* @param \WP_Post $post The post being deleted.
* @return bool True on success.
*/
public function publish_delete( \WP_Post $post ): bool {
// Check if was published to ActivityPub.
$published = get_post_meta( $post->ID, '_fedistream_activitypub_published', true );
if ( ! $published ) {
return false;
}
$actor = $this->get_attributed_to( $post );
if ( ! $actor ) {
return false;
}
$activity = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Delete',
'id' => get_permalink( $post->ID ) . '#activity-delete-' . time(),
'actor' => $actor,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'object' => array(
'type' => 'Tombstone',
'id' => get_permalink( $post->ID ),
),
);
// Get the artist for this content.
$artist_id = $this->get_artist_for_post( $post );
if ( ! $artist_id ) {
return false;
}
// Broadcast to followers.
$results = $this->follower_handler->broadcast_activity( $artist_id, $activity );
return count( array_filter( $results ) ) > 0 || count( $results ) === 0;
}
/**
* Get the appropriate transformer for a post.
*
* @param \WP_Post $post The post.
* @return TrackTransformer|AlbumTransformer|PlaylistTransformer|null
*/
private function get_transformer( \WP_Post $post ) {
switch ( $post->post_type ) {
case 'fedistream_track':
return new TrackTransformer( $post );
case 'fedistream_album':
return new AlbumTransformer( $post );
case 'fedistream_playlist':
return new PlaylistTransformer( $post );
default:
return null;
}
}
/**
* Get the artist ID associated with a post.
*
* @param \WP_Post $post The post.
* @return int|null
*/
private function get_artist_for_post( \WP_Post $post ): ?int {
switch ( $post->post_type ) {
case 'fedistream_track':
$artist_ids = get_post_meta( $post->ID, '_fedistream_artist_ids', true );
if ( is_array( $artist_ids ) && ! empty( $artist_ids ) ) {
return absint( $artist_ids[0] );
}
// Fall back to album artist.
$album_id = get_post_meta( $post->ID, '_fedistream_album_id', true );
if ( $album_id ) {
return absint( get_post_meta( $album_id, '_fedistream_album_artist', true ) );
}
break;
case 'fedistream_album':
$artist_id = get_post_meta( $post->ID, '_fedistream_album_artist', true );
return $artist_id ? absint( $artist_id ) : null;
case 'fedistream_playlist':
// For playlists, try to find an artist owned by the author.
$artist_args = array(
'post_type' => 'fedistream_artist',
'posts_per_page' => 1,
'author' => $post->post_author,
);
$artists = get_posts( $artist_args );
if ( ! empty( $artists ) ) {
return $artists[0]->ID;
}
break;
}
return null;
}
/**
* Get the attributed actor for a post.
*
* @param \WP_Post $post The post.
* @return string|null Actor URI.
*/
private function get_attributed_to( \WP_Post $post ): ?string {
$artist_id = $this->get_artist_for_post( $post );
if ( $artist_id ) {
return get_permalink( $artist_id );
}
return null;
}
/**
* Manually trigger publication for a post.
*
* @param int $post_id The post ID.
* @return bool True on success.
*/
public function manual_publish( int $post_id ): bool {
$post = get_post( $post_id );
if ( ! $post || 'publish' !== $post->post_status ) {
return false;
}
// Check if already published.
$published = get_post_meta( $post_id, '_fedistream_activitypub_published', true );
if ( $published ) {
return $this->publish_update( $post );
}
return $this->publish_create( $post );
}
/**
* Get the outbox collection for an artist.
*
* @param int $artist_id The artist ID.
* @param int $page The page number (0 for summary).
* @param int $per_page Items per page.
* @return array
*/
public function get_collection( int $artist_id, int $page = 0, int $per_page = 20 ): array {
$artist = get_post( $artist_id );
if ( ! $artist || 'fedistream_artist' !== $artist->post_type ) {
return array();
}
$actor = new ArtistActor( $artist );
return $actor->get_outbox_collection( $page, $per_page );
}
}

View File

@@ -0,0 +1,433 @@
<?php
/**
* Playlist Transformer for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Transforms Playlist posts to ActivityPub OrderedCollection objects.
*/
class PlaylistTransformer {
/**
* The playlist post.
*
* @var \WP_Post
*/
protected \WP_Post $post;
/**
* Constructor.
*
* @param \WP_Post $post The playlist post.
*/
public function __construct( \WP_Post $post ) {
$this->post = $post;
}
/**
* Get the ActivityPub object type.
*
* @return string
*/
public function get_type(): string {
return 'OrderedCollection';
}
/**
* Get the object ID (URI).
*
* @return string
*/
public function get_id(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the object name (title).
*
* @return string
*/
public function get_name(): string {
return $this->post->post_title;
}
/**
* Get the content (description).
*
* @return string
*/
public function get_content(): string {
$content = $this->post->post_content;
// Apply content filters for proper formatting.
$content = apply_filters( 'the_content', $content );
return wp_kses_post( $content );
}
/**
* Get the summary (excerpt).
*
* @return string
*/
public function get_summary(): string {
if ( ! empty( $this->post->post_excerpt ) ) {
return wp_strip_all_tags( $this->post->post_excerpt );
}
// Generate excerpt from content.
return wp_trim_words( wp_strip_all_tags( $this->post->post_content ), 30 );
}
/**
* Get the URL (permalink).
*
* @return string
*/
public function get_url(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the attributed actor (playlist creator).
*
* @return string User's ActivityPub actor URI.
*/
public function get_attributed_to(): string {
$user_id = $this->post->post_author;
// Check if there's an artist associated with this user.
$artist_args = array(
'post_type' => 'fedistream_artist',
'posts_per_page' => 1,
'author' => $user_id,
);
$artists = get_posts( $artist_args );
if ( ! empty( $artists ) ) {
return get_permalink( $artists[0]->ID );
}
// Fall back to user profile URL.
return get_author_posts_url( $user_id );
}
/**
* Get the published date.
*
* @return string ISO 8601 date.
*/
public function get_published(): string {
return get_the_date( 'c', $this->post );
}
/**
* Get the updated date.
*
* @return string ISO 8601 date.
*/
public function get_updated(): string {
return get_the_modified_date( 'c', $this->post );
}
/**
* Get the total duration.
*
* @return string ISO 8601 duration.
*/
public function get_duration(): string {
$seconds = (int) get_post_meta( $this->post->ID, '_fedistream_playlist_duration', true );
if ( ! $seconds ) {
// Calculate from tracks.
$tracks = $this->get_tracks();
foreach ( $tracks as $track ) {
$seconds += (int) get_post_meta( $track->ID, '_fedistream_duration', true );
}
}
if ( ! $seconds ) {
return '';
}
return $this->format_duration_iso8601( $seconds );
}
/**
* Get the tracks in this playlist.
*
* @return array Array of WP_Post objects.
*/
public function get_tracks(): array {
global $wpdb;
$table = $wpdb->prefix . 'fedistream_playlist_tracks';
// Get track IDs in order.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$track_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT track_id FROM {$table} WHERE playlist_id = %d ORDER BY position ASC",
$this->post->ID
)
);
if ( empty( $track_ids ) ) {
return array();
}
// Get track posts in order.
$args = array(
'post_type' => 'fedistream_track',
'post_status' => 'publish',
'post__in' => $track_ids,
'orderby' => 'post__in',
'posts_per_page' => -1,
);
$query = new \WP_Query( $args );
return $query->posts;
}
/**
* Get the total item count.
*
* @return int
*/
public function get_total_items(): int {
$count = (int) get_post_meta( $this->post->ID, '_fedistream_track_count', true );
if ( ! $count ) {
$count = count( $this->get_tracks() );
}
return $count;
}
/**
* Get the ordered items (track objects).
*
* @return array
*/
public function get_ordered_items(): array {
$tracks = $this->get_tracks();
$items = array();
foreach ( $tracks as $track ) {
$transformer = new TrackTransformer( $track );
$items[] = $transformer->to_object();
}
return $items;
}
/**
* Get the image/cover attachment.
*
* @return array|null
*/
public function get_image_attachment(): ?array {
$thumbnail_id = get_post_thumbnail_id( $this->post->ID );
if ( ! $thumbnail_id ) {
return null;
}
$image = wp_get_attachment_image_src( $thumbnail_id, 'medium' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get tags (moods).
*
* @return array
*/
public function get_tags(): array {
$tags = array();
// Get moods.
$moods = get_the_terms( $this->post->ID, 'fedistream_mood' );
if ( $moods && ! is_wp_error( $moods ) ) {
foreach ( $moods as $mood ) {
$tags[] = array(
'type' => 'Hashtag',
'name' => '#' . sanitize_title( $mood->name ),
'href' => get_term_link( $mood ),
);
}
}
return $tags;
}
/**
* Check if the playlist is public.
*
* @return bool
*/
public function is_public(): bool {
$visibility = get_post_meta( $this->post->ID, '_fedistream_playlist_visibility', true );
return 'public' === $visibility || empty( $visibility );
}
/**
* Transform to ActivityPub object array.
*
* @return array
*/
public function to_object(): array {
$object = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => $this->get_type(),
'id' => $this->get_id(),
'name' => $this->get_name(),
'summary' => $this->get_summary(),
'content' => $this->get_content(),
'url' => $this->get_url(),
'attributedTo' => $this->get_attributed_to(),
'published' => $this->get_published(),
'updated' => $this->get_updated(),
'totalItems' => $this->get_total_items(),
'orderedItems' => $this->get_ordered_items(),
);
// Add duration.
$duration = $this->get_duration();
if ( $duration ) {
$object['duration'] = $duration;
}
// Add image.
$image = $this->get_image_attachment();
if ( $image ) {
$object['image'] = $image;
}
// Add tags.
$tags = $this->get_tags();
if ( ! empty( $tags ) ) {
$object['tag'] = $tags;
}
// Add visibility indicator.
if ( ! $this->is_public() ) {
$object['sensitive'] = false;
$visibility = get_post_meta( $this->post->ID, '_fedistream_playlist_visibility', true );
if ( 'unlisted' === $visibility ) {
$object['visibility'] = 'unlisted';
}
}
// Add collaborative flag.
$collaborative = get_post_meta( $this->post->ID, '_fedistream_playlist_collaborative', true );
if ( $collaborative ) {
$object['collaborative'] = true;
}
return $object;
}
/**
* Create a Create activity for this playlist.
*
* @return array
*/
public function to_create_activity(): array {
$actor = $this->get_attributed_to();
// Determine audience based on visibility.
$visibility = get_post_meta( $this->post->ID, '_fedistream_playlist_visibility', true );
$to = array();
$cc = array();
if ( 'public' === $visibility || empty( $visibility ) ) {
$to[] = 'https://www.w3.org/ns/activitystreams#Public';
$cc[] = $actor . '/followers';
} elseif ( 'unlisted' === $visibility ) {
$to[] = $actor . '/followers';
$cc[] = 'https://www.w3.org/ns/activitystreams#Public';
} else {
// Private - only followers.
$to[] = $actor . '/followers';
}
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Create',
'id' => $this->get_id() . '#activity-create',
'actor' => $actor,
'published' => $this->get_published(),
'to' => $to,
'cc' => $cc,
'object' => $this->to_object(),
);
}
/**
* Create an Update activity for this playlist.
*
* @return array
*/
public function to_update_activity(): array {
$actor = $this->get_attributed_to();
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Update',
'id' => $this->get_id() . '#activity-update-' . time(),
'actor' => $actor,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor . '/followers' ),
'object' => $this->to_object(),
);
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
}

View File

@@ -0,0 +1,476 @@
<?php
/**
* REST API endpoints for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* REST API handler for ActivityPub endpoints.
*/
class RestApi {
/**
* The namespace for REST routes.
*
* @var string
*/
private const NAMESPACE = 'fedistream/v1';
/**
* The follower handler.
*
* @var FollowerHandler
*/
private FollowerHandler $follower_handler;
/**
* The outbox handler.
*
* @var Outbox
*/
private Outbox $outbox;
/**
* Constructor.
*/
public function __construct() {
$this->follower_handler = new FollowerHandler();
$this->outbox = new Outbox();
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
// Add ActivityPub content type to allowed responses.
add_filter( 'rest_pre_serve_request', array( $this, 'serve_activitypub' ), 10, 4 );
}
/**
* Register REST API routes.
*
* @return void
*/
public function register_routes(): void {
// Artist actor endpoint.
register_rest_route(
self::NAMESPACE,
'/artist/(?P<id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_artist_actor' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => array( $this, 'validate_artist_id' ),
),
),
)
);
// Artist inbox endpoint.
register_rest_route(
self::NAMESPACE,
'/artist/(?P<id>\d+)/inbox',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_inbox' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => array( $this, 'validate_artist_id' ),
),
),
)
);
// Artist outbox endpoint.
register_rest_route(
self::NAMESPACE,
'/artist/(?P<id>\d+)/outbox',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_outbox' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => array( $this, 'validate_artist_id' ),
),
'page' => array(
'default' => 0,
'sanitize_callback' => 'absint',
),
),
)
);
// Artist followers endpoint.
register_rest_route(
self::NAMESPACE,
'/artist/(?P<id>\d+)/followers',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_followers' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => array( $this, 'validate_artist_id' ),
),
'page' => array(
'default' => 0,
'sanitize_callback' => 'absint',
),
),
)
);
// Track/Album/Playlist object endpoint.
register_rest_route(
self::NAMESPACE,
'/object/(?P<type>track|album|playlist)/(?P<id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_object' ),
'permission_callback' => '__return_true',
'args' => array(
'type' => array(
'required' => true,
),
'id' => array(
'required' => true,
'sanitize_callback' => 'absint',
),
),
)
);
// Reactions endpoint.
register_rest_route(
self::NAMESPACE,
'/reactions/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_reactions' ),
'permission_callback' => '__return_true',
'args' => array(
'post_id' => array(
'required' => true,
'sanitize_callback' => 'absint',
),
'type' => array(
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
// Manual publish endpoint (requires auth).
register_rest_route(
self::NAMESPACE,
'/publish/(?P<post_id>\d+)',
array(
'methods' => 'POST',
'callback' => array( $this, 'manual_publish' ),
'permission_callback' => array( $this, 'can_edit_post' ),
'args' => array(
'post_id' => array(
'required' => true,
'sanitize_callback' => 'absint',
),
),
)
);
}
/**
* Validate artist ID.
*
* @param mixed $id The ID to validate.
* @return bool
*/
public function validate_artist_id( $id ): bool {
$artist = get_post( absint( $id ) );
return $artist && 'fedistream_artist' === $artist->post_type && 'publish' === $artist->post_status;
}
/**
* Check if user can edit the post.
*
* @param \WP_REST_Request $request The request.
* @return bool
*/
public function can_edit_post( \WP_REST_Request $request ): bool {
$post_id = absint( $request->get_param( 'post_id' ) );
return current_user_can( 'edit_post', $post_id );
}
/**
* Get artist actor.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_artist_actor( \WP_REST_Request $request ): \WP_REST_Response {
$artist_id = absint( $request->get_param( 'id' ) );
$artist = get_post( $artist_id );
if ( ! $artist ) {
return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 );
}
$actor = new ArtistActor( $artist );
$response = new \WP_REST_Response( $actor->to_array() );
$response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $response;
}
/**
* Handle inbox requests.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function handle_inbox( \WP_REST_Request $request ): \WP_REST_Response {
$artist_id = absint( $request->get_param( 'id' ) );
$artist = get_post( $artist_id );
if ( ! $artist ) {
return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 );
}
// Get the activity from request body.
$activity = $request->get_json_params();
if ( empty( $activity ) ) {
return new \WP_REST_Response( array( 'error' => 'Invalid activity' ), 400 );
}
// Verify HTTP signature (basic verification).
$signature = $request->get_header( 'Signature' );
if ( ! $signature ) {
// Allow unsigned requests for now, but log it.
do_action( 'fedistream_unsigned_activity', $activity, $artist_id );
}
// Process the activity based on type.
$type = $activity['type'] ?? '';
switch ( $type ) {
case 'Follow':
$this->follower_handler->handle_follow( $activity, 0 );
break;
case 'Undo':
$this->follower_handler->handle_undo( $activity, 0 );
break;
case 'Like':
do_action( 'activitypub_inbox_like', $activity, 0 );
break;
case 'Announce':
do_action( 'activitypub_inbox_announce', $activity, 0 );
break;
case 'Create':
do_action( 'activitypub_inbox_create', $activity, 0 );
break;
case 'Delete':
do_action( 'activitypub_inbox_delete', $activity, 0 );
break;
default:
do_action( 'fedistream_inbox_activity', $activity, $artist_id, $type );
break;
}
return new \WP_REST_Response( null, 202 );
}
/**
* Get outbox collection.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_outbox( \WP_REST_Request $request ): \WP_REST_Response {
$artist_id = absint( $request->get_param( 'id' ) );
$page = absint( $request->get_param( 'page' ) );
$artist = get_post( $artist_id );
if ( ! $artist ) {
return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 );
}
$actor = new ArtistActor( $artist );
$collection = $actor->get_outbox_collection( $page );
$response = new \WP_REST_Response( $collection );
$response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $response;
}
/**
* Get followers collection.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_followers( \WP_REST_Request $request ): \WP_REST_Response {
$artist_id = absint( $request->get_param( 'id' ) );
$page = absint( $request->get_param( 'page' ) );
$artist = get_post( $artist_id );
if ( ! $artist ) {
return new \WP_REST_Response( array( 'error' => 'Artist not found' ), 404 );
}
$actor = new ArtistActor( $artist );
$collection = $actor->get_followers_collection( $page );
$response = new \WP_REST_Response( $collection );
$response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $response;
}
/**
* Get ActivityPub object.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_object( \WP_REST_Request $request ): \WP_REST_Response {
$type = $request->get_param( 'type' );
$post_id = absint( $request->get_param( 'id' ) );
$post_type = 'fedistream_' . $type;
$post = get_post( $post_id );
if ( ! $post || $post->post_type !== $post_type || 'publish' !== $post->post_status ) {
return new \WP_REST_Response( array( 'error' => 'Object not found' ), 404 );
}
$transformer = null;
switch ( $type ) {
case 'track':
$transformer = new TrackTransformer( $post );
break;
case 'album':
$transformer = new AlbumTransformer( $post );
break;
case 'playlist':
$transformer = new PlaylistTransformer( $post );
break;
}
if ( ! $transformer ) {
return new \WP_REST_Response( array( 'error' => 'Invalid object type' ), 400 );
}
$object = $transformer->to_object();
$response = new \WP_REST_Response( $object );
$response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $response;
}
/**
* Get reactions for a post.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function get_reactions( \WP_REST_Request $request ): \WP_REST_Response {
$post_id = absint( $request->get_param( 'post_id' ) );
$type = sanitize_text_field( $request->get_param( 'type' ) );
$post = get_post( $post_id );
if ( ! $post ) {
return new \WP_REST_Response( array( 'error' => 'Post not found' ), 404 );
}
$integration = new Integration();
$reactions = $integration->get_reactions( $post_id, $type );
$counts = $integration->get_reaction_counts( $post_id );
return new \WP_REST_Response(
array(
'reactions' => $reactions,
'counts' => $counts,
)
);
}
/**
* Manually publish a post to ActivityPub.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function manual_publish( \WP_REST_Request $request ): \WP_REST_Response {
$post_id = absint( $request->get_param( 'post_id' ) );
$result = $this->outbox->manual_publish( $post_id );
if ( $result ) {
return new \WP_REST_Response(
array(
'success' => true,
'message' => __( 'Published to ActivityPub', 'wp-fedistream' ),
)
);
}
return new \WP_REST_Response(
array(
'success' => false,
'message' => __( 'Failed to publish', 'wp-fedistream' ),
),
500
);
}
/**
* Serve ActivityPub content type when requested.
*
* @param bool $served Whether the request has been served.
* @param \WP_REST_Response $result The response.
* @param \WP_REST_Request $request The request.
* @param \WP_REST_Server $server The server.
* @return bool
*/
public function serve_activitypub( bool $served, $result, \WP_REST_Request $request, \WP_REST_Server $server ): bool {
// Check if this is a FediStream route.
$route = $request->get_route();
if ( strpos( $route, '/fedistream/' ) === false ) {
return $served;
}
// Check Accept header for ActivityPub.
$accept = $request->get_header( 'Accept' );
if ( $accept && ( strpos( $accept, 'application/activity+json' ) !== false || strpos( $accept, 'application/ld+json' ) !== false ) ) {
// Will be handled by our response headers.
}
return $served;
}
}

View File

@@ -0,0 +1,412 @@
<?php
/**
* Track Transformer for ActivityPub.
*
* @package WP_FediStream
*/
namespace WP_FediStream\ActivityPub;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Transforms Track posts to ActivityPub Audio objects.
*/
class TrackTransformer {
/**
* The track post.
*
* @var \WP_Post
*/
protected \WP_Post $post;
/**
* Constructor.
*
* @param \WP_Post $post The track post.
*/
public function __construct( \WP_Post $post ) {
$this->post = $post;
}
/**
* Get the ActivityPub object type.
*
* @return string
*/
public function get_type(): string {
return 'Audio';
}
/**
* Get the object ID (URI).
*
* @return string
*/
public function get_id(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the object name (title).
*
* @return string
*/
public function get_name(): string {
return $this->post->post_title;
}
/**
* Get the content (lyrics or description).
*
* @return string
*/
public function get_content(): string {
$content = $this->post->post_content;
// Apply content filters for proper formatting.
$content = apply_filters( 'the_content', $content );
return wp_kses_post( $content );
}
/**
* Get the summary (excerpt).
*
* @return string
*/
public function get_summary(): string {
if ( ! empty( $this->post->post_excerpt ) ) {
return wp_strip_all_tags( $this->post->post_excerpt );
}
// Generate excerpt from content.
return wp_trim_words( wp_strip_all_tags( $this->post->post_content ), 30 );
}
/**
* Get the URL (permalink).
*
* @return string
*/
public function get_url(): string {
return get_permalink( $this->post->ID );
}
/**
* Get the attributed actor(s).
*
* @return array|string Artist(s) URIs.
*/
public function get_attributed_to() {
$artist_ids = get_post_meta( $this->post->ID, '_fedistream_artist_ids', true );
if ( ! is_array( $artist_ids ) || empty( $artist_ids ) ) {
// Fall back to album artist.
$album_id = get_post_meta( $this->post->ID, '_fedistream_album_id', true );
$artist_id = $album_id ? get_post_meta( $album_id, '_fedistream_album_artist', true ) : 0;
if ( $artist_id ) {
return get_permalink( $artist_id );
}
return array();
}
// Return single artist or array of artists.
if ( count( $artist_ids ) === 1 ) {
return get_permalink( $artist_ids[0] );
}
return array_map( 'get_permalink', $artist_ids );
}
/**
* Get the published date.
*
* @return string ISO 8601 date.
*/
public function get_published(): string {
return get_the_date( 'c', $this->post );
}
/**
* Get the updated date.
*
* @return string ISO 8601 date.
*/
public function get_updated(): string {
return get_the_modified_date( 'c', $this->post );
}
/**
* Get the duration.
*
* @return string ISO 8601 duration.
*/
public function get_duration(): string {
$seconds = (int) get_post_meta( $this->post->ID, '_fedistream_duration', true );
if ( ! $seconds ) {
return '';
}
return $this->format_duration_iso8601( $seconds );
}
/**
* Get the audio attachment.
*
* @return array|null
*/
public function get_audio_attachment(): ?array {
$audio_id = get_post_meta( $this->post->ID, '_fedistream_audio_file', true );
if ( ! $audio_id ) {
return null;
}
$audio_url = wp_get_attachment_url( $audio_id );
$mime_type = get_post_mime_type( $audio_id );
$audio_meta = wp_get_attachment_metadata( $audio_id );
if ( ! $audio_url ) {
return null;
}
$attachment = array(
'type' => 'Audio',
'mediaType' => $mime_type ?: 'audio/mpeg',
'url' => $audio_url,
'name' => $this->post->post_title,
);
// Add duration if available.
$duration = $this->get_duration();
if ( $duration ) {
$attachment['duration'] = $duration;
}
return $attachment;
}
/**
* Get the image/thumbnail attachment.
*
* @return array|null
*/
public function get_image_attachment(): ?array {
$thumbnail_id = get_post_thumbnail_id( $this->post->ID );
// Fall back to album artwork.
if ( ! $thumbnail_id ) {
$album_id = get_post_meta( $this->post->ID, '_fedistream_album_id', true );
$thumbnail_id = $album_id ? get_post_thumbnail_id( $album_id ) : 0;
}
if ( ! $thumbnail_id ) {
return null;
}
$image = wp_get_attachment_image_src( $thumbnail_id, 'medium' );
if ( ! $image ) {
return null;
}
return array(
'type' => 'Image',
'mediaType' => get_post_mime_type( $thumbnail_id ),
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
);
}
/**
* Get tags (genres, moods).
*
* @return array
*/
public function get_tags(): array {
$tags = array();
// Get genres.
$genres = get_the_terms( $this->post->ID, 'fedistream_genre' );
if ( $genres && ! is_wp_error( $genres ) ) {
foreach ( $genres as $genre ) {
$tags[] = array(
'type' => 'Hashtag',
'name' => '#' . sanitize_title( $genre->name ),
'href' => get_term_link( $genre ),
);
}
}
// Get moods.
$moods = get_the_terms( $this->post->ID, 'fedistream_mood' );
if ( $moods && ! is_wp_error( $moods ) ) {
foreach ( $moods as $mood ) {
$tags[] = array(
'type' => 'Hashtag',
'name' => '#' . sanitize_title( $mood->name ),
'href' => get_term_link( $mood ),
);
}
}
return $tags;
}
/**
* Get the context (album).
*
* @return string|null Album URI if available.
*/
public function get_context(): ?string {
$album_id = get_post_meta( $this->post->ID, '_fedistream_album_id', true );
if ( ! $album_id ) {
return null;
}
return get_permalink( $album_id );
}
/**
* Transform to ActivityPub object array.
*
* @return array
*/
public function to_object(): array {
$object = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => $this->get_type(),
'id' => $this->get_id(),
'name' => $this->get_name(),
'summary' => $this->get_summary(),
'content' => $this->get_content(),
'url' => $this->get_url(),
'attributedTo' => $this->get_attributed_to(),
'published' => $this->get_published(),
'updated' => $this->get_updated(),
);
// Add duration.
$duration = $this->get_duration();
if ( $duration ) {
$object['duration'] = $duration;
}
// Add attachments.
$attachments = array();
$audio = $this->get_audio_attachment();
if ( $audio ) {
$attachments[] = $audio;
}
$image = $this->get_image_attachment();
if ( $image ) {
$attachments[] = $image;
}
if ( ! empty( $attachments ) ) {
$object['attachment'] = $attachments;
}
// Add tags.
$tags = $this->get_tags();
if ( ! empty( $tags ) ) {
$object['tag'] = $tags;
}
// Add context (album).
$context = $this->get_context();
if ( $context ) {
$object['context'] = $context;
}
// Add additional metadata.
$explicit = get_post_meta( $this->post->ID, '_fedistream_explicit', true );
if ( $explicit ) {
$object['sensitive'] = true;
}
// Add ISRC if available.
$isrc = get_post_meta( $this->post->ID, '_fedistream_isrc', true );
if ( $isrc ) {
$object['isrc'] = $isrc;
}
return $object;
}
/**
* Create a Create activity for this track.
*
* @return array
*/
public function to_create_activity(): array {
$attributed_to = $this->get_attributed_to();
$actor = is_array( $attributed_to ) ? $attributed_to[0] : $attributed_to;
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Create',
'id' => $this->get_id() . '#activity-create',
'actor' => $actor,
'published' => $this->get_published(),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor . '/followers' ),
'object' => $this->to_object(),
);
}
/**
* Create an Announce activity for this track.
*
* @param string $actor_uri The actor announcing the track.
* @return array
*/
public function to_announce_activity( string $actor_uri ): array {
return array(
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Announce',
'id' => $this->get_id() . '#activity-announce-' . time(),
'actor' => $actor_uri,
'published' => gmdate( 'c' ),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( $actor_uri . '/followers' ),
'object' => $this->get_id(),
);
}
/**
* Format duration as ISO 8601.
*
* @param int $seconds The duration in seconds.
* @return string ISO 8601 duration.
*/
private function format_duration_iso8601( int $seconds ): string {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$secs = $seconds % 60;
$duration = 'PT';
if ( $hours > 0 ) {
$duration .= $hours . 'H';
}
if ( $minutes > 0 ) {
$duration .= $minutes . 'M';
}
if ( $secs > 0 || ( $hours === 0 && $minutes === 0 ) ) {
$duration .= $secs . 'S';
}
return $duration;
}
}