You've already forked wp-fedistream
WP FediStream - Stream music over ActivityPub Features: - Custom post types: Artist, Album, Track, Playlist - Custom taxonomies: Genre, Mood, License - User roles: Artist, Label - Admin dashboard with statistics - Frontend templates and shortcodes - Audio player with queue management - ActivityPub integration with actor support - WooCommerce product types for albums/tracks - User library with favorites and history - Notification system (in-app and email) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
477 lines
12 KiB
PHP
477 lines
12 KiB
PHP
<?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;
|
|
}
|
|
}
|