updated plugin ActivityPub version 5.8.0

This commit is contained in:
2025-04-29 21:19:06 +00:00
committed by Gitium
parent 19dfd317cc
commit fdfbf76539
166 changed files with 14119 additions and 7163 deletions

View File

@ -0,0 +1,359 @@
<?php
/**
* ActivityPub Actors REST-Class
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Collection\Actors as Actor_Collection;
use Activitypub\Webfinger;
use function Activitypub\is_activitypub_request;
/**
* ActivityPub Actors REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#followers
*/
class Actors_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = '(?:users|actors)\/(?P<user_id>[\w\-\.]+)';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
'args' => array(
'user_id' => array(
'description' => 'The ID or username of the actor.',
'type' => 'string',
'required' => true,
'pattern' => '[\w\-\.]+',
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
\register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/remote-follow',
array(
'args' => array(
'user_id' => array(
'description' => 'The ID or username of the actor.',
'type' => 'string',
'required' => true,
'pattern' => '[\w\-\.]+',
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_remote_follow_item' ),
'permission_callback' => '__return_true',
'args' => array(
'resource' => array(
'description' => 'The resource to follow.',
'type' => 'string',
'required' => true,
),
),
),
)
);
}
/**
* Retrieves a single actor.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = Actor_Collection::get_by_various( $user_id );
if ( \is_wp_error( $user ) ) {
return $user;
}
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_users_pre' );
$data = $user->to_array();
$response = \rest_ensure_response( $data );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
$response->header( 'Link', \sprintf( '<%1$s>; rel="alternate"; type="application/activity+json"', $user->get_id() ) );
return $response;
}
/**
* Retrieves the remote follow endpoint.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_remote_follow_item( $request ) {
$resource = $request->get_param( 'resource' );
$user_id = $request->get_param( 'user_id' );
$user = Actor_Collection::get_by_various( $user_id );
if ( \is_wp_error( $user ) ) {
return $user;
}
$template = Webfinger::get_remote_follow_endpoint( $resource );
if ( \is_wp_error( $template ) ) {
return $template;
}
$resource = $user->get_webfinger();
$url = \str_replace( '{uri}', $resource, $template );
return \rest_ensure_response(
array(
'url' => $url,
'template' => $template,
)
);
}
/**
* Retrieves the actor schema, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$this->schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'actor',
'type' => 'object',
'properties' => array(
'@context' => array(
'description' => 'The JSON-LD context for the response.',
'type' => array( 'array', 'object' ),
'readonly' => true,
),
'id' => array(
'description' => 'The unique identifier for the actor.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
),
'type' => array(
'description' => 'The type of the actor.',
'type' => 'string',
'enum' => array( 'Person', 'Service', 'Organization', 'Application', 'Group' ),
'readonly' => true,
),
'attachment' => array(
'description' => 'Additional information attached to the actor.',
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'type' => array(
'type' => 'string',
'enum' => array( 'PropertyValue', 'Link' ),
),
'name' => array(
'type' => 'string',
),
'value' => array(
'type' => 'string',
),
'href' => array(
'type' => 'string',
'format' => 'uri',
),
'rel' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
),
),
'readonly' => true,
),
'name' => array(
'description' => 'The display name of the actor.',
'type' => 'string',
'readonly' => true,
),
'icon' => array(
'description' => 'The icon/avatar of the actor.',
'type' => 'object',
'properties' => array(
'type' => array(
'type' => 'string',
),
'url' => array(
'type' => 'string',
'format' => 'uri',
),
),
'readonly' => true,
),
'published' => array(
'description' => 'The date the actor was published.',
'type' => 'string',
'format' => 'date-time',
'readonly' => true,
),
'summary' => array(
'description' => 'A summary about the actor.',
'type' => 'string',
'readonly' => true,
),
'tag' => array(
'description' => 'Tags associated with the actor.',
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'type' => array(
'type' => 'string',
),
'href' => array(
'type' => 'string',
'format' => 'uri',
),
'name' => array(
'type' => 'string',
),
),
),
'readonly' => true,
),
'url' => array(
'description' => 'The URL to the actor\'s profile page.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
),
'inbox' => array(
'description' => 'The inbox endpoint for the actor.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
),
'outbox' => array(
'description' => 'The outbox endpoint for the actor.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
),
'following' => array(
'description' => 'The following endpoint for the actor.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
),
'followers' => array(
'description' => 'The followers endpoint for the actor.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
),
'streams' => array(
'description' => 'The streams associated with the actor.',
'type' => 'array',
'readonly' => true,
),
'preferredUsername' => array(
'description' => 'The preferred username of the actor.',
'type' => 'string',
'readonly' => true,
),
'publicKey' => array(
'description' => 'The public key information for the actor.',
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'string',
'format' => 'uri',
),
'owner' => array(
'type' => 'string',
'format' => 'uri',
),
'publicKeyPem' => array(
'type' => 'string',
),
),
'readonly' => true,
),
'manuallyApprovesFollowers' => array(
'description' => 'Whether the actor manually approves followers.',
'type' => 'boolean',
'readonly' => true,
),
'attributionDomains' => array(
'description' => 'The attribution domains for the actor.',
'type' => 'array',
'items' => array(
'type' => 'string',
),
'readonly' => true,
),
'featured' => array(
'description' => 'The featured collection endpoint for the actor.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
),
'indexable' => array(
'description' => 'Whether the actor is indexable.',
'type' => 'boolean',
'readonly' => true,
),
'webfinger' => array(
'description' => 'The webfinger identifier for the actor.',
'type' => 'string',
'readonly' => true,
),
'discoverable' => array(
'description' => 'Whether the actor is discoverable.',
'type' => 'boolean',
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -0,0 +1,238 @@
<?php
/**
* Actors_Inbox_Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Debug;
use function Activitypub\get_context;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* Actors_Inbox_Controller class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#inbox
*/
class Actors_Inbox_Controller extends Actors_Controller {
use Collection;
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/inbox',
array(
'args' => array(
'user_id' => array(
'description' => 'The ID or username of the actor.',
'type' => 'string',
'required' => true,
'pattern' => '[\w\-\.]+',
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => '__return_true',
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
'type' => 'integer',
'minimum' => 1,
// No default so we can differentiate between Collection and CollectionPage requests.
),
'per_page' => array(
'description' => 'Maximum number of items to be returned in result set.',
'type' => 'integer',
'default' => 20,
'minimum' => 1,
),
),
'schema' => array( $this, 'get_collection_schema' ),
),
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
'args' => array(
'id' => array(
'description' => 'The unique identifier for the activity.',
'type' => 'string',
'format' => 'uri',
'required' => true,
),
'actor' => array(
'description' => 'The actor performing the activity.',
'type' => 'string',
'required' => true,
'sanitize_callback' => '\Activitypub\object_to_uri',
),
'type' => array(
'description' => 'The type of the activity.',
'type' => 'string',
'required' => true,
),
'object' => array(
'description' => 'The object of the activity.',
'required' => true,
'validate_callback' => function ( $param, $request, $key ) {
/**
* Filter the ActivityPub object validation.
*
* @param bool $validate The validation result.
* @param array $param The object data.
* @param object $request The request object.
* @param string $key The key.
*/
return \apply_filters( 'activitypub_validate_object', true, $param, $request, $key );
},
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Renders the user-inbox.
*
* @param \WP_REST_Request $request The request object.
* @return \WP_REST_Response|\WP_Error Response object or WP_Error.
*/
public function get_items( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = Actors::get_by_various( $user_id );
if ( \is_wp_error( $user ) ) {
return $user;
}
/**
* Fires before the ActivityPub inbox is created and sent to the client.
*/
\do_action( 'activitypub_rest_inbox_pre' );
$response = array(
'@context' => get_context(),
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $user->get__id() ) ),
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
'type' => 'OrderedCollection',
'totalItems' => 0,
'orderedItems' => array(),
);
/**
* Filters the ActivityPub inbox data before it is sent to the client.
*
* @param array $response The ActivityPub inbox array.
*/
$response = \apply_filters( 'activitypub_rest_inbox_array', $response );
$response = $this->prepare_collection_response( $response, $request );
if ( \is_wp_error( $response ) ) {
return $response;
}
/**
* Fires after the ActivityPub inbox has been created and sent to the client.
*/
\do_action( 'activitypub_inbox_post' );
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
/**
* Handles user-inbox requests.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response|\WP_Error Response object or WP_Error.
*/
public function create_item( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = Actors::get_by_various( $user_id );
if ( \is_wp_error( $user ) ) {
return $user;
}
$data = $request->get_json_params();
$activity = Activity::init_from_array( $data );
$type = $request->get_param( 'type' );
$type = \strtolower( $type );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( \wp_check_comment_disallowed_list( $activity->to_json( false ), '', '', '', $_SERVER['REMOTE_ADDR'], $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) {
Debug::write_log( 'Blocked activity from: ' . $activity->get_actor() );
} else {
/**
* ActivityPub inbox action.
*
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param string $type The type of the activity.
* @param Activity|\WP_Error $activity The Activity object.
*/
\do_action( 'activitypub_inbox', $data, $user->get__id(), $type, $activity );
/**
* ActivityPub inbox action for specific activity types.
*
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param Activity|\WP_Error $activity The Activity object.
*/
\do_action( 'activitypub_inbox_' . $type, $data, $user->get__id(), $activity );
}
$response = \rest_ensure_response( array() );
$response->set_status( 202 );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
/**
* Retrieves the schema for the inbox collection, conforming to JSON Schema.
*
* @return array Collection schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$item_schema = array(
'type' => 'object',
);
$schema = $this->get_collection_schema( $item_schema );
// Add inbox-specific properties.
$schema['title'] = 'inbox';
$schema['properties']['generator'] = array(
'description' => 'The software used to generate the collection.',
'type' => 'string',
'format' => 'uri',
);
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -1,161 +0,0 @@
<?php
/**
* ActivityPub Actors REST-Class
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use Activitypub\Webfinger;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\is_activitypub_request;
/**
* ActivityPub Actors REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#followers
*/
class Actors {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes.
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(users|actors)/(?P<user_id>[\w\-\.]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(users|actors)/(?P<user_id>[\w\-\.]+)/remote-follow',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'remote_follow_get' ),
'permission_callback' => '__return_true',
'args' => array(
'resource' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);
}
/**
* Handle GET request
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$link_header = sprintf( '<%1$s>; rel="alternate"; type="application/activity+json"', $user->get_id() );
// Redirect to canonical URL if it is not an ActivityPub request.
if ( ! is_activitypub_request() ) {
header( 'Link: ' . $link_header );
header( 'Location: ' . $user->get_canonical_url(), true, 301 );
exit;
}
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_users_pre' );
$json = $user->to_array();
$rest_response = new WP_REST_Response( $json, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
$rest_response->header( 'Link', $link_header );
return $rest_response;
}
/**
* Endpoint for remote follow UI/Block.
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function remote_follow_get( WP_REST_Request $request ) {
$resource = $request->get_param( 'resource' );
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$template = Webfinger::get_remote_follow_endpoint( $resource );
if ( is_wp_error( $template ) ) {
return $template;
}
$resource = $user->get_webfinger();
$url = str_replace( '{uri}', $resource, $template );
return new WP_REST_Response(
array(
'url' => $url,
'template' => $template,
),
200
);
}
/**
* The supported parameters.
*
* @return array List of parameters,
*/
public static function request_parameters() {
$params = array();
$params['page'] = array(
'type' => 'string',
);
$params['user_id'] = array(
'required' => true,
'type' => 'string',
);
return $params;
}
}

View File

@ -0,0 +1,168 @@
<?php
/**
* Application Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Model\Application;
/**
* ActivityPub Application Controller.
*/
class Application_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = 'application';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => '__return_true',
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Retrieves the application actor profile.
*
* @param \WP_REST_Request $request The request object.
* @return \WP_REST_Response Response object.
*/
public function get_item( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$json = ( new Application() )->to_array();
$rest_response = new \WP_REST_Response( $json, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* Retrieves the schema for the application endpoint.
*
* @return array Schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$this->schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'application',
'type' => 'object',
'properties' => array(
'@context' => array(
'type' => 'array',
'items' => array(
'type' => array( 'string', 'object' ),
),
),
'id' => array(
'type' => 'string',
'format' => 'uri',
),
'type' => array(
'type' => 'string',
'enum' => array( 'Application' ),
),
'name' => array(
'type' => 'string',
),
'icon' => array(
'type' => 'object',
'properties' => array(
'type' => array(
'type' => 'string',
),
'url' => array(
'type' => 'string',
'format' => 'uri',
),
),
),
'published' => array(
'type' => 'string',
'format' => 'date-time',
),
'summary' => array(
'type' => 'string',
),
'url' => array(
'type' => 'string',
'format' => 'uri',
),
'inbox' => array(
'type' => 'string',
'format' => 'uri',
),
'outbox' => array(
'type' => 'string',
'format' => 'uri',
),
'streams' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'preferredUsername' => array(
'type' => 'string',
),
'publicKey' => array(
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'string',
'format' => 'uri',
),
'owner' => array(
'type' => 'string',
'format' => 'uri',
),
'publicKeyPem' => array(
'type' => 'string',
),
),
),
'manuallyApprovesFollowers' => array(
'type' => 'boolean',
),
'discoverable' => array(
'type' => 'boolean',
),
'indexable' => array(
'type' => 'boolean',
),
'webfinger' => array(
'type' => 'string',
),
),
);
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -1,324 +0,0 @@
<?php
/**
* Collections REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Activity\Actor;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Users as User_Collection;
use Activitypub\Collection\Replies;
use Activitypub\Transformer\Factory;
use WP_Error;
use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub Collections REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://docs.joinmastodon.org/spec/activitypub/#featured
* @see https://docs.joinmastodon.org/spec/activitypub/#featuredTags
*/
class Collection {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes.
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(users|actors)/(?P<user_id>[\w\-\.]+)/collections/tags',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'tags_get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(users|actors)/(?P<user_id>[\w\-\.]+)/collections/featured',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'featured_get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/collections/moderators',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'moderators_get' ),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(?P<type>[\w\-\.]+)s/(?P<id>[\w\-\.]+)/replies',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'replies_get' ),
'args' => self::request_parameters_for_replies(),
'permission_callback' => '__return_true',
),
)
);
}
/**
* The endpoint for replies collections.
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function replies_get( $request ) {
$type = $request->get_param( 'type' );
// Get the WordPress object of that "owns" the requested replies.
switch ( $type ) {
case 'comment':
$wp_object = \get_comment( $request->get_param( 'id' ) );
break;
case 'post':
default:
$wp_object = \get_post( $request->get_param( 'id' ) );
break;
}
if ( ! isset( $wp_object ) || is_wp_error( $wp_object ) ) {
return new WP_Error(
'activitypub_replies_collection_does_not_exist',
\sprintf(
// translators: %s: The type (post, comment, etc.) for which no replies collection exists.
\__( 'No reply collection exists for the type %s.', 'activitypub' ),
$type
)
);
}
$page = intval( $request->get_param( 'page' ) );
// If the request parameter page is present get the CollectionPage otherwise the replies collection.
if ( isset( $page ) ) {
$response = Replies::get_collection_page( $wp_object, $page );
} else {
$response = Replies::get_collection( $wp_object );
}
if ( is_wp_error( $response ) ) {
return $response;
}
// Add ActivityPub Context.
$response = array_merge(
array( '@context' => Base_Object::JSON_LD_CONTEXT ),
$response
);
return new WP_REST_Response( $response, 200 );
}
/**
* The Featured Tags endpoint
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function tags_get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$number = 4;
$tags = \get_terms(
array(
'taxonomy' => 'post_tag',
'orderby' => 'count',
'order' => 'DESC',
'number' => $number,
)
);
if ( is_wp_error( $tags ) ) {
$tags = array();
}
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $user->get__id() ) ),
'type' => 'Collection',
'totalItems' => is_countable( $tags ) ? count( $tags ) : 0,
'items' => array(),
);
foreach ( $tags as $tag ) {
$response['items'][] = array(
'type' => 'Hashtag',
'href' => \esc_url( \get_tag_link( $tag ) ),
'name' => esc_hashtag( $tag->name ),
);
}
$rest_response = new WP_REST_Response( $response, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* Featured posts endpoint
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function featured_get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$sticky_posts = \get_option( 'sticky_posts' );
if ( ! is_single_user() && User_Collection::BLOG_USER_ID === $user->get__id() ) {
$posts = array();
} elseif ( $sticky_posts ) {
$args = array(
'post__in' => $sticky_posts,
'ignore_sticky_posts' => 1,
'orderby' => 'date',
'order' => 'DESC',
);
if ( $user->get__id() > 0 ) {
$args['author'] = $user->get__id();
}
$posts = \get_posts( $args );
} else {
$posts = array();
}
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $user_id ) ),
'type' => 'OrderedCollection',
'totalItems' => is_countable( $posts ) ? count( $posts ) : 0,
'orderedItems' => array(),
);
foreach ( $posts as $post ) {
$transformer = Factory::get_transformer( $post );
if ( \is_wp_error( $transformer ) ) {
continue;
}
$response['orderedItems'][] = $transformer->to_object()->to_array( false );
}
$rest_response = new WP_REST_Response( $response, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* Moderators endpoint.
*
* @return WP_REST_Response The response object.
*/
public static function moderators_get() {
$response = array(
'@context' => Actor::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( 'collections/moderators' ),
'type' => 'OrderedCollection',
'orderedItems' => array(),
);
$users = User_Collection::get_collection();
foreach ( $users as $user ) {
$response['orderedItems'][] = $user->get_url();
}
$rest_response = new WP_REST_Response( $response, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* The supported parameters.
*
* @return array List of parameters.
*/
public static function request_parameters() {
$params = array();
$params['user_id'] = array(
'required' => true,
'type' => 'string',
);
return $params;
}
/**
* The supported parameters.
*
* @return array list of parameters.
*/
public static function request_parameters_for_replies() {
$params = array();
$params['type'] = array(
'required' => true,
'type' => 'string',
'enum' => array( 'post', 'comment' ),
);
$params['id'] = array(
'required' => true,
'type' => 'string',
);
return $params;
}
}

View File

@ -0,0 +1,272 @@
<?php
/**
* Collections_Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Actors;
use Activitypub\Model\Application;
use Activitypub\Model\Blog;
use Activitypub\Model\User;
use Activitypub\Transformer\Factory;
use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path;
/**
* Collections_Controller class.
*
* @author Matthias Pfefferle
*
* @see https://docs.joinmastodon.org/spec/activitypub/#featured
* @see https://docs.joinmastodon.org/spec/activitypub/#featuredTags
* @see https://www.w3.org/TR/activitypub/#collections
*/
class Collections_Controller extends Actors_Controller {
use Collection;
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/collections/(?P<type>[\w\-\.]+)',
array(
'args' => array(
'user_id' => array(
'description' => 'The user ID or username.',
'type' => 'string',
'required' => true,
),
'type' => array(
'description' => 'The type of collection to query.',
'type' => 'string',
'enum' => array( 'tags', 'featured' ),
'required' => true,
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => '__return_true',
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
'type' => 'integer',
'minimum' => 1,
// No default so we can differentiate between Collection and CollectionPage requests.
),
'per_page' => array(
'description' => 'Maximum number of items to be returned in result set.',
'type' => 'integer',
'default' => 20,
'minimum' => 1,
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Retrieves a collection of featured tags.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response|\WP_Error Response object or WP_Error object.
*/
public function get_items( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = Actors::get_by_various( $user_id );
if ( \is_wp_error( $user ) ) {
return $user;
}
switch ( $request->get_param( 'type' ) ) {
case 'tags':
$response = $this->get_tags( $request, $user );
break;
case 'featured':
$response = $this->get_featured( $request, $user );
break;
default:
$response = new \WP_Error( 'rest_unknown_collection_type', 'Unknown collection type.', array( 'status' => 404 ) );
}
if ( \is_wp_error( $response ) ) {
return $response;
}
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
/**
* Retrieves a collection of featured tags.
*
* @param \WP_REST_Request $request The request object.
* @param User|Blog|Application $user Actor.
*
* @return array Collection of featured tags.
*/
public function get_tags( $request, $user ) {
$tags = \get_terms(
array(
'taxonomy' => 'post_tag',
'orderby' => 'count',
'order' => 'DESC',
'number' => 4,
)
);
if ( \is_wp_error( $tags ) ) {
$tags = array();
}
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $user->get__id() ) ),
'type' => 'Collection',
'totalItems' => \is_countable( $tags ) ? \count( $tags ) : 0,
'items' => array(),
);
foreach ( $tags as $tag ) {
$response['items'][] = array(
'type' => 'Hashtag',
'href' => \esc_url( \get_tag_link( $tag ) ),
'name' => esc_hashtag( $tag->name ),
);
}
return $this->prepare_collection_response( $response, $request );
}
/**
* Retrieves a collection of featured posts.
*
* @param \WP_REST_Request $request The request object.
* @param User|Blog|Application $user Actor.
*
* @return array Collection of featured posts.
*/
public function get_featured( $request, $user ) {
$posts = array();
if ( is_single_user() || Actors::BLOG_USER_ID !== $user->get__id() ) {
$sticky_posts = \get_option( 'sticky_posts' );
if ( $sticky_posts && is_array( $sticky_posts ) ) {
// Only show public posts.
$args = array(
'post__in' => $sticky_posts,
'ignore_sticky_posts' => 1,
'orderby' => 'date',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_content_visibility',
'compare' => 'NOT EXISTS',
),
),
);
if ( $user->get__id() > 0 ) {
$args['author'] = $user->get__id();
}
$posts = \get_posts( $args );
}
}
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $request->get_param( 'user_id' ) ) ),
'type' => 'OrderedCollection',
'totalItems' => \is_countable( $posts ) ? \count( $posts ) : 0,
'orderedItems' => array(),
);
foreach ( $posts as $post ) {
$transformer = Factory::get_transformer( $post );
if ( \is_wp_error( $transformer ) ) {
continue;
}
$response['orderedItems'][] = $transformer->to_object()->to_array( false );
}
return $this->prepare_collection_response( $response, $request );
}
/**
* Retrieves the schema for the Collections endpoint.
*
* @return array Schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$schema = $this->get_collection_schema();
// Add collections-specific properties.
$schema['title'] = 'featured';
$schema['properties']['generator'] = array(
'description' => 'The software used to generate the collection.',
'type' => 'string',
'format' => 'uri',
);
$schema['properties']['oneOf'] = array(
'orderedItems' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
),
),
'items' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'type' => array(
'type' => 'string',
'enum' => array( 'Hashtag' ),
'required' => true,
),
'href' => array(
'type' => 'string',
'format' => 'uri',
'required' => true,
),
'name' => array(
'type' => 'string',
'required' => true,
),
),
),
),
);
unset( $schema['properties']['orderedItems'] );
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -1,100 +0,0 @@
<?php
/**
* Comment REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_Error;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use Activitypub\Comment as Comment_Utils;
use Activitypub\Webfinger as Webfinger_Utils;
/**
* ActivityPub Followers REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#followers
*/
class Comment {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes.
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/comments/(?P<comment_id>\d+)/remote-reply',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'remote_reply_get' ),
'permission_callback' => '__return_true',
'args' => array(
'resource' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);
}
/**
* Endpoint for remote follow UI/Block.
*
* @param WP_REST_Request $request The request object.
*
* @return array|string|WP_Error|WP_REST_Response The URL to the remote follow page or an error.
*/
public static function remote_reply_get( WP_REST_Request $request ) {
$resource = $request->get_param( 'resource' );
$comment_id = $request->get_param( 'comment_id' );
$comment = get_comment( $comment_id );
if ( ! $comment ) {
return new WP_Error( 'activitypub_comment_not_found', __( 'Comment not found', 'activitypub' ), array( 'status' => 404 ) );
}
$is_local = Comment_Utils::is_local( $comment );
if ( $is_local ) {
return new WP_Error( 'activitypub_local_only_comment', __( 'Comment is local only', 'activitypub' ), array( 'status' => 403 ) );
}
$template = Webfinger_Utils::get_remote_follow_endpoint( $resource );
if ( is_wp_error( $template ) ) {
return $template;
}
$resource = Comment_Utils::get_source_id( $comment_id );
if ( ! $resource ) {
$resource = Comment_Utils::generate_id( $comment );
}
$url = str_replace( '{uri}', $resource, $template );
return new WP_REST_Response(
array(
'url' => $url,
'template' => $template,
),
200
);
}
}

View File

@ -0,0 +1,156 @@
<?php
/**
* Comments_Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Comment;
use Activitypub\Webfinger;
/**
* Comments_Controller class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#followers
*/
class Comments_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = 'comments/(?P<comment_id>\d+)';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/remote-reply',
array(
'args' => array(
'comment_id' => array(
'description' => 'The ID of the comment.',
'type' => 'integer',
'required' => true,
'validate_callback' => array( $this, 'validate_comment' ),
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => '__return_true',
'args' => array(
'resource' => array(
'description' => 'The resource to reply to.',
'type' => 'string',
'required' => true,
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Validates if a comment can be replied to remotely.
*
* @param mixed $param The parameter to validate.
*
* @return true|\WP_Error True if the comment can be replied to, WP_Error otherwise.
*/
public function validate_comment( $param ) {
$comment = \get_comment( $param );
if ( ! $comment ) {
return new \WP_Error( 'activitypub_comment_not_found', \__( 'Comment not found', 'activitypub' ), array( 'status' => 404 ) );
}
$is_local = Comment::is_local( $comment );
if ( $is_local ) {
return new \WP_Error( 'activitypub_local_only_comment', \__( 'Comment is local only', 'activitypub' ), array( 'status' => 403 ) );
}
return true;
}
/**
* Retrieves the remote reply URL for a comment.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response|\WP_Error Response object or WP_Error object.
*/
public function get_item( $request ) {
$resource = $request->get_param( 'resource' );
$comment_id = $request->get_param( 'comment_id' );
$template = Webfinger::get_remote_follow_endpoint( $resource );
if ( \is_wp_error( $template ) ) {
return $template;
}
$resource = Comment::get_source_id( $comment_id );
if ( ! $resource ) {
$resource = Comment::generate_id( \get_comment( $comment_id ) );
}
$url = \str_replace( '{uri}', $resource, $template );
return \rest_ensure_response(
array(
'url' => $url,
'template' => $template,
)
);
}
/**
* Retrieves the schema for the remote reply endpoint.
*
* @return array Schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$this->schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'remote-reply',
'type' => 'object',
'properties' => array(
'url' => array(
'description' => 'The URL to the remote reply page.',
'type' => 'string',
'format' => 'uri',
'required' => true,
),
'template' => array(
'description' => 'The template URL for remote replies.',
'type' => 'string',
'format' => 'uri',
'required' => true,
),
),
);
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -0,0 +1,226 @@
<?php
/**
* Followers_Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use function Activitypub\get_context;
use function Activitypub\get_masked_wp_version;
use function Activitypub\get_rest_url_by_path;
/**
* Followers_Controller class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#followers
*/
class Followers_Controller extends Actors_Controller {
use Collection;
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/followers',
array(
'args' => array(
'user_id' => array(
'description' => 'The ID or username of the actor.',
'type' => 'string',
'required' => true,
'pattern' => '[\w\-\.]+',
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
'type' => 'integer',
'minimum' => 1,
// No default so we can differentiate between Collection and CollectionPage requests.
),
'per_page' => array(
'description' => 'Maximum number of items to be returned in result set.',
'type' => 'integer',
'default' => 20,
'minimum' => 1,
),
'order' => array(
'description' => 'Order sort attribute ascending or descending.',
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
),
'context' => array(
'description' => 'The context in which the request is made.',
'type' => 'string',
'default' => 'simple',
'enum' => array( 'simple', 'full' ),
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Retrieves followers list.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = Actors::get_by_various( $user_id );
if ( \is_wp_error( $user ) ) {
return $user;
}
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_followers_pre' );
$order = $request->get_param( 'order' );
$per_page = $request->get_param( 'per_page' );
$page = $request->get_param( 'page' ) ?? 1;
$context = $request->get_param( 'context' );
$data = Followers::get_followers_with_count( $user_id, $per_page, $page, array( 'order' => \ucwords( $order ) ) );
$response = array(
'@context' => get_context(),
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/followers', $user->get__id() ) ),
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
'actor' => $user->get_id(),
'type' => 'OrderedCollection',
'totalItems' => $data['total'],
'orderedItems' => array_map(
function ( $item ) use ( $context ) {
if ( 'full' === $context ) {
return $item->to_array( false );
}
return $item->get_id();
},
$data['followers']
),
);
$response = $this->prepare_collection_response( $response, $request );
if ( is_wp_error( $response ) ) {
return $response;
}
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
/**
* Retrieves the followers schema, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
// Define the schema for items in the followers collection.
$item_schema = array(
'oneOf' => array(
array(
'type' => 'string',
'format' => 'uri',
),
array(
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'string',
'format' => 'uri',
),
'type' => array(
'type' => 'string',
),
'name' => array(
'type' => 'string',
),
'icon' => array(
'type' => 'object',
'properties' => array(
'type' => array(
'type' => 'string',
),
'mediaType' => array(
'type' => 'string',
),
'url' => array(
'type' => 'string',
'format' => 'uri',
),
),
),
'published' => array(
'type' => 'string',
'format' => 'date-time',
),
'summary' => array(
'type' => 'string',
),
'updated' => array(
'type' => 'string',
'format' => 'date-time',
),
'url' => array(
'type' => 'string',
'format' => 'uri',
),
'streams' => array(
'type' => 'array',
),
'preferredUsername' => array(
'type' => 'string',
),
),
),
),
);
$schema = $this->get_collection_schema( $item_schema );
// Add followers-specific properties.
$schema['title'] = 'followers';
$schema['properties']['actor'] = array(
'description' => 'The actor who owns the followers collection.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
);
$schema['properties']['generator'] = array(
'description' => 'The generator of the followers collection.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
);
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -1,154 +0,0 @@
<?php
/**
* Followers REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use stdClass;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Collection\Users as User_Collection;
use Activitypub\Collection\Followers as Follower_Collection;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* ActivityPub Followers REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#followers
*/
class Followers {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes.
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(users|actors)/(?P<user_id>[\w\-\.]+)/followers',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Handle GET request
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$order = $request->get_param( 'order' );
$per_page = (int) $request->get_param( 'per_page' );
$page = (int) $request->get_param( 'page' );
$context = $request->get_param( 'context' );
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_rest_followers_pre' );
$data = Follower_Collection::get_followers_with_count( $user_id, $per_page, $page, array( 'order' => ucwords( $order ) ) );
$json = new stdClass();
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$json->{'@context'} = \Activitypub\get_context();
$json->id = get_rest_url_by_path( sprintf( 'actors/%d/followers', $user->get__id() ) );
$json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version();
$json->actor = $user->get_id();
$json->type = 'OrderedCollectionPage';
$json->totalItems = $data['total'];
$json->partOf = get_rest_url_by_path( sprintf( 'actors/%d/followers', $user->get__id() ) );
$json->first = \add_query_arg( 'page', 1, $json->partOf );
$json->last = \add_query_arg( 'page', \ceil( $json->totalItems / $per_page ), $json->partOf );
if ( $page && ( ( \ceil( $json->totalItems / $per_page ) ) > $page ) ) {
$json->next = \add_query_arg( 'page', $page + 1, $json->partOf );
}
if ( $page && ( $page > 1 ) ) {
$json->prev = \add_query_arg( 'page', $page - 1, $json->partOf );
}
$json->orderedItems = array_map(
function ( $item ) use ( $context ) {
if ( 'full' === $context ) {
return $item->to_array( false );
}
return $item->get_url();
},
$data['followers']
);
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$rest_response = new WP_REST_Response( $json, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* The supported parameters.
*
* @return array List of parameters.
*/
public static function request_parameters() {
$params = array();
$params['page'] = array(
'type' => 'integer',
'default' => 1,
);
$params['per_page'] = array(
'type' => 'integer',
'default' => 20,
);
$params['order'] = array(
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
);
$params['user_id'] = array(
'required' => true,
'type' => 'string',
);
$params['context'] = array(
'type' => 'string',
'default' => 'simple',
'enum' => array( 'simple', 'full' ),
);
return $params;
}
}

View File

@ -0,0 +1,183 @@
<?php
/**
* Following_Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Collection\Actors;
use function Activitypub\get_context;
use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* Following_Controller class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#following
*/
class Following_Controller extends Actors_Controller {
use Collection;
/**
* Initialize the class, registering WordPress hooks.
*/
public function __construct() {
\add_filter( 'activitypub_rest_following', array( self::class, 'default_following' ), 10, 2 );
}
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/following',
array(
'args' => array(
'user_id' => array(
'description' => 'The ID or username of the actor.',
'type' => 'string',
'required' => true,
'pattern' => '[\w\-\.]+',
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
'type' => 'integer',
'minimum' => 1,
// No default so we can differentiate between Collection and CollectionPage requests.
),
'per_page' => array(
'description' => 'Maximum number of items to be returned in result set.',
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
),
),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Retrieves following list.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = Actors::get_by_various( $user_id );
if ( \is_wp_error( $user ) ) {
return $user;
}
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_following_pre' );
$response = array(
'@context' => get_context(),
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/following', $user->get__id() ) ),
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
'actor' => $user->get_id(),
'type' => 'OrderedCollection',
);
/**
* Filter the list of following urls.
*
* @param array $items The array of following urls.
* @param \Activitypub\Model\User $user The user object.
*/
$items = \apply_filters( 'activitypub_rest_following', array(), $user );
$response['totalItems'] = \is_countable( $items ) ? \count( $items ) : 0;
$response['orderedItems'] = $items;
$response = $this->prepare_collection_response( $response, $request );
if ( is_wp_error( $response ) ) {
return $response;
}
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
/**
* Add the Blog Authors to the following list of the Blog Actor
* if Blog not in single mode.
*
* @param array $follow_list The array of following urls.
* @param \Activitypub\Model\User $user The user object.
*
* @return array The array of following urls.
*/
public static function default_following( $follow_list, $user ) {
if ( 0 !== $user->get__id() || is_single_user() ) {
return $follow_list;
}
$users = Actors::get_collection();
foreach ( $users as $user ) {
$follow_list[] = $user->get_id();
}
return $follow_list;
}
/**
* Retrieves the following schema, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$item_schema = array(
'type' => 'string',
'format' => 'uri',
);
$schema = $this->get_collection_schema( $item_schema );
// Add following-specific properties.
$schema['title'] = 'following';
$schema['properties']['actor'] = array(
'description' => 'The actor who owns the following collection.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
);
$schema['properties']['generator'] = array(
'description' => 'The generator of the following collection.',
'type' => 'string',
'format' => 'uri',
'readonly' => true,
);
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -1,144 +0,0 @@
<?php
/**
* ActivityPub Following REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_REST_Response;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* ActivityPub Following REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#following
*/
class Following {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
\add_filter( 'activitypub_rest_following', array( self::class, 'default_following' ), 10, 2 );
}
/**
* Register routes
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(users|actors)/(?P<user_id>[\w\-\.]+)/following',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( self::class, 'get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Handle GET request
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_following_pre' );
$json = new \stdClass();
$json->{'@context'} = \Activitypub\get_context();
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$json->id = get_rest_url_by_path( sprintf( 'actors/%d/following', $user->get__id() ) );
$json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version();
$json->actor = $user->get_id();
$json->type = 'OrderedCollectionPage';
$json->partOf = get_rest_url_by_path( sprintf( 'actors/%d/following', $user->get__id() ) );
/**
* Filter the list of following urls.
*
* @param array $items The array of following urls.
* @param \Activitypub\Model\User $user The user object.
*/
$items = apply_filters( 'activitypub_rest_following', array(), $user );
$json->totalItems = is_countable( $items ) ? count( $items ) : 0;
$json->orderedItems = $items;
$json->first = $json->partOf;
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$rest_response = new WP_REST_Response( $json, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* The supported parameters.
*
* @return array List of parameters.
*/
public static function request_parameters() {
$params = array();
$params['page'] = array(
'type' => 'integer',
);
$params['user_id'] = array(
'required' => true,
'type' => 'string',
);
return $params;
}
/**
* Add the Blog Authors to the following list of the Blog Actor
* if Blog not in single mode.
*
* @param array $follow_list The array of following urls.
* @param \Activitypub\Model\User $user The user object.
*
* @return array The array of following urls.
*/
public static function default_following( $follow_list, $user ) {
if ( 0 !== $user->get__id() || is_single_user() ) {
return $follow_list;
}
$users = User_Collection::get_collection();
foreach ( $users as $user ) {
$follow_list[] = $user->get_url();
}
return $follow_list;
}
}

View File

@ -0,0 +1,255 @@
<?php
/**
* Inbox_Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Debug;
use function Activitypub\is_same_domain;
use function Activitypub\extract_recipients_from_activity;
/**
* Inbox_Controller class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#inbox
*/
class Inbox_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = 'inbox';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
'args' => array(
'id' => array(
'description' => 'The unique identifier for the activity.',
'type' => 'string',
'format' => 'uri',
'required' => true,
),
'actor' => array(
'description' => 'The actor performing the activity.',
'type' => 'string',
'required' => true,
'sanitize_callback' => '\Activitypub\object_to_uri',
),
'type' => array(
'description' => 'The type of the activity.',
'type' => 'string',
'required' => true,
),
'object' => array(
'description' => 'The object of the activity.',
'required' => true,
'validate_callback' => function ( $param, $request, $key ) {
/**
* Filter the ActivityPub object validation.
*
* @param bool $validate The validation result.
* @param array $param The object data.
* @param object $request The request object.
* @param string $key The key.
*/
return \apply_filters( 'activitypub_validate_object', true, $param, $request, $key );
},
),
'to' => array(
'description' => 'The primary recipients of the activity.',
'type' => array( 'string', 'array' ),
'required' => false,
'sanitize_callback' => function ( $param ) {
if ( \is_string( $param ) ) {
$param = array( $param );
}
return $param;
},
),
'cc' => array(
'description' => 'The secondary recipients of the activity.',
'type' => array( 'string', 'array' ),
'sanitize_callback' => function ( $param ) {
if ( \is_string( $param ) ) {
$param = array( $param );
}
return $param;
},
),
'bcc' => array(
'description' => 'The private recipients of the activity.',
'type' => array( 'string', 'array' ),
'sanitize_callback' => function ( $param ) {
if ( \is_string( $param ) ) {
$param = array( $param );
}
return $param;
},
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* The shared inbox.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response|\WP_Error Response object or WP_Error.
*/
public function create_item( $request ) {
$data = $request->get_json_params();
$activity = Activity::init_from_array( $data );
$type = \strtolower( $request->get_param( 'type' ) );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( \wp_check_comment_disallowed_list( $activity->to_json( false ), '', '', '', $_SERVER['REMOTE_ADDR'], $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) {
Debug::write_log( 'Blocked activity from: ' . $activity->get_actor() );
} else {
$recipients = extract_recipients_from_activity( $data );
foreach ( $recipients as $recipient ) {
if ( ! is_same_domain( $recipient ) ) {
continue;
}
$actor = Actors::get_by_various( $recipient );
if ( ! $actor || \is_wp_error( $actor ) ) {
continue;
}
/**
* ActivityPub inbox action.
*
* @param array $data The data array.
* @param int $user_id The user ID.
* @param string $type The type of the activity.
* @param Activity|\WP_Error $activity The Activity object.
*/
\do_action( 'activitypub_inbox', $data, $actor->get__id(), $type, $activity );
/**
* ActivityPub inbox action for specific activity types.
*
* @param array $data The data array.
* @param int $user_id The user ID.
* @param Activity|\WP_Error $activity The Activity object.
*/
\do_action( 'activitypub_inbox_' . $type, $data, $actor->get__id(), $activity );
}
}
$response = \rest_ensure_response( array() );
$response->set_status( 202 );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
/**
* Retrieves the schema for a single inbox item, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$schema = array(
'$schema' => 'https://json-schema.org/draft-04/schema#',
'title' => 'activity',
'type' => 'object',
'properties' => array(
'@context' => array(
'description' => 'The JSON-LD context for the activity.',
'type' => array( 'string', 'array', 'object' ),
'required' => true,
),
'id' => array(
'description' => 'The unique identifier for the activity.',
'type' => 'string',
'format' => 'uri',
'required' => true,
),
'type' => array(
'description' => 'The type of the activity.',
'type' => 'string',
'required' => true,
),
'actor' => array(
'description' => 'The actor performing the activity.',
'type' => array( 'string', 'object' ),
'format' => 'uri',
'required' => true,
),
'object' => array(
'description' => 'The object of the activity.',
'type' => array( 'string', 'object' ),
'required' => true,
),
'to' => array(
'description' => 'The primary recipients of the activity.',
'type' => 'array',
'items' => array(
'type' => 'string',
'format' => 'uri',
),
),
'cc' => array(
'description' => 'The secondary recipients of the activity.',
'type' => 'array',
'items' => array(
'type' => 'string',
'format' => 'uri',
),
),
'bcc' => array(
'description' => 'The private recipients of the activity.',
'type' => 'array',
'items' => array(
'type' => 'string',
'format' => 'uri',
),
),
),
);
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -1,336 +0,0 @@
<?php
/**
* Inbox REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\get_context;
use function Activitypub\url_to_authorid;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
use function Activitypub\extract_recipients_from_activity;
/**
* ActivityPub Inbox REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#inbox
*/
class Inbox {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes.
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/inbox',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( self::class, 'shared_inbox_post' ),
'args' => self::shared_inbox_post_parameters(),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(users|actors)/(?P<user_id>[\w\-\.]+)/inbox',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( self::class, 'user_inbox_post' ),
'args' => self::user_inbox_post_parameters(),
'permission_callback' => '__return_true',
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'user_inbox_get' ),
'args' => self::user_inbox_get_parameters(),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Renders the user-inbox.
*
* @param \WP_REST_Request $request The request object.
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function user_inbox_get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_inbox_pre' );
$json = new \stdClass();
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$json->{'@context'} = get_context();
$json->id = get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user->get__id() ) );
$json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version();
$json->type = 'OrderedCollectionPage';
$json->partOf = get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user->get__id() ) );
$json->totalItems = 0;
$json->orderedItems = array();
$json->first = $json->partOf;
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
/**
* Filter the ActivityPub inbox array.
*
* @param array $json The ActivityPub inbox array.
*/
$json = \apply_filters( 'activitypub_rest_inbox_array', $json );
/**
* Action triggered after the ActivityPub profile has been created and sent to the client.
*/
\do_action( 'activitypub_inbox_post' );
$rest_response = new WP_REST_Response( $json, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* Handles user-inbox requests.
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function user_inbox_post( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$data = $request->get_json_params();
$activity = Activity::init_from_array( $data );
$type = $request->get_param( 'type' );
$type = \strtolower( $type );
/**
* ActivityPub inbox action.
*
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param string $type The type of the activity.
* @param Activity $activity The Activity object.
*/
\do_action( 'activitypub_inbox', $data, $user->get__id(), $type, $activity );
/**
* ActivityPub inbox action for specific activity types.
*
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param Activity $activity The Activity object.
*/
\do_action( "activitypub_inbox_{$type}", $data, $user->get__id(), $activity );
$rest_response = new WP_REST_Response( array(), 202 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* The shared inbox.
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response
*/
public static function shared_inbox_post( $request ) {
$data = $request->get_json_params();
$activity = Activity::init_from_array( $data );
$type = $request->get_param( 'type' );
$type = \strtolower( $type );
/**
* ActivityPub inbox action.
*
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param string $type The type of the activity.
* @param Activity $activity The Activity object.
*/
\do_action( 'activitypub_inbox', $data, null, $type, $activity );
/**
* ActivityPub inbox action for specific activity types.
*
* @param array $data The data array.
* @param int|null $user_id The user ID.
* @param Activity $activity The Activity object.
*/
\do_action( "activitypub_inbox_{$type}", $data, null, $activity );
$rest_response = new WP_REST_Response( array(), 202 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* The supported parameters.
*
* @return array List of parameters.
*/
public static function user_inbox_get_parameters() {
$params = array();
$params['page'] = array(
'type' => 'integer',
);
$params['user_id'] = array(
'required' => true,
'type' => 'string',
);
return $params;
}
/**
* The supported parameters.
*
* @return array List of parameters.
*/
public static function user_inbox_post_parameters() {
$params = array();
$params['user_id'] = array(
'required' => true,
'type' => 'string',
);
$params['id'] = array(
'required' => true,
'sanitize_callback' => 'esc_url_raw',
);
$params['actor'] = array(
'required' => true,
'sanitize_callback' => '\Activitypub\object_to_uri',
);
$params['type'] = array(
'required' => true,
);
$params['object'] = array(
'required' => true,
'validate_callback' => function ( $param, $request, $key ) {
/**
* Filter the ActivityPub object validation.
*
* @param bool $validate The validation result.
* @param array $param The object data.
* @param object $request The request object.
* @param string $key The key.
*/
return apply_filters( 'activitypub_validate_object', true, $param, $request, $key );
},
);
return $params;
}
/**
* The supported parameters.
*
* @return array List of parameters.
*/
public static function shared_inbox_post_parameters() {
$params = self::user_inbox_post_parameters();
$params['to'] = array(
'required' => false,
'sanitize_callback' => function ( $param ) {
if ( \is_string( $param ) ) {
$param = array( $param );
}
return $param;
},
);
$params['cc'] = array(
'sanitize_callback' => function ( $param ) {
if ( \is_string( $param ) ) {
$param = array( $param );
}
return $param;
},
);
$params['bcc'] = array(
'sanitize_callback' => function ( $param ) {
if ( \is_string( $param ) ) {
$param = array( $param );
}
return $param;
},
);
return $params;
}
/**
* Get local user recipients.
*
* @param array $data The data array.
*
* @return array The list of local users.
*/
public static function get_recipients( $data ) {
$recipients = extract_recipients_from_activity( $data );
$users = array();
foreach ( $recipients as $recipient ) {
$user_id = url_to_authorid( $recipient );
$user = get_user_by( 'id', $user_id );
if ( $user ) {
$users[] = $user;
}
}
return $users;
}
}

View File

@ -0,0 +1,139 @@
<?php
/**
* ActivityPub Interaction Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Http;
/**
* Interaction Controller.
*/
class Interaction_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = 'interactions';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => '__return_true',
'args' => array(
'uri' => array(
'description' => 'The URI of the object to interact with.',
'type' => 'string',
'format' => 'uri',
'required' => true,
),
),
),
)
);
}
/**
* Retrieves the interaction URL for a given URI.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response Response object on success, dies on failure.
*/
public function get_item( $request ) {
$uri = $request->get_param( 'uri' );
$redirect_url = '';
$object = Http::get_remote_object( $uri );
if ( \is_wp_error( $object ) || ! isset( $object['type'] ) ) {
// Use wp_die as this can be called from the front-end. See https://github.com/Automattic/wordpress-activitypub/pull/1149/files#r1915297109.
\wp_die(
esc_html__( 'The URL is not supported!', 'activitypub' ),
'',
array(
'response' => 400,
'back_link' => true,
)
);
}
if ( ! empty( $object['url'] ) ) {
$uri = \esc_url( $object['url'] );
}
switch ( $object['type'] ) {
case 'Group':
case 'Person':
case 'Service':
case 'Application':
case 'Organization':
/**
* Filters the URL used for following an ActivityPub actor.
*
* @param string $redirect_url The URL to redirect to.
* @param string $uri The URI of the actor to follow.
* @param array $object The full actor object data.
*/
$redirect_url = \apply_filters( 'activitypub_interactions_follow_url', $redirect_url, $uri, $object );
break;
default:
$redirect_url = \admin_url( 'post-new.php?in_reply_to=' . $uri );
/**
* Filters the URL used for replying to an ActivityPub object.
*
* By default, this redirects to the WordPress post editor with the in_reply_to parameter set.
*
* @param string $redirect_url The URL to redirect to.
* @param string $uri The URI of the object to reply to.
* @param array $object The full object data being replied to.
*/
$redirect_url = \apply_filters( 'activitypub_interactions_reply_url', $redirect_url, $uri, $object );
}
/**
* Filters the redirect URL.
*
* This filter runs after the type-specific filters and allows for final modifications
* to the interaction URL regardless of the object type.
*
* @param string $redirect_url The URL to redirect to.
* @param string $uri The URI of the object.
* @param array $object The object being interacted with.
*/
$redirect_url = \apply_filters( 'activitypub_interactions_url', $redirect_url, $uri, $object );
// Check if hook is implemented.
if ( ! $redirect_url ) {
// Use wp_die as this can be called from the front-end. See https://github.com/Automattic/wordpress-activitypub/pull/1149/files#r1915297109.
\wp_die(
esc_html__( 'This Interaction type is not supported yet!', 'activitypub' ),
'',
array(
'response' => 400,
'back_link' => true,
)
);
}
return new \WP_REST_Response( null, 302, array( 'Location' => \esc_url( $redirect_url ) ) );
}
}

View File

@ -1,118 +0,0 @@
<?php
/**
* ActivityPub Interaction REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_REST_Response;
use Activitypub\Http;
/**
* Interaction class.
*/
class Interaction {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/interactions',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( self::class, 'get' ),
'permission_callback' => '__return_true',
'args' => array(
'uri' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'esc_url',
),
),
),
)
);
}
/**
* Handle GET request.
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response Redirect to the editor or die.
*/
public static function get( $request ) {
$uri = $request->get_param( 'uri' );
$redirect_url = null;
$object = Http::get_remote_object( $uri );
if (
\is_wp_error( $object ) ||
! isset( $object['type'] )
) {
\wp_die(
\esc_html__(
'The URL is not supported!',
'activitypub'
),
400
);
}
if ( ! empty( $object['url'] ) ) {
$uri = \esc_url( $object['url'] );
}
switch ( $object['type'] ) {
case 'Group':
case 'Person':
case 'Service':
case 'Application':
case 'Organization':
$redirect_url = \apply_filters( 'activitypub_interactions_follow_url', $redirect_url, $uri, $object );
break;
default:
$redirect_url = \admin_url( 'post-new.php?in_reply_to=' . $uri );
$redirect_url = \apply_filters( 'activitypub_interactions_reply_url', $redirect_url, $uri, $object );
}
/**
* Filter the redirect URL.
*
* @param string $redirect_url The URL to redirect to.
* @param string $uri The URI of the object.
* @param array $object The object.
*/
$redirect_url = \apply_filters( 'activitypub_interactions_url', $redirect_url, $uri, $object );
// Check if hook is implemented.
if ( ! $redirect_url ) {
\wp_die(
esc_html__(
'This Interaction type is not supported yet!',
'activitypub'
),
400
);
}
return new WP_REST_Response(
null,
302,
array(
'Location' => \esc_url( $redirect_url ),
)
);
}
}

View File

@ -0,0 +1,132 @@
<?php
/**
* Moderators_Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Collection\Actors;
use function Activitypub\get_context;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub Moderators_Controller class.
*/
class Moderators_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = 'collections/moderators';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => '__return_true',
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Retrieves a collection of moderators.
*
* @param \WP_REST_Request $request The request object.
* @return \WP_REST_Response|\WP_Error Response object or WP_Error object.
*/
public function get_items( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$actors = array();
foreach ( Actors::get_collection() as $user ) {
$actors[] = $user->get_id();
}
/**
* Filter the list of moderators.
*
* @param array $actors The list of moderators.
*/
$actors = apply_filters( 'activitypub_rest_moderators', $actors );
$response = array(
'@context' => get_context(),
'id' => get_rest_url_by_path( 'collections/moderators' ),
'type' => 'OrderedCollection',
'orderedItems' => $actors,
);
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
/**
* Retrieves the schema for the Moderators endpoint.
*
* @return array Schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'moderators',
'type' => 'object',
'properties' => array(
'@context' => array(
'type' => 'array',
'items' => array(
'type' => array( 'string', 'object' ),
),
'required' => true,
),
'id' => array(
'type' => 'string',
'format' => 'uri',
'required' => true,
),
'type' => array(
'type' => 'string',
'enum' => array( 'OrderedCollection' ),
'required' => true,
),
'orderedItems' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
'format' => 'uri',
),
'required' => true,
),
),
);
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -0,0 +1,172 @@
<?php
/**
* NodeInfo controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use function Activitypub\get_masked_wp_version;
use function Activitypub\get_total_users;
use function Activitypub\get_active_users;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub NodeInfo Controller.
*
* @author Matthias Pfefferle
*
* @see https://nodeinfo.diaspora.software/
*/
class Nodeinfo_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The REST base for this controller's route.
*
* @var string
*/
protected $rest_base = 'nodeinfo';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<version>\d\.\d)',
array(
'args' => array(
'version' => array(
'description' => 'The version of the NodeInfo schema.',
'type' => 'string',
'required' => true,
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Retrieves the NodeInfo discovery profile.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response Response object.
*/
public function get_items( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$response = array(
'links' => array(
/*
* Needs http protocol for spec compliance.
* @ticket https://github.com/Automattic/wordpress-activitypub/pull/1275
*/
array(
'rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0',
'href' => get_rest_url_by_path( '/nodeinfo/2.0' ),
),
array(
'rel' => 'https://nodeinfo.diaspora.software/ns/schema/2.0',
'href' => get_rest_url_by_path( '/nodeinfo/2.0' ),
),
array(
'rel' => 'https://www.w3.org/ns/activitystreams#Application',
'href' => get_rest_url_by_path( 'application' ),
),
),
);
return \rest_ensure_response( $response );
}
/**
* Retrieves the NodeInfo profile.
*
* @param \WP_REST_Request $request The request object.
* @return \WP_REST_Response Response object.
*/
public function get_item( $request ) {
$version = $request->get_param( 'version' );
/**
* Fires before the NodeInfo data is created and sent to the client.
*
* @param string $version The NodeInfo version.
*/
\do_action( 'activitypub_rest_nodeinfo_pre', $version );
switch ( $version ) {
case '2.0':
$response = $this->get_version_2_0();
break;
default:
$response = new \WP_Error( 'activitypub_rest_nodeinfo_invalid_version', 'Unsupported NodeInfo version.', array( 'status' => 405 ) );
break;
}
return \rest_ensure_response( $response );
}
/**
* Get the NodeInfo 2.0 data.
*
* @return array
*/
public function get_version_2_0() {
$posts = \wp_count_posts();
$comments = \wp_count_comments();
return array(
'version' => '2.0',
'software' => array(
'name' => 'wordpress',
'version' => get_masked_wp_version(),
),
'protocols' => array( 'activitypub' ),
'services' => array(
'inbound' => array(),
'outbound' => array(),
),
'openRegistrations' => (bool) get_option( 'users_can_register' ),
'usage' => array(
'users' => array(
'total' => get_total_users(),
'activeHalfyear' => get_active_users( 6 ),
'activeMonth' => get_active_users(),
),
'localPosts' => $posts->publish,
'localComments' => $comments->approved,
),
'metadata' => array(
'nodeName' => \get_bloginfo( 'name' ),
'nodeDescription' => \get_bloginfo( 'description' ),
'nodeIcon' => \get_site_icon_url(),
),
);
}
}

View File

@ -1,187 +0,0 @@
<?php
/**
* NodeInfo REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_REST_Response;
use function Activitypub\get_total_users;
use function Activitypub\get_active_users;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* ActivityPub NodeInfo REST-Class.
*
* @author Matthias Pfefferle
*
* @see http://nodeinfo.diaspora.software/
*/
class Nodeinfo {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/nodeinfo/discovery',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( self::class, 'discovery' ),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/nodeinfo',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( self::class, 'nodeinfo' ),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/nodeinfo2',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( self::class, 'nodeinfo2' ),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Render NodeInfo file.
*
* @return WP_REST_Response The JSON profile of the NodeInfo.
*/
public static function nodeinfo() {
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_nodeinfo_pre' );
$nodeinfo = array();
$nodeinfo['version'] = '2.0';
$nodeinfo['software'] = array(
'name' => 'wordpress',
'version' => get_masked_wp_version(),
);
$posts = \wp_count_posts();
$comments = \wp_count_comments();
$nodeinfo['usage'] = array(
'users' => array(
'total' => get_total_users(),
'activeMonth' => get_active_users( '1 month ago' ),
'activeHalfyear' => get_active_users( '6 month ago' ),
),
'localPosts' => (int) $posts->publish,
'localComments' => (int) $comments->approved,
);
$nodeinfo['openRegistrations'] = false;
$nodeinfo['protocols'] = array( 'activitypub' );
$nodeinfo['services'] = array(
'inbound' => array(),
'outbound' => array(),
);
$nodeinfo['metadata'] = array(
'nodeName' => \get_bloginfo( 'name' ),
'nodeDescription' => \get_bloginfo( 'description' ),
'nodeIcon' => \get_site_icon_url(),
);
return new WP_REST_Response( $nodeinfo, 200 );
}
/**
* Render NodeInfo file.
*
* @return WP_REST_Response The JSON profile of the NodeInfo.
*/
public static function nodeinfo2() {
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_nodeinfo2_pre' );
$nodeinfo = array();
$nodeinfo['version'] = '2.0';
$nodeinfo['server'] = array(
'baseUrl' => \home_url( '/' ),
'name' => \get_bloginfo( 'name' ),
'software' => 'wordpress',
'version' => get_masked_wp_version(),
);
$posts = \wp_count_posts();
$comments = \wp_count_comments();
$nodeinfo['usage'] = array(
'users' => array(
'total' => get_total_users(),
'activeMonth' => get_active_users( 1 ),
'activeHalfyear' => get_active_users( 6 ),
),
'localPosts' => (int) $posts->publish,
'localComments' => (int) $comments->approved,
);
$nodeinfo['openRegistrations'] = false;
$nodeinfo['protocols'] = array( 'activitypub' );
$nodeinfo['services'] = array(
'inbound' => array(),
'outbound' => array(),
);
return new WP_REST_Response( $nodeinfo, 200 );
}
/**
* Render NodeInfo discovery file.
*
* @return WP_REST_Response
*/
public static function discovery() {
$discovery = array();
$discovery['links'] = array(
array(
'rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0',
'href' => get_rest_url_by_path( 'nodeinfo' ),
),
array(
'rel' => 'https://www.w3.org/ns/activitystreams#Application',
'href' => get_rest_url_by_path( 'application' ),
),
);
return new \WP_REST_Response( $discovery, 200 );
}
}

View File

@ -0,0 +1,257 @@
<?php
/**
* Outbox Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use function Activitypub\get_masked_wp_version;
use function ActivityPub\get_rest_url_by_path;
/**
* ActivityPub Outbox Controller.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#outbox
*/
class Outbox_Controller extends \WP_REST_Controller {
use Collection;
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = '(?:users|actors)/(?P<user_id>[\w\-\.]+)/outbox';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
'args' => array(
'user_id' => array(
'description' => 'The ID of the user or actor.',
'type' => 'string',
'validate_callback' => array( $this, 'validate_user_id' ),
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
'type' => 'integer',
'minimum' => 1,
// No default so we can differentiate between Collection and CollectionPage requests.
),
'per_page' => array(
'description' => 'Maximum number of items to be returned in result set.',
'type' => 'integer',
'default' => 20,
'minimum' => 1,
'maximum' => 100,
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Validates the user_id parameter.
*
* @param mixed $user_id The user_id parameter.
* @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise.
*/
public function validate_user_id( $user_id ) {
$user = Actors::get_by_various( $user_id );
if ( \is_wp_error( $user ) ) {
return $user;
}
return true;
}
/**
* Retrieves a collection of outbox items.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
$page = $request->get_param( 'page' ) ?? 1;
$user = Actors::get_by_various( $request->get_param( 'user_id' ) );
$user_id = $user->get__id();
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*
* @param \WP_REST_Request $request The request object.
*/
\do_action( 'activitypub_rest_outbox_pre', $request );
/**
* Filters the list of activity types to include in the outbox.
*
* @param string[] $activity_types The list of activity types.
*/
$activity_types = apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) );
$args = array(
'posts_per_page' => $request->get_param( 'per_page' ),
'author' => $user_id > 0 ? $user_id : null,
'paged' => $page,
'post_type' => Outbox::POST_TYPE,
'post_status' => 'any',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_activitypub_activity_actor',
'value' => Actors::get_type_by_id( $user_id ),
),
),
);
if ( get_current_user_id() !== $user_id && ! current_user_can( 'activitypub' ) ) {
$args['meta_query'][] = array(
'key' => '_activitypub_activity_type',
'value' => $activity_types,
'compare' => 'IN',
);
$args['meta_query'][] = array(
'relation' => 'OR',
array(
'key' => 'activitypub_content_visibility',
'compare' => 'NOT EXISTS',
),
array(
'key' => 'activitypub_content_visibility',
'value' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
),
);
}
/**
* Filters WP_Query arguments when querying Outbox items via the REST API.
*
* Enables adding extra arguments or setting defaults for an outbox collection request.
*
* @param array $args Array of arguments for WP_Query.
* @param \WP_REST_Request $request The REST API request.
*/
$args = apply_filters( 'rest_activitypub_outbox_query', $args, $request );
$outbox_query = new \WP_Query();
$query_result = $outbox_query->query( $args );
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ),
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
'actor' => $user->get_id(),
'type' => 'OrderedCollection',
'totalItems' => $outbox_query->found_posts,
'orderedItems' => array(),
);
\update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) );
foreach ( $query_result as $outbox_item ) {
$response['orderedItems'][] = $this->prepare_item_for_response( $outbox_item, $request );
}
$response = $this->prepare_collection_response( $response, $request );
if ( is_wp_error( $response ) ) {
return $response;
}
/**
* Filter the ActivityPub outbox array.
*
* @param array $response The ActivityPub outbox array.
* @param \WP_REST_Request $request The request object.
*/
$response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request );
/**
* Action triggered after the ActivityPub profile has been created and sent to the client.
*
* @param \WP_REST_Request $request The request object.
*/
\do_action( 'activitypub_outbox_post', $request );
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
/**
* Prepares the item for the REST response.
*
* @param mixed $item WordPress representation of the item.
* @param \WP_REST_Request $request Request object.
* @return array Response object on success, or WP_Error object on failure.
*/
public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$activity = Outbox::get_activity( $item->ID );
return $activity->to_array( false );
}
/**
* Retrieves the outbox schema, conforming to JSON Schema.
*
* @return array Collection schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$item_schema = array(
'type' => 'object',
);
$schema = $this->get_collection_schema( $item_schema );
// Add outbox-specific properties.
$schema['title'] = 'outbox';
$schema['properties']['actor'] = array(
'description' => 'The actor who owns this outbox.',
'type' => 'string',
'format' => 'uri',
'required' => true,
);
$schema['properties']['generator'] = array(
'description' => 'The software used to generate the collection.',
'type' => 'string',
'format' => 'uri',
);
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -1,173 +0,0 @@
<?php
/**
* Outbox REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use stdClass;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users as User_Collection;
use Activitypub\Transformer\Factory;
use function Activitypub\get_context;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_masked_wp_version;
/**
* ActivityPub Outbox REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#outbox
*/
class Outbox {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(users|actors)/(?P<user_id>[\w\-\.]+)/outbox',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'user_outbox_get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Renders the user-outbox
*
* @param \WP_REST_Request $request The request object.
* @return WP_REST_Response|\WP_Error The response object or WP_Error.
*/
public static function user_outbox_get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) );
$page = $request->get_param( 'page', 1 );
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_outbox_pre' );
$json = new stdClass();
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$json->{'@context'} = get_context();
$json->id = get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) );
$json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version();
$json->actor = $user->get_id();
$json->type = 'OrderedCollectionPage';
$json->partOf = get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) );
$json->totalItems = 0;
if ( $user_id > 0 ) {
$count_posts = \count_user_posts( $user_id, $post_types, true );
$json->totalItems = \intval( $count_posts );
} else {
foreach ( $post_types as $post_type ) {
$count_posts = \wp_count_posts( $post_type );
$json->totalItems += \intval( $count_posts->publish );
}
}
$json->first = \add_query_arg( 'page', 1, $json->partOf );
$json->last = \add_query_arg( 'page', \ceil( $json->totalItems / 10 ), $json->partOf );
if ( $page && ( ( \ceil( $json->totalItems / 10 ) ) > $page ) ) {
$json->next = \add_query_arg( 'page', $page + 1, $json->partOf );
}
if ( $page && ( $page > 1 ) ) {
$json->prev = \add_query_arg( 'page', $page - 1, $json->partOf );
}
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( $page ) {
$posts = \get_posts(
array(
'posts_per_page' => 10,
'author' => $user_id > 0 ? $user_id : null,
'paged' => $page,
'post_type' => $post_types,
)
);
foreach ( $posts as $post ) {
$transformer = Factory::get_transformer( $post );
if ( \is_wp_error( $transformer ) ) {
continue;
}
$post = $transformer->to_object();
$activity = new Activity();
$activity->set_type( 'Create' );
$activity->set_object( $post );
$json->orderedItems[] = $activity->to_array( false ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
}
}
/**
* Filter the ActivityPub outbox array.
*
* @param array $json The ActivityPub outbox array.
*/
$json = \apply_filters( 'activitypub_rest_outbox_array', $json );
/**
* Action triggered after the ActivityPub profile has been created and sent to the client
*/
\do_action( 'activitypub_outbox_post' );
$rest_response = new WP_REST_Response( $json, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* The supported parameters.
*
* @return array List of parameters.
*/
public static function request_parameters() {
$params = array();
$params['page'] = array(
'type' => 'integer',
'default' => 1,
);
$params['user_id'] = array(
'required' => true,
'type' => 'string',
);
return $params;
}
}

View File

@ -0,0 +1,160 @@
<?php
/**
* ActivityPub Post REST Endpoints
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_REST_Server;
use WP_REST_Response;
use WP_Error;
use Activitypub\Comment;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Replies;
use function Activitypub\get_rest_url_by_path;
/**
* Class Post
*
* @package Activitypub\Rest
*/
class Post {
/**
* Initialize the class and register routes.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes.
*/
public static function register_routes() {
register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/posts/(?P<id>\d+)/reactions',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( static::class, 'get_reactions' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'type' => 'integer',
),
),
)
);
register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/posts/(?P<id>\d+)/context',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( static::class, 'get_context' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'required' => true,
'type' => 'integer',
),
),
)
);
}
/**
* Get reactions for a post.
*
* @param \WP_REST_Request $request The request.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public static function get_reactions( $request ) {
$post_id = $request->get_param( 'id' );
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'post_not_found', 'Post not found', array( 'status' => 404 ) );
}
$reactions = array();
foreach ( Comment::get_comment_types() as $type_object ) {
$comments = get_comments(
array(
'post_id' => $post_id,
'type' => $type_object['type'],
'status' => 'approve',
)
);
if ( empty( $comments ) ) {
continue;
}
$count = count( $comments );
// phpcs:disable WordPress.WP.I18n
$label = sprintf(
_n(
$type_object['count_single'],
$type_object['count_plural'],
$count,
'activitypub'
),
number_format_i18n( $count )
);
// phpcs:enable WordPress.WP.I18n
$reactions[ $type_object['collection'] ] = array(
'label' => $label,
'items' => array_map(
function ( $comment ) {
return array(
'name' => $comment->comment_author,
'url' => $comment->comment_author_url,
'avatar' => get_comment_meta( $comment->comment_ID, 'avatar_url', true ),
);
},
$comments
),
);
}
return new WP_REST_Response( $reactions );
}
/**
* Get the context for a post.
*
* @param \WP_REST_Request $request The request.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public static function get_context( $request ) {
$post_id = $request->get_param( 'id' );
$collection = Replies::get_context_collection( $post_id );
if ( false === $collection ) {
return new WP_Error( 'post_not_found', 'Post not found', array( 'status' => 404 ) );
}
$response = array_merge(
array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'posts/%d/context', $post_id ) ),
),
$collection
);
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
}

View File

@ -0,0 +1,256 @@
<?php
/**
* Replies_Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Replies;
use Activitypub\Collection\Interactions;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub Replies_Controller class.
*/
class Replies_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = '(?P<object_type>[\w\-\.]+)s/(?P<id>[\w\-\.]+)/(?P<type>[\w\-\.]+)';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
'args' => array(
'object_type' => array(
'description' => 'The type of object to get replies for.',
'type' => 'string',
'enum' => array( 'post', 'comment' ),
'required' => true,
),
'id' => array(
'description' => 'The ID of the object.',
'type' => 'string',
'required' => true,
),
'type' => array(
'description' => 'The type of collection to query.',
'type' => 'string',
'enum' => array( 'replies', 'likes', 'shares' ),
'required' => true,
),
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => '__return_true',
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
'type' => 'integer',
'minimum' => 1,
// No default so we can differentiate between Collection and CollectionPage requests.
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Retrieves a collection of replies.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response|\WP_Error Response object or WP_Error object.
*/
public function get_items( $request ) {
$object_type = $request->get_param( 'object_type' );
$id = (int) $request->get_param( 'id' );
if ( 'comment' === $object_type ) {
$wp_object = \get_comment( $id );
} else {
$wp_object = \get_post( $id );
}
if ( ! isset( $wp_object ) || \is_wp_error( $wp_object ) ) {
return new \WP_Error(
'activitypub_replies_collection_does_not_exist',
\sprintf(
// translators: %s: The type (post, comment, etc.) for which no replies collection exists.
\__( 'No reply collection exists for the type %s.', 'activitypub' ),
$object_type
),
array( 'status' => 404 )
);
}
switch ( $request->get_param( 'type' ) ) {
case 'replies':
$response = $this->get_replies( $request, $wp_object );
break;
case 'likes':
$response = $this->get_likes( $request, $wp_object );
break;
case 'shares':
$response = $this->get_shares( $request, $wp_object );
break;
default:
$response = new \WP_Error( 'rest_unknown_collection_type', 'Unknown collection type.', array( 'status' => 404 ) );
}
// Prepend ActivityPub Context.
$response = array_merge( array( '@context' => Base_Object::JSON_LD_CONTEXT ), $response );
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
/**
* Retrieves a collection of replies.
*
* @param \WP_REST_Request $request The request object.
* @param \WP_Post|\WP_Comment $wp_object The WordPress object.
*
* @return array Response collection of replies.
*/
public function get_replies( $request, $wp_object ) {
$page = $request->get_param( 'page' );
// If the request parameter page is present get the CollectionPage otherwise the Replies collection.
if ( null === $page ) {
$response = Replies::get_collection( $wp_object );
} else {
$response = Replies::get_collection_page( $wp_object, $page );
}
return $response;
}
/**
* Retrieves a collection of likes.
*
* @param \WP_REST_Request $request The request object.
* @param \WP_Post|\WP_Comment $wp_object The WordPress object.
*
* @return array Response collection of likes.
*/
public function get_likes( $request, $wp_object ) {
if ( $wp_object instanceof \WP_Post ) {
$likes = Interactions::count_by_type( $wp_object->ID, 'like' );
} else {
$likes = 0;
}
$response = array(
'id' => get_rest_url_by_path( sprintf( 'posts/%d/likes', $wp_object->ID ) ),
'type' => 'Collection',
'totalItems' => $likes,
);
return $response;
}
/**
* Retrieves a collection of shares.
*
* @param \WP_REST_Request $request The request object.
* @param \WP_Post|\WP_Comment $wp_object The WordPress object.
*
* @return array Response collection of shares.
*/
public function get_shares( $request, $wp_object ) {
if ( $wp_object instanceof \WP_Post ) {
$shares = Interactions::count_by_type( $wp_object->ID, 'repost' );
} else {
$shares = 0;
}
$response = array(
'id' => get_rest_url_by_path( sprintf( 'posts/%d/shares', $wp_object->ID ) ),
'type' => 'Collection',
'totalItems' => $shares,
);
return $response;
}
/**
* Retrieves the schema for the Replies endpoint.
*
* @return array Schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'replies',
'type' => 'object',
'properties' => array(
'@context' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
'required' => true,
),
'id' => array(
'type' => 'string',
'format' => 'uri',
'required' => true,
),
'type' => array(
'type' => 'string',
'enum' => array( 'Collection', 'OrderedCollection', 'CollectionPage', 'OrderedCollectionPage' ),
'required' => true,
),
'first' => array(
'type' => 'string',
'format' => 'uri',
),
'last' => array(
'type' => 'string',
'format' => 'uri',
),
'items' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
),
),
),
);
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -8,9 +8,11 @@
namespace Activitypub\Rest;
use WP_Error;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Signature;
use Activitypub\Model\Application;
use function Activitypub\use_authorized_fetch;
/**
* ActivityPub Server REST-Class.
@ -24,79 +26,36 @@ class Server {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
\add_filter( 'rest_request_before_callbacks', array( self::class, 'validate_activitypub_requests' ), 9, 3 );
\add_filter( 'rest_request_before_callbacks', array( self::class, 'authorize_activitypub_requests' ), 10, 3 );
self::add_hooks();
}
/**
* Register routes
* Add sever hooks.
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/application',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( self::class, 'application_actor' ),
'permission_callback' => '__return_true',
),
)
);
public static function add_hooks() {
\add_filter( 'rest_request_before_callbacks', array( self::class, 'validate_requests' ), 9, 3 );
\add_filter( 'rest_request_parameter_order', array( self::class, 'request_parameter_order' ), 10, 2 );
}
/**
* Render Application actor profile
* Callback function to authorize an api request.
*
* @return WP_REST_Response The JSON profile of the Application Actor.
*/
public static function application_actor() {
$user = new Application();
$json = $user->to_array();
$rest_response = new WP_REST_Response( $json, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* Callback function to authorize each api requests
* The function is meant to be used as part of permission callbacks for rest api endpoints.
*
* @see WP_REST_Request
* It verifies the signature of POST, PUT, PATCH, and DELETE requests, as well as GET requests in secure mode.
* You can use the filter 'activitypub_defer_signature_verification' to defer the signature verification.
* HEAD requests are always bypassed.
*
* @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
* @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
*
* @param WP_REST_Response|\WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
* Usually a WP_REST_Response or WP_Error.
* @param array $handler Route handler used for the request.
* @param \WP_REST_Request $request Request used to generate the response.
* @param \WP_REST_Request $request The request object.
*
* @return mixed|WP_Error The response, error, or modified response.
* @return bool|\WP_Error True if the request is authorized, WP_Error if not.
*/
public static function authorize_activitypub_requests( $response, $handler, $request ) {
public static function verify_signature( $request ) {
if ( 'HEAD' === $request->get_method() ) {
return $response;
}
if ( \is_wp_error( $response ) ) {
return $response;
}
$route = $request->get_route();
// Check if it is an activitypub request and exclude webfinger and nodeinfo endpoints.
if (
! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ||
\str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'webfinger' ) ||
\str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'nodeinfo' ) ||
\str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'application' )
) {
return $response;
return true;
}
/**
@ -113,14 +72,14 @@ class Server {
$defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request );
if ( $defer ) {
return $response;
return true;
}
if (
// POST-Requests are always signed.
// POST-Requests always have to be signed.
'GET' !== $request->get_method() ||
// GET-Requests only require a signature in secure mode.
( 'GET' === $request->get_method() && ACTIVITYPUB_AUTHORIZED_FETCH )
( 'GET' === $request->get_method() && use_authorized_fetch() )
) {
$verified_request = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_request ) ) {
@ -132,7 +91,7 @@ class Server {
}
}
return $response;
return true;
}
/**
@ -145,7 +104,7 @@ class Server {
*
* @return mixed|WP_Error The response, error, or modified response.
*/
public static function validate_activitypub_requests( $response, $handler, $request ) {
public static function validate_requests( $response, $handler, $request ) {
if ( 'HEAD' === $request->get_method() ) {
return $response;
}
@ -181,4 +140,34 @@ class Server {
return $response;
}
/**
* Modify the parameter priority order for a REST API request.
*
* @param string[] $order Array of types to check, in order of priority.
* @param \WP_REST_Request $request The request object.
*
* @return string[] The modified order of types to check.
*/
public static function request_parameter_order( $order, $request ) {
$route = $request->get_route();
// Check if it is an activitypub request and exclude webfinger and nodeinfo endpoints.
if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
return $order;
}
$method = $request->get_method();
if ( WP_REST_Server::CREATABLE !== $method ) {
return $order;
}
return array(
'JSON',
'POST',
'URL',
'defaults',
);
}
}

View File

@ -0,0 +1,133 @@
<?php
/**
* ActivityPub URL Validator Controller.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Http;
use Activitypub\Embed;
/**
* URL Validator Controller Class.
*/
class URL_Validator_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = 'url/validate';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => array(
'url' => array(
'type' => 'string',
'format' => 'uri',
'required' => true,
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Check if a given request has access to validate URLs.
*
* @param \WP_REST_Request $request The request.
*
* @return bool True if the request has access to validate URLs, false otherwise.
*/
public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return current_user_can( 'edit_posts' );
}
/**
* Check if URL is a valid ActivityPub endpoint.
*
* @param \WP_REST_Request $request The request.
*
* @return \WP_REST_Response|\WP_Error
*/
public function get_items( $request ) {
$url = $request->get_param( 'url' );
$object = Http::get_remote_object( $url );
if ( is_wp_error( $object ) ) {
return new \WP_Error(
'activitypub_invalid_url',
__( 'Invalid URL.', 'activitypub' ),
array( 'status' => 400 )
);
}
$response = array(
'is_activitypub' => ! empty( $object['type'] ),
'is_real_oembed' => Embed::has_real_oembed( $url ),
'html' => false,
);
if ( $response['is_activitypub'] ) {
$response['html'] = wp_oembed_get( $url );
}
return rest_ensure_response( $response );
}
/**
* Get the URL validation schema.
*
* @return array
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'validated-url',
'type' => 'object',
'properties' => array(
'is_activitypub' => array(
'type' => 'boolean',
'default' => false,
),
'is_real_oembed' => array(
'type' => 'boolean',
'default' => false,
),
'html' => array(
'type' => 'string',
'default' => false,
),
),
);
$this->schema = $schema;
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -0,0 +1,173 @@
<?php
/**
* WebFinger REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
/**
* ActivityPub WebFinger REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://webfinger.net/
*/
class Webfinger_Controller extends \WP_REST_Controller {
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = 'webfinger';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => '__return_true',
'args' => array(
'resource' => array(
'description' => 'The WebFinger resource.',
'type' => 'string',
'required' => true,
'pattern' => '^(acct:)|^(https?://)(.+)$',
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Retrieves the WebFinger profile.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response Response object.
*/
public function get_item( $request ) {
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_webfinger_pre' );
$resource = $request->get_param( 'resource' );
$response = $this->get_profile( $resource );
$code = 200;
if ( \is_wp_error( $response ) ) {
$code = 400;
$error_data = $response->get_error_data();
if ( isset( $error_data['status'] ) ) {
$code = $error_data['status'];
}
}
return new \WP_REST_Response(
$response,
$code,
array(
'Access-Control-Allow-Origin' => '*',
'Content-Type' => 'application/jrd+json; charset=' . \get_option( 'blog_charset' ),
)
);
}
/**
* Get the WebFinger profile.
*
* @param string $webfinger The WebFinger resource.
*
* @return array|\WP_Error The WebFinger profile or WP_Error if not found.
*/
public function get_profile( $webfinger ) {
/**
* Filter the WebFinger data.
*
* @param array $data The WebFinger data.
* @param string $webfinger The WebFinger resource.
*/
return \apply_filters( 'webfinger_data', array(), $webfinger );
}
/**
* Retrieves the schema for the WebFinger endpoint.
*
* @return array Schema data.
*/
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$this->schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'webfinger',
'type' => 'object',
'required' => array( 'subject', 'links' ),
'properties' => array(
'subject' => array(
'description' => 'The subject of this WebFinger record.',
'type' => 'string',
'format' => 'uri',
),
'aliases' => array(
'description' => 'Alternative identifiers for the subject.',
'type' => 'array',
'items' => array(
'type' => 'string',
'format' => 'uri',
),
),
'links' => array(
'description' => 'Links associated with the subject.',
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'rel' => array(
'description' => 'The relation type of the link.',
'type' => 'string',
'required' => true,
),
'type' => array(
'description' => 'The content type of the link.',
'type' => 'string',
),
'href' => array(
'description' => 'The target URL of the link.',
'type' => 'string',
'format' => 'uri',
),
'template' => array(
'description' => 'A URI template for the link.',
'type' => 'string',
'format' => 'uri',
),
),
),
),
),
);
return $this->add_additional_fields_schema( $this->schema );
}
}

View File

@ -1,115 +0,0 @@
<?php
/**
* WebFinger REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_REST_Response;
/**
* ActivityPub WebFinger REST-Class.
*
* @author Matthias Pfefferle
*
* @see https://webfinger.net/
*/
class Webfinger {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_routes();
}
/**
* Register routes.
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/webfinger',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( self::class, 'webfinger' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
}
/**
* WebFinger endpoint.
*
* @param \WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response object.
*/
public static function webfinger( $request ) {
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
*/
\do_action( 'activitypub_rest_webfinger_pre' );
$code = 200;
$resource = $request->get_param( 'resource' );
$response = self::get_profile( $resource );
if ( \is_wp_error( $response ) ) {
$code = 400;
$error_data = $response->get_error_data();
if ( isset( $error_data['status'] ) ) {
$code = $error_data['status'];
}
}
return new WP_REST_Response(
$response,
$code,
array(
'Access-Control-Allow-Origin' => '*',
'Content-Type' => 'application/jrd+json; charset=' . get_option( 'blog_charset' ),
)
);
}
/**
* The supported parameters.
*
* @return array list of parameters
*/
public static function request_parameters() {
$params = array();
$params['resource'] = array(
'required' => true,
'type' => 'string',
'pattern' => '^(acct:)|^(https?://)(.+)$',
);
return $params;
}
/**
* Get the WebFinger profile.
*
* @param string $webfinger the WebFinger resource.
*
* @return array|\WP_Error The WebFinger profile or WP_Error if not found.
*/
public static function get_profile( $webfinger ) {
/**
* Filter the WebFinger data.
*
* @param array $data The WebFinger data.
* @param string $webfinger The WebFinger resource.
*/
return apply_filters( 'webfinger_data', array(), $webfinger );
}
}

View File

@ -0,0 +1,146 @@
<?php
/**
* Collection Trait file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
/**
* Collection Trait.
*
* Provides methods for handling ActivityPub Collections, including pagination
* and type transitions between Collection and CollectionPage.
*/
trait Collection {
/**
* Prepares a collection response by adding navigation links and handling pagination.
*
* Adds first, last, next, and previous page links to a collection response
* based on the current page and total items. Also handles the transformation
* between Collection and CollectionPage types.
*
* @param array $response The collection response array.
* @param \WP_REST_Request $request The request object.
* @return array|\WP_Error The response array with navigation links or WP_Error on invalid page.
*/
public function prepare_collection_response( $response, $request ) {
$page = $request->get_param( 'page' );
$per_page = $request->get_param( 'per_page' );
$max_pages = \ceil( $response['totalItems'] / $per_page );
if ( $page > $max_pages ) {
return new \WP_Error(
'rest_post_invalid_page_number',
'The page number requested is larger than the number of pages available.',
array( 'status' => 400 )
);
}
// No need to add links if there's only one page.
if ( 1 >= $max_pages && null === $page ) {
return $response;
}
$response['first'] = \add_query_arg( 'page', 1, $response['id'] );
$response['last'] = \add_query_arg( 'page', $max_pages, $response['id'] );
// If this is a Collection request, return early.
if ( null === $page ) {
// No items in Collections, only links to CollectionPages.
unset( $response['items'], $response['orderedItems'] );
return $response;
}
// Still here, so this is a Page request. Append the type.
$response['type'] .= 'Page';
$response['partOf'] = $response['id'];
$response['id'] .= '?page=' . $page;
if ( $max_pages > $page ) {
$response['next'] = \add_query_arg( 'page', $page + 1, $response['id'] );
}
if ( $page > 1 ) {
$response['prev'] = \add_query_arg( 'page', $page - 1, $response['id'] );
}
return $response;
}
/**
* Get the schema for an ActivityPub Collection.
*
* Returns a schema definition for ActivityPub (Ordered)Collection and (Ordered)CollectionPage
* that controllers can use to compose their full schema by passing in their item schema.
*
* @param array $item_schema Optional. The schema for the items in the collection. Default empty array.
* @return array The collection schema.
*/
public function get_collection_schema( $item_schema = array() ) {
$collection_schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'collection',
'type' => 'object',
'properties' => array(
'@context' => array(
'description' => 'The JSON-LD context of the OrderedCollection.',
'type' => array( 'string', 'array', 'object' ),
),
'id' => array(
'description' => 'The unique identifier for the OrderedCollection.',
'type' => 'string',
'format' => 'uri',
),
'type' => array(
'description' => 'The type of the object. Either OrderedCollection or OrderedCollectionPage.',
'type' => 'string',
'enum' => array( 'Collection', 'CollectionPage', 'OrderedCollection', 'OrderedCollectionPage' ),
),
'totalItems' => array(
'description' => 'The total number of items in the collection.',
'type' => 'integer',
'minimum' => 0,
),
'orderedItems' => array(
'description' => 'The ordered items in the collection.',
'type' => 'array',
),
'first' => array(
'description' => 'Link to the first page of the collection.',
'type' => 'string',
'format' => 'uri',
),
'last' => array(
'description' => 'Link to the last page of the collection.',
'type' => 'string',
'format' => 'uri',
),
'next' => array(
'description' => 'Link to the next page of the collection.',
'type' => 'string',
'format' => 'uri',
),
'prev' => array(
'description' => 'Link to the previous page of the collection.',
'type' => 'string',
'format' => 'uri',
),
'partOf' => array(
'description' => 'The OrderedCollection to which this OrderedCollectionPage belongs.',
'type' => 'string',
'format' => 'uri',
),
),
);
// Add the orderedItems property based on the provided item schema.
if ( ! empty( $item_schema ) ) {
$collection_schema['properties']['orderedItems']['items'] = $item_schema;
}
return $collection_schema;
}
}