Files
wp-fedistream/includes/ActivityPub/RestApi.php
magdev 4a5d7b9f4d 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>
2026-01-28 23:23:05 +01:00

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