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\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\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\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\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/(?Ptrack|album|playlist)/(?P\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\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\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; } }