update endpoints (#390)

* add collection endpoint

* show featured posts

* more consistant wording

* backwards compatibility with php7.x

* compatibility with php5.6

* use ACTIVITYPUB_AUTHORIZED_FETCH instead

because the ACTIVITYPUB_SECURE_MODE could be misinterpreted with disabling the security mechanisms completely.

* the blog user follows all authors of a blog

if not in single_user mode

* phpdoc

* adding changes based on feedback from @jeherve

* global namespace

* better hashtag handling

should also fix #373 #239

thanks @jeherve for help and feedback!

* fix workflow
This commit is contained in:
Matthias Pfefferle 2023-08-09 13:07:30 +02:00 committed by GitHub
parent beb194c395
commit 049046be70
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 312 additions and 16 deletions

View file

@ -27,7 +27,7 @@ function init() {
\defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || \define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|(?<=<p>)|(?<=<br>)|^)#([A-Za-z0-9_]+)(?:(?=\s|[[:punct:]]|$))' ); \defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || \define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|(?<=<p>)|(?<=<br>)|^)#([A-Za-z0-9_]+)(?:(?=\s|[[:punct:]]|$))' );
\defined( 'ACTIVITYPUB_USERNAME_REGEXP' ) || \define( 'ACTIVITYPUB_USERNAME_REGEXP', '(?:([A-Za-z0-9_-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))' ); \defined( 'ACTIVITYPUB_USERNAME_REGEXP' ) || \define( 'ACTIVITYPUB_USERNAME_REGEXP', '(?:([A-Za-z0-9_-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))' );
\defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "<strong>[ap_title]</strong>\n\n[ap_content]\n\n[ap_hashtags]\n\n[ap_shortlink]" ); \defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "<strong>[ap_title]</strong>\n\n[ap_content]\n\n[ap_hashtags]\n\n[ap_shortlink]" );
\defined( 'ACTIVITYPUB_SECURE_MODE' ) || \define( 'ACTIVITYPUB_SECURE_MODE', apply_filters( 'activitypub_secure_mode', $value = false ) ); \defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) || \define( 'ACTIVITYPUB_AUTHORIZED_FETCH', false );
\define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); \define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
\define( 'ACTIVITYPUB_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); \define( 'ACTIVITYPUB_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
@ -47,6 +47,7 @@ function init() {
Rest\Following::init(); Rest\Following::init();
Rest\Webfinger::init(); Rest\Webfinger::init();
Rest\Server::init(); Rest\Server::init();
Rest\Collection::init();
Admin::init(); Admin::init();
Hashtag::init(); Hashtag::init();

View file

@ -97,7 +97,7 @@ class Activitypub {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/blog-json.php'; $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/blog-json.php';
} }
if ( ACTIVITYPUB_SECURE_MODE ) { if ( ACTIVITYPUB_AUTHORIZED_FETCH ) {
$verification = Signature::verify_http_signature( $_SERVER ); $verification = Signature::verify_http_signature( $_SERVER );
if ( \is_wp_error( $verification ) ) { if ( \is_wp_error( $verification ) ) {
// fallback as template_loader can't return http headers // fallback as template_loader can't return http headers

View file

@ -1,6 +1,8 @@
<?php <?php
namespace Activitypub; namespace Activitypub;
use function Activitypub\esc_hashtag;
class Shortcodes { class Shortcodes {
/** /**
* Class constructor, registering WordPress then Shortcodes * Class constructor, registering WordPress then Shortcodes
@ -44,9 +46,9 @@ class Shortcodes {
foreach ( $tags as $tag ) { foreach ( $tags as $tag ) {
$hash_tags[] = \sprintf( $hash_tags[] = \sprintf(
'<a rel="tag" class="u-tag u-category" href="%s">#%s</a>', '<a rel="tag" class="u-tag u-category" href="%s">%s</a>',
\esc_url( \get_tag_link( $tag ) ), \esc_url( \get_tag_link( $tag ) ),
\wp_strip_all_tags( $tag->slug ) esc_hashtag( $tag->name )
); );
} }
@ -357,9 +359,9 @@ class Shortcodes {
foreach ( $categories as $category ) { foreach ( $categories as $category ) {
$hash_tags[] = \sprintf( $hash_tags[] = \sprintf(
'<a rel="tag" class="u-tag u-category" href="%s">#%s</a>', '<a rel="tag" class="u-tag u-category" href="%s">%s</a>',
\esc_url( \get_category_link( $category ) ), \esc_url( \get_category_link( $category ) ),
\wp_strip_all_tags( $category->slug ) esc_hashtag( $category->name )
); );
} }

View file

@ -185,4 +185,25 @@ class Users {
public static function normalize_host( $host ) { public static function normalize_host( $host ) {
return \str_replace( 'www.', '', $host ); return \str_replace( 'www.', '', $host );
} }
/**
* Get the User collection.
*
* @return array The User collection.
*/
public static function get_collection() {
$users = \get_users(
array(
'capability__in' => array( 'publish_posts' ),
)
);
$return = array();
foreach ( $users as $user ) {
$return[] = User::from_wp_user( $user->ID );
}
return $return;
}
} }

View file

@ -232,6 +232,43 @@ function snake_to_camel_case( $string ) {
return lcfirst( str_replace( '_', '', ucwords( $string, '_' ) ) ); return lcfirst( str_replace( '_', '', ucwords( $string, '_' ) ) );
} }
/**
* Escapes a Tag, to be used as a hashtag.
*
* @param string $string The string to escape.
*
* @return string The escaped hastag.
*/
function esc_hashtag( $string ) {
$hashtag = \wp_specialchars_decode( $string, ENT_QUOTES );
// Remove all characters that are not letters, numbers, or underscores.
$hashtag = \preg_replace( '/emoji-regex(*SKIP)(?!)|[^\p{L}\p{Nd}_]+/u', '_', $hashtag );
// Capitalize every letter that is preceded by an underscore.
$hashtag = preg_replace_callback(
'/_(.)/',
function ( $matches ) {
return '' . strtoupper( $matches[1] );
},
$hashtag
);
// Add a hashtag to the beginning of the string.
$hashtag = ltrim( $hashtag, '#' );
$hashtag = '#' . $hashtag;
/**
* Allow defining your own custom hashtag generation rules.
*
* @param string $hashtag The hashtag to be returned.
* @param string $string The original string.
*/
$hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $string );
return esc_html( $hashtag );
}
/** /**
* Check if a request is for an ActivityPub request. * Check if a request is for an ActivityPub request.
* *

View file

@ -98,4 +98,12 @@ class Application_User extends Blog_User {
public function get_attachment() { public function get_attachment() {
return array(); return array();
} }
public function get_featured_tags() {
return array();
}
public function get_featured() {
return array();
}
} }

View file

@ -18,6 +18,24 @@ class User extends Actor {
*/ */
protected $_id; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore protected $_id; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
/**
* The Featured-Tags.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#featuredTags
*
* @var string
*/
protected $featured_tags;
/**
* The Featured-Posts.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#featured
*
* @var string
*/
protected $featured;
/** /**
* The User-Type * The User-Type
* *
@ -205,6 +223,24 @@ class User extends Actor {
return get_rest_url_by_path( sprintf( 'users/%d/following', $this->get__id() ) ); return get_rest_url_by_path( sprintf( 'users/%d/following', $this->get__id() ) );
} }
/**
* Returns the Featured-API-Endpoint.
*
* @return string The Featured-Endpoint.
*/
public function get_featured() {
return get_rest_url_by_path( sprintf( 'users/%d/collections/featured', $this->get__id() ) );
}
/**
* Returns the Featured-Tags-API-Endpoint.
*
* @return string The Featured-Tags-Endpoint.
*/
public function get_featured_tags() {
return get_rest_url_by_path( sprintf( 'users/%d/collections/tags', $this->get__id() ) );
}
/** /**
* Extend the User-Output with Attachments. * Extend the User-Output with Attachments.
* *

View file

@ -0,0 +1,162 @@
<?php
namespace Activitypub\Rest;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Transformer\Post;
use function Activitypub\esc_hashtag;
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() {
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
}
/**
* Register routes
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/users/(?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/(?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',
),
)
);
}
/**
* The Featured Tags endpoint
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response object.
*/
public static function tags_get( $request ) {
$user_id = $request->get_param( 'user_id' );
$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' => 'https://www.w3.org/ns/activitystreams',
array(
'Hashtah' => 'as:Hastag',
),
'id' => get_rest_url_by_path( sprintf( 'users/%d/collections/tags', $user_id ) ),
'totalItems' => count( $tags ),
'items' => array(),
);
foreach ( $tags as $tag ) {
$response['items'][] = array(
'type' => 'Hashtag',
'href' => \esc_url( \get_tag_link( $tag ) ),
'name' => esc_hashtag( $tag->name ),
);
}
return new WP_REST_Response( $response, 200 );
}
/**
* Featured posts endpoint
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response object.
*/
public static function featured_get( $request ) {
$user_id = $request->get_param( 'user_id' );
$args = array(
'post__in' => \get_option( 'sticky_posts' ),
'ignore_sticky_posts' => 1,
);
if ( $user_id > 0 ) {
$args['author'] = $user_id;
}
$posts = \get_posts( $args );
$response = array(
'@context' => 'https://www.w3.org/ns/activitystreams',
array(
'ostatus' => 'http://ostatus.org#',
'atomUri' => 'ostatus:atomUri',
'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri',
'conversation' => 'ostatus:conversation',
'sensitive' => 'as:sensitive',
'toot' => 'http://joinmastodon.org/ns#',
'votersCount' => 'toot:votersCount',
),
'id' => get_rest_url_by_path( sprintf( 'users/%d/collections/featured', $user_id ) ),
'totalItems' => count( $posts ),
'items' => array(),
);
foreach ( $posts as $post ) {
$response['items'][] = Post::transform( $post )->to_object()->to_array();
}
return new WP_REST_Response( $response, 200 );
}
/**
* 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;
}
}

View file

@ -3,6 +3,7 @@ namespace Activitypub\Rest;
use Activitypub\Collection\Users as User_Collection; 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_rest_url_by_path;
/** /**
@ -18,6 +19,7 @@ class Following {
*/ */
public static function init() { public static function init() {
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) ); \add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
\add_filter( 'activitypub_rest_following', array( self::class, 'default_following' ), 10, 2 );
} }
/** /**
@ -68,8 +70,11 @@ class Following {
$json->type = 'OrderedCollectionPage'; $json->type = 'OrderedCollectionPage';
$json->partOf = get_rest_url_by_path( sprintf( 'users/%d/following', $user->get__id() ) ); // phpcs:ignore $json->partOf = get_rest_url_by_path( sprintf( 'users/%d/following', $user->get__id() ) ); // phpcs:ignore
$json->totalItems = 0; // phpcs:ignore
$json->orderedItems = apply_filters( 'activitypub_following', array(), $user ); // phpcs:ignore $items = apply_filters( 'activitypub_rest_following', array(), $user ); // phpcs:ignore
$json->totalItems = count( $items ); // phpcs:ignore
$json->orderedItems = $items; // phpcs:ignore
$json->first = $json->partOf; // phpcs:ignore $json->first = $json->partOf; // phpcs:ignore
@ -98,4 +103,27 @@ class Following {
return $params; return $params;
} }
/**
* Add the Blog Authors to the following list of the Blog Actor
* if Blog not in single mode.
*
* @param array $array The array of following urls.
* @param User $user The user object.
*
* @return array The array of following urls.
*/
public static function default_following( $array, $user ) {
if ( 0 !== $user->get__id() || is_single_user() ) {
return $array;
}
$users = User_Collection::get_collection();
foreach ( $users as $user ) {
$array[] = $user->get_url();
}
return $array;
}
} }

View file

@ -102,7 +102,7 @@ class Inbox {
$json->first = $json->partOf; // phpcs:ignore $json->first = $json->partOf; // phpcs:ignore
// filter output // filter output
$json = \apply_filters( 'activitypub_inbox_array', $json ); $json = \apply_filters( 'activitypub_rest_inbox_array', $json );
/* /*
* Action triggerd after the ActivityPub profile has been created and sent to the client * Action triggerd after the ActivityPub profile has been created and sent to the client

View file

@ -116,7 +116,7 @@ class Outbox {
} }
// filter output // filter output
$json = \apply_filters( 'activitypub_outbox_array', $json ); $json = \apply_filters( 'activitypub_rest_outbox_array', $json );
/* /*
* Action triggerd after the ActivityPub profile has been created and sent to the client * Action triggerd after the ActivityPub profile has been created and sent to the client

View file

@ -72,9 +72,9 @@ class Server {
// check if it is an activitypub request and exclude webfinger and nodeinfo endpoints // check if it is an activitypub request and exclude webfinger and nodeinfo endpoints
if ( if (
! str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) || ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ||
str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'webfinger' ) || \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 ) . 'nodeinfo' )
) { ) {
return $response; return $response;
} }
@ -86,7 +86,7 @@ class Server {
return $verified_request; return $verified_request;
} }
} elseif ( 'GET' === $request->get_method() ) { // GET-Requests are only signed in secure mode } elseif ( 'GET' === $request->get_method() ) { // GET-Requests are only signed in secure mode
if ( ACTIVITYPUB_SECURE_MODE ) { if ( ACTIVITYPUB_AUTHORIZED_FETCH ) {
$verified_request = Signature::verify_http_signature( $request ); $verified_request = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_request ) ) { if ( \is_wp_error( $verified_request ) ) {
return $verified_request; return $verified_request;

View file

@ -6,6 +6,7 @@ use Activitypub\Collection\Users;
use Activitypub\Model\Blog_User; use Activitypub\Model\Blog_User;
use Activitypub\Activity\Base_Object; use Activitypub\Activity\Base_Object;
use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user; use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path; use function Activitypub\get_rest_url_by_path;
@ -379,8 +380,8 @@ class Post {
foreach ( $post_tags as $post_tag ) { foreach ( $post_tags as $post_tag ) {
$tag = array( $tag = array(
'type' => 'Hashtag', 'type' => 'Hashtag',
'href' => esc_url( \get_tag_link( $post_tag->term_id ) ), 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ),
'name' => '#' . \esc_attr( $post_tag->slug ), 'name' => esc_hashtag( $post_tag->name ),
); );
$tags[] = $tag; $tags[] = $tag;
} }