modified file wp-piwik

This commit is contained in:
2026-06-03 21:29:22 +00:00
committed by Gitium
parent 57bccfdbd1
commit 769efed689
2556 changed files with 368982 additions and 126264 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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -0,0 +1,173 @@
<?php
/**
* Server REST-Class file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use WP_Error;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Signature;
use function Activitypub\use_authorized_fetch;
/**
* ActivityPub Server REST-Class.
*
* @author Django Doucet
*
* @see https://www.w3.org/TR/activitypub/#security-verification
*/
class Server {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::add_hooks();
}
/**
* Add sever hooks.
*/
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 );
}
/**
* Callback function to authorize an api request.
*
* The function is meant to be used as part of permission callbacks for rest api endpoints.
*
* 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_Request $request The request object.
*
* @return bool|\WP_Error True if the request is authorized, WP_Error if not.
*/
public static function verify_signature( $request ) {
if ( 'HEAD' === $request->get_method() ) {
return true;
}
/**
* Filter to defer signature verification.
*
* Skip signature verification for debugging purposes or to reduce load for
* certain Activity-Types, like "Delete".
*
* @param bool $defer Whether to defer signature verification.
* @param \WP_REST_Request $request The request used to generate the response.
*
* @return bool Whether to defer signature verification.
*/
$defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request );
if ( $defer ) {
return true;
}
if (
// POST-Requests always have to be signed.
'GET' !== $request->get_method() ||
// GET-Requests only require a signature in secure mode.
( 'GET' === $request->get_method() && use_authorized_fetch() )
) {
$verified_request = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_request ) ) {
return new WP_Error(
'activitypub_signature_verification',
$verified_request->get_error_message(),
array( 'status' => 401 )
);
}
}
return true;
}
/**
* Callback function to validate incoming ActivityPub requests
*
* @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.
*
* @return mixed|WP_Error The response, error, or modified response.
*/
public static function validate_requests( $response, $handler, $request ) {
if ( 'HEAD' === $request->get_method() ) {
return $response;
}
$route = $request->get_route();
if (
\is_wp_error( $response ) ||
! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE )
) {
return $response;
}
$params = $request->get_json_params();
// Type is required for ActivityPub requests, so it fail later in the process.
if ( ! isset( $params['type'] ) ) {
return $response;
}
if (
ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS &&
in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
) {
return new WP_Error(
'activitypub_server_does_not_accept_incoming_interactions',
\__( 'This server does not accept incoming interactions.', 'activitypub' ),
// We have to use a 2XX status code here, because otherwise the response will be
// treated as an error and Mastodon might block this WordPress instance.
array( 'status' => 202 )
);
}
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

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