diff --git a/activitypub.php b/activitypub.php index e826e46..abb68d9 100644 --- a/activitypub.php +++ b/activitypub.php @@ -27,7 +27,7 @@ function init() { \defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || \define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|(?<=

)|(?<=
)|^)#([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_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "[ap_title]\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_BASENAME', plugin_basename( __FILE__ ) ); @@ -47,6 +47,7 @@ function init() { Rest\Following::init(); Rest\Webfinger::init(); Rest\Server::init(); + Rest\Collection::init(); Admin::init(); Hashtag::init(); diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index fa2f941..e379c10 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -97,7 +97,7 @@ class Activitypub { $json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/blog-json.php'; } - if ( ACTIVITYPUB_SECURE_MODE ) { + if ( ACTIVITYPUB_AUTHORIZED_FETCH ) { $verification = Signature::verify_http_signature( $_SERVER ); if ( \is_wp_error( $verification ) ) { // fallback as template_loader can't return http headers diff --git a/includes/class-shortcodes.php b/includes/class-shortcodes.php index 7877b64..d1c71f5 100644 --- a/includes/class-shortcodes.php +++ b/includes/class-shortcodes.php @@ -1,6 +1,8 @@ #%s', + '', \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 ) { $hash_tags[] = \sprintf( - '', + '', \esc_url( \get_category_link( $category ) ), - \wp_strip_all_tags( $category->slug ) + esc_hashtag( $category->name ) ); } diff --git a/includes/collection/class-users.php b/includes/collection/class-users.php index 036bbc3..f6e35a6 100644 --- a/includes/collection/class-users.php +++ b/includes/collection/class-users.php @@ -185,4 +185,25 @@ class Users { public static function normalize_host( $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; + } } diff --git a/includes/functions.php b/includes/functions.php index a3698c8..6d9013d 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -232,6 +232,43 @@ function snake_to_camel_case( $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. * diff --git a/includes/model/class-application-user.php b/includes/model/class-application-user.php index 888c709..2affde5 100644 --- a/includes/model/class-application-user.php +++ b/includes/model/class-application-user.php @@ -98,4 +98,12 @@ class Application_User extends Blog_User { public function get_attachment() { return array(); } + + public function get_featured_tags() { + return array(); + } + + public function get_featured() { + return array(); + } } diff --git a/includes/model/class-user.php b/includes/model/class-user.php index 23cd013..63ec86f 100644 --- a/includes/model/class-user.php +++ b/includes/model/class-user.php @@ -18,6 +18,24 @@ class User extends Actor { */ 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 * @@ -205,6 +223,24 @@ class User extends Actor { 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. * diff --git a/includes/rest/class-collection.php b/includes/rest/class-collection.php new file mode 100644 index 0000000..00b676d --- /dev/null +++ b/includes/rest/class-collection.php @@ -0,0 +1,162 @@ +[\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[\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; + } +} diff --git a/includes/rest/class-following.php b/includes/rest/class-following.php index f212677..48e3ca0 100644 --- a/includes/rest/class-following.php +++ b/includes/rest/class-following.php @@ -3,6 +3,7 @@ namespace Activitypub\Rest; use Activitypub\Collection\Users as User_Collection; +use function Activitypub\is_single_user; use function Activitypub\get_rest_url_by_path; /** @@ -18,6 +19,7 @@ class Following { */ public static function init() { \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->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 @@ -98,4 +103,27 @@ class Following { 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; + } } diff --git a/includes/rest/class-inbox.php b/includes/rest/class-inbox.php index 38969f7..aa8e012 100644 --- a/includes/rest/class-inbox.php +++ b/includes/rest/class-inbox.php @@ -102,7 +102,7 @@ class Inbox { $json->first = $json->partOf; // phpcs:ignore // 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 diff --git a/includes/rest/class-outbox.php b/includes/rest/class-outbox.php index b7ff095..2f50bd7 100644 --- a/includes/rest/class-outbox.php +++ b/includes/rest/class-outbox.php @@ -116,7 +116,7 @@ class Outbox { } // 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 diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php index 3b78af2..325b9ad 100644 --- a/includes/rest/class-server.php +++ b/includes/rest/class-server.php @@ -72,9 +72,9 @@ class Server { // check if it is an activitypub request and exclude webfinger and nodeinfo endpoints if ( - ! str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) || - str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'webfinger' ) || - str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'nodeinfo' ) + ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) || + \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'webfinger' ) || + \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'nodeinfo' ) ) { return $response; } @@ -86,7 +86,7 @@ class Server { return $verified_request; } } 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 ); if ( \is_wp_error( $verified_request ) ) { return $verified_request; diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index e9453dc..cc8a1c6 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -6,6 +6,7 @@ use Activitypub\Collection\Users; use Activitypub\Model\Blog_User; use Activitypub\Activity\Base_Object; +use function Activitypub\esc_hashtag; use function Activitypub\is_single_user; use function Activitypub\get_rest_url_by_path; @@ -379,8 +380,8 @@ class Post { foreach ( $post_tags as $post_tag ) { $tag = array( 'type' => 'Hashtag', - 'href' => esc_url( \get_tag_link( $post_tag->term_id ) ), - 'name' => '#' . \esc_attr( $post_tag->slug ), + 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ), + 'name' => esc_hashtag( $post_tag->name ), ); $tags[] = $tag; }