From 4e653f5f75d62062cae1bef334cd0bcb590b324a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 20 Dec 2018 11:33:08 +0100 Subject: [PATCH] 0.1.0 * added basic WebFinger support * added basic NodeInfo support * fully functional "follow" activity * send new posts to your followers * receive comments from your followers --- .travis.yml | 2 +- README.md | 28 +-- activitypub.php | 24 ++- includes/class-activitypub-activities.php | 64 ------- includes/class-activitypub-activity.php | 84 +++++++++ includes/class-activitypub-admin.php | 54 ++++++ includes/class-activitypub-post.php | 142 +++++++++++---- includes/class-activitypub-signature.php | 40 ++--- includes/class-activitypub.php | 9 + includes/class-db-activitypub-followers.php | 63 +------ includes/class-rest-activitypub-followers.php | 8 +- includes/class-rest-activitypub-inbox.php | 162 +++++++++++++++++- includes/class-rest-activitypub-outbox.php | 42 ++++- includes/functions.php | 153 ++++++++++++++--- languages/activitypub.pot | 117 +++++++++++-- readme.txt | 28 +-- templates/json-post.php | 4 +- templates/settings-page.php | 64 +++++++ 18 files changed, 825 insertions(+), 263 deletions(-) delete mode 100644 includes/class-activitypub-activities.php create mode 100644 includes/class-activitypub-activity.php create mode 100644 includes/class-activitypub-admin.php create mode 100644 templates/settings-page.php diff --git a/.travis.yml b/.travis.yml index 7e65ed9..66854d6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,7 +17,7 @@ env: - WP_TRAVISCI=travis:phpunit - SVN_REPO: https://plugins.svn.wordpress.org/activitypub/ - GH_REF: https://github.com/pfefferle/wordpress-activitypub.git - - secure: k6uZL/zVYvuSsG4tUMvVC6+DTYKSueOYj6j9csKSa08EzPl0JLPoNN/5MPihi+zZkBPLL+KjBgUUYfBtdcOh/xwPb+lilg5UnvM7TKQEG583RtBbohADvahQHN4mxZb6yBvrmvbB5jNtf+3L4RcOgdONEb2CpGo5n8RRM3MXxF4WSn9s+F+P0fvWCpcZnM9yqgbTJNUaZHw4tRWQ7eYZ5kFxSxKSnZd5+149UAh2YcKjA+ix3rrK0ClOlGaMVZz+SV4/qoNwamxMTMAQ6Be1wIelXz0n92FIonw6euDfBSgNLg+WLiAYoqaM+tEluLV1DRcx5TLUfmAOGli6pEfqL6XZxf8iZheMtn5Ir0nR4vLbOUKEojqEpLwmlUjiTN6RSZbPMquBNz/lOEQd9S/EzPLrtvlBRYAq0EmI62KtXVG+vHta2TTF+LS0caXjVZaHcpF4SYmuG5WyR6d0KpnVw6czvXu7hyq2sNz6lj1hUt15pPZO8tFkJFTs87pOBfEj/GnjIE4Iab2HA/HgdWqFpB+5ZAWH9QDIa9c3+QUQQf2qA7Z1yS0c5SBn/TE+0O3yomyBTD63Zc7gNG2qqw+THql5fzG3iGV5M6db+yTY0INfsJYuRjQXpn9Q35ZTxgXEEvu7naHh162wa14K18zzXoVEjhywOoW3X1Qiz6VFK8s= + - secure: "WaeBLo0MDuK4X7NQvk5Ie5BVnexHtyDfHAV8v7dB8B67d8GCWy9K5I54jTECNRUC+CecMakLq5DOfn+ThtWdkJmoJKnGNFp8ZrWkMsfVJRi3CDED2HkccOrxkmXBj8Z6A8jZjcfVNrEmq/6697xVNRGeaS08l9rokh7pyb8INWY=" matrix: include: - php: 7.2 diff --git a/README.md b/README.md index 5fac62d..65361e1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Donate link:** https://notiz.blog/donate/ **Tags:** OStatus, fediverse, activitypub, activitystream **Requires at least:** 4.7 -**Tested up to:** 5.0 +**Tested up to:** 5.0.2 **Stable tag:** 0.1.0 **Requires PHP:** 5.6 **License:** MIT @@ -13,9 +13,9 @@ The ActivityPub protocol is a decentralized social networking protocol based upo ## Description ## -This is **BETA** software, see the FAQ to see what works and what still needs to be implemented or is in planning. +This is **BETA** software, see the FAQ to see the current feature set or rather what is still planned. -This plugin enables ActivityPub for your Blog. Your readers will be able to follow your Blogposts on Mastodon and other Federated Plattforms that support ActivityPub. +This plugin implements the ActivityPub for your Blog. Your readers will be able to follow your Blogposts on Mastodon and other Federated Plattforms that support ActivityPub. ## Frequently Asked Questions ## @@ -26,30 +26,34 @@ Implemented: * profile pages (JSON representation) * custom links * functional inbox/outbox -* follow (accept follows) +* follow (accept follows) +* share posts +* receive comments/reactions To implement: -* share posts -* share comments +* signature verification +* better WordPress integration +* better configuration possibilities +* threaded comments support ### Why does the plugin not support ...? ### *ActivityPub* extends WordPress with some fediverse features, but it does not compete with plattforms like Friendi.ca or Mastodon. If you want to have a **decentralized social network**, please use [Mastodon](https://joinmastodon.org/) or [GNU.social](https://gnu.io/social/). -### What are the differences to Pterotype? ### +### What are the differences between this plugin and Pterotype? ### **PHP Version** -*ActivityPub* needs PHP 5.6, *Pterotype* requires 7.2.x +*This plugin* needs PHP 5.6, *Pterotype* requires 7.2.x **Compatibility** -*ActivityPub* is compatible with OStatus and the IndieWeb movement. *Pterotype* implements its own WebFinger endpoint, that is not compatible with the [WebFinger plugin](https://wordpress.org/plugins/webfinger/). +*This plugin* is compatible with OStatus and the IndieWeb movement. *Pterotype* implements for example its own WebFinger endpoint, which is not compatible with the [WebFinger plugin](https://wordpress.org/plugins/webfinger/). **Custom tables** -*Pterotype* creates/uses a bunch of custom tables, *ActivityPub* only uses the native tables and adds as few meta data as possible. +*Pterotype* creates/uses a bunch of custom tables, *this plugin* only uses the native tables and adds as few meta data as possible. ## Changelog ## @@ -59,7 +63,9 @@ Project maintained on github at [pfefferle/wordpress-activitypub](https://github * added basic WebFinger support * added basic NodeInfo support -* fully functional "follow" activity +* fully functional "follow" activity +* send new posts to your followers +* receive comments from your followers ### 0.0.2 ### diff --git a/activitypub.php b/activitypub.php index ff72b36..303ca44 100644 --- a/activitypub.php +++ b/activitypub.php @@ -18,6 +18,7 @@ function activitypub_init() { require_once dirname( __FILE__ ) . '/includes/class-activitypub-signature.php'; require_once dirname( __FILE__ ) . '/includes/class-activitypub-post.php'; + require_once dirname( __FILE__ ) . '/includes/class-activitypub-activity.php'; require_once dirname( __FILE__ ) . '/includes/class-db-activitypub-followers.php'; require_once dirname( __FILE__ ) . '/includes/functions.php'; @@ -29,9 +30,16 @@ function activitypub_init() { // Configure the REST API route require_once dirname( __FILE__ ) . '/includes/class-rest-activitypub-outbox.php'; add_action( 'rest_api_init', array( 'Rest_Activitypub_Outbox', 'register_routes' ) ); + add_action( 'activitypub_send_post_activity', array( 'Rest_Activitypub_Outbox', 'send_post_activity' ) ); require_once dirname( __FILE__ ) . '/includes/class-rest-activitypub-inbox.php'; add_action( 'rest_api_init', array( 'Rest_Activitypub_Inbox', 'register_routes' ) ); + //add_filter( 'rest_pre_serve_request', array( 'Rest_Activitypub_Inbox', 'serve_request' ), 11, 4 ); + add_action( 'activitypub_inbox_follow', array( 'Rest_Activitypub_Inbox', 'handle_follow' ), 10, 2 ); + add_action( 'activitypub_inbox_unfollow', array( 'Rest_Activitypub_Inbox', 'handle_unfollow' ), 10, 2 ); + add_action( 'activitypub_inbox_like', array( 'Rest_Activitypub_Inbox', 'handle_reaction' ), 10, 2 ); + add_action( 'activitypub_inbox_announce', array( 'Rest_Activitypub_Inbox', 'handle_reaction' ), 10, 2 ); + add_action( 'activitypub_inbox_create', array( 'Rest_Activitypub_Inbox', 'handle_create' ), 10, 2 ); require_once dirname( __FILE__ ) . '/includes/class-rest-activitypub-followers.php'; add_action( 'rest_api_init', array( 'Rest_Activitypub_Followers', 'register_routes' ) ); @@ -45,11 +53,17 @@ function activitypub_init() { add_filter( 'nodeinfo_data', array( 'Rest_Activitypub_Nodeinfo', 'add_nodeinfo_discovery' ), 10, 2 ); add_filter( 'nodeinfo2_data', array( 'Rest_Activitypub_Nodeinfo', 'add_nodeinfo2_discovery' ), 10 ); - // Configure activities - require_once dirname( __FILE__ ) . '/includes/class-activitypub-activities.php'; - add_action( 'activitypub_inbox_follow', array( 'Activitypub_Activities', 'accept' ), 10, 2 ); - add_action( 'activitypub_inbox_follow', array( 'Activitypub_Activities', 'follow' ), 10, 2 ); - add_action( 'activitypub_inbox_unfollow', array( 'Activitypub_Activities', 'unfollow' ), 10, 2 ); + add_post_type_support( 'post', 'activitypub' ); + add_post_type_support( 'page', 'activitypub' ); + + $post_types = get_post_types_by_support( 'activitypub' ); + foreach ( $post_types as $post_type ) { + add_action( 'publish_' . $post_type, array( 'Activitypub', 'schedule_post_activity' ) ); + } + + require_once dirname( __FILE__ ) . '/includes/class-activitypub-admin.php'; + add_action( 'admin_menu', array( 'Activitypub_Admin', 'admin_menu' ) ); + add_action( 'admin_init', array( 'Activitypub_Admin', 'register_settings' ) ); } add_action( 'plugins_loaded', 'activitypub_init' ); diff --git a/includes/class-activitypub-activities.php b/includes/class-activitypub-activities.php deleted file mode 100644 index 870a1f2..0000000 --- a/includes/class-activitypub-activities.php +++ /dev/null @@ -1,64 +0,0 @@ - array( 'https://www.w3.org/ns/activitystreams' ), - 'type' => 'Accept', - 'actor' => get_author_posts_url( $author_id ), - 'object' => $data, - 'to' => $data['actor'], - ) - ); - - return activitypub_safe_remote_post( $inbox, $activity, $author_id ); - } - - /** - * [follow description] - * @param [type] $data [description] - * @param [type] $author_id [description] - * @return [type] [description] - */ - public static function follow( $data, $author_id ) { - if ( ! array_key_exists( 'actor', $data ) ) { - return new WP_Error( 'activitypub_no_actor', __( 'No "Actor" found', 'activitypub' ), $metadata ); - } - - Db_Activitypub_Followers::add_follower( $data['actor'], $author_id ); - } - - /** - * [unfollow description] - * @param [type] $data [description] - * @param [type] $author_id [description] - * @return [type] [description] - */ - public static function unfollow( $data, $author_id ) { - - } - - /** - * [create description] - * @param [type] $data [description] - * @param [type] $author_id [description] - * @return [type] [description] - */ - public static function create( $data, $author_id ) { - - } -} diff --git a/includes/class-activitypub-activity.php b/includes/class-activitypub-activity.php new file mode 100644 index 0000000..68e1b92 --- /dev/null +++ b/includes/class-activitypub-activity.php @@ -0,0 +1,84 @@ +context = null; + } elseif ( 'full' === $context ) { + $this->context = get_activitypub_context(); + } + + $this->type = ucfirst( $type ); + $this->published = date( 'Y-m-d\TH:i:s\Z', strtotime( 'now' ) ); + } + + public function __call( $method, $params ) { + $var = strtolower( substr( $method, 4 ) ); + + if ( strncasecmp( $method, 'get', 3 ) === 0 ) { + return $this->$var; + } + + if ( strncasecmp( $method, 'set', 3 ) === 0 ) { + $this->$var = $params[0]; + } + } + + public function from_post( $object ) { + $this->object = $object; + $this->published = $object['published']; + $this->actor = $object['attributedTo']; + $this->id = $object['id']; + } + + public function from_comment( $object ) { + + } + + public function to_array() { + $array = get_object_vars( $this ); + + if ( $this->context ) { + $array = array( '@context' => $this->context ) + $array; + } + + unset( $array['context'] ); + + return $array; + } + + public function to_json() { + return wp_json_encode( $this->to_array() ); + } + + public function to_simple_array() { + return array( + '@context' => $this->context, + 'type' => $this->type, + 'actor' => $this->actor, + 'object' => $this->object, + 'to' => $this->to, + ); + } + + public function to_simple_json() { + return wp_json_encode( $this->to_simple_array() ); + } +} diff --git a/includes/class-activitypub-admin.php b/includes/class-activitypub-admin.php new file mode 100644 index 0000000..bf14d73 --- /dev/null +++ b/includes/class-activitypub-admin.php @@ -0,0 +1,54 @@ +add_help_tab( + array( + 'id' => 'overview', + 'title' => __( 'Overview', 'activitypub' ), + 'content' => + '

' . __( 'ActivityPub is a decentralized social networking protocol based on the ActivityStreams 2.0 data format. ActivityPub is an official W3C recommended standard published by the W3C Social Web Working Group. It provides a client to server API for creating, updating and deleting content, as well as a federated server to server API for delivering notifications and subscribing to content.', 'activitypub' ) . '

', + ) + ); + + get_current_screen()->set_help_sidebar( + '

' . __( 'For more information:', 'activitypub' ) . '

' . + '

' . __( 'Test Suite', 'activitypub' ) . '

' . + '

' . __( 'W3C Spec', 'activitypub' ) . '

' . + '

' . __( 'Give us feedback', 'activitypub' ) . '

' . + '
' . + '

' . __( 'Donate', 'activitypub' ) . '

' + ); + } +} diff --git a/includes/class-activitypub-post.php b/includes/class-activitypub-post.php index 43851ed..09f545a 100644 --- a/includes/class-activitypub-post.php +++ b/includes/class-activitypub-post.php @@ -8,47 +8,129 @@ class Activitypub_Post { private $post; public function __construct( $post = null ) { - if ( ! $post ) { - $post = get_post(); - } - - $this->post = $post; + $this->post = get_post( $post ); } - public function to_json_array( $with_context = false ) { + public function get_post() { + return $this->post; + } + + public function get_post_author() { + return $this->post->post_author; + } + + public function to_array() { $post = $this->post; - $json = new stdClass(); + setup_postdata( $post ); - if ( $with_context ) { - $json->{'@context'} = get_activitypub_context(); - } - - $json->published = date( 'Y-m-d\TH:i:s\Z', strtotime( $post->post_date ) ); - $json->id = $post->guid . '&activitypub'; - $json->type = 'Create'; - $json->actor = get_author_posts_url( $post->post_author ); - $json->to = array( 'https://www.w3.org/ns/activitystreams#Public' ); - $json->cc = array( 'https://www.w3.org/ns/activitystreams#Public' ); - - $json->object = array( - 'id' => $post->guid, + $array = array( + 'id' => get_permalink( $post ), 'type' => $this->get_object_type(), 'published' => date( 'Y-m-d\TH:i:s\Z', strtotime( $post->post_date ) ), + 'attributedTo' => get_author_posts_url( $post->post_author ), + 'summary' => apply_filters( 'the_excerpt', get_post_field( 'post_excerpt', $post->ID ) ), + 'inReplyTo' => null, + 'content' => apply_filters( 'the_content', get_post_field( 'post_content', $post->ID ) ), + 'contentMap' => array( + strstr( get_locale(), '_', true ) => apply_filters( 'the_content', get_post_field( 'post_content', $post->ID ) ), + ), 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), 'cc' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'attributedTo' => get_author_posts_url( $post->post_author ), - 'summary' => null, - 'inReplyTo' => null, - 'content' => esc_html( $post->post_content ), - 'contentMap' => array( - strstr( get_locale(), '_', true ) => esc_html( $post->post_content ) , - ), - 'attachment' => array(), - 'tag' => array(), + 'attachment' => $this->get_attachments(), + 'tag' => $this->get_tags(), ); - return apply_filters( 'activitypub_json_post', $json ); + wp_reset_postdata(); + + return apply_filters( 'activitypub_post', $array ); + } + + public function to_json() { + return wp_json_encode( $this->to_array() ); + } + + public function get_attachments() { + $max_images = apply_filters( 'activitypub_max_images', 3 ); + + $images = array(); + + // max images can't be negative or zero + if ( $max_images <= 0 ) { + $max_images = 1; + } + + $id = $this->post->ID; + + $image_ids = array(); + // list post thumbnail first if this post has one + if ( function_exists( 'has_post_thumbnail' ) && has_post_thumbnail( $id ) ) { + $image_ids[] = get_post_thumbnail_id( $id ); + $max_images--; + } + // then list any image attachments + $query = new WP_Query( + array( + 'post_parent' => $id, + 'post_status' => 'inherit', + 'post_type' => 'attachment', + 'post_mime_type' => 'image', + 'order' => 'ASC', + 'orderby' => 'menu_order ID', + 'posts_per_page' => $max_images, + ) + ); + foreach ( $query->get_posts() as $attachment ) { + if ( ! in_array( $attachment->ID, $image_ids ) ) { + $image_ids[] = $attachment->ID; + } + } + // get URLs for each image + foreach ( $image_ids as $id ) { + $thumbnail = wp_get_attachment_image_src( $id, 'full' ); + $mimetype = get_post_mime_type( $id ); + + if ( $thumbnail ) { + $images[] = array( + 'url' => $thumbnail[0], + 'type' => $mimetype + ); + } + } + + $attachments = array(); + + // add attachments + if ( $images ) { + foreach ( $images as $image ) { + $attachment = array( + "type" => "Image", + "url" => $image['url'], + "mediaType" => $image['type'], + ); + $attachments[] = $attachment; + } + } + + return $attachments; + } + + public function get_tags() { + $tags = array(); + + $post_tags = get_the_tags( $this->post->ID ); + if ( $post_tags ) { + foreach( $post_tags as $post_tag ) { + $tag = array( + "type" => "Hashtag", + "href" => get_tag_link( $post_tag->term_id ), + "name" => '#' . $post_tag->name, + ); + $tags[] = $tag; + } + } + + return $tags; } /** diff --git a/includes/class-activitypub-signature.php b/includes/class-activitypub-signature.php index 85cba19..7fc6a79 100644 --- a/includes/class-activitypub-signature.php +++ b/includes/class-activitypub-signature.php @@ -3,37 +3,37 @@ class Activitypub_Signature { /** - * @param int $author_id + * @param int $user_id * * @return mixed */ - public static function get_public_key( $author_id, $force = false ) { - $key = get_user_meta( $author_id, 'magic_sig_public_key' ); + public static function get_public_key( $user_id, $force = false ) { + $key = get_user_meta( $user_id, 'magic_sig_public_key' ); if ( $key && ! $force ) { return $key[0]; } - self::generate_key_pair( $author_id ); - $key = get_user_meta( $author_id, 'magic_sig_public_key' ); + self::generate_key_pair( $user_id ); + $key = get_user_meta( $user_id, 'magic_sig_public_key' ); return $key[0]; } /** - * @param int $author_id + * @param int $user_id * * @return mixed */ - public static function get_private_key( $author_id, $force = false ) { - $key = get_user_meta( $author_id, 'magic_sig_private_key' ); + public static function get_private_key( $user_id, $force = false ) { + $key = get_user_meta( $user_id, 'magic_sig_private_key' ); if ( $key && ! $force ) { return $key[0]; } - self::generate_key_pair( $author_id ); - $key = get_user_meta( $author_id, 'magic_sig_private_key' ); + self::generate_key_pair( $user_id ); + $key = get_user_meta( $user_id, 'magic_sig_private_key' ); return $key[0]; } @@ -41,9 +41,9 @@ class Activitypub_Signature { /** * Generates the pair keys * - * @param int $author_id + * @param int $user_id */ - public static function generate_key_pair( $author_id ) { + public static function generate_key_pair( $user_id ) { $config = array( 'digest_alg' => 'sha512', 'private_key_bits' => 2048, @@ -56,18 +56,18 @@ class Activitypub_Signature { openssl_pkey_export( $key, $priv_key ); // private key - update_user_meta( $author_id, 'magic_sig_private_key', $priv_key ); + update_user_meta( $user_id, 'magic_sig_private_key', $priv_key ); $detail = openssl_pkey_get_details( $key ); // public key - update_user_meta( $author_id, 'magic_sig_public_key', $detail['key'] ); + update_user_meta( $user_id, 'magic_sig_public_key', $detail['key'] ); } - public static function generate_signature( $author_id, $inbox, $date ) { - $key = self::get_private_key( $author_id ); + public static function generate_signature( $user_id, $url, $date ) { + $key = self::get_private_key( $user_id ); - $url_parts = wp_parse_url( $inbox ); + $url_parts = wp_parse_url( $url ); $host = $url_parts['host']; $path = '/'; @@ -86,14 +86,14 @@ class Activitypub_Signature { $signature = null; openssl_sign( $signed_string, $signature, $key, OPENSSL_ALGO_SHA256 ); - $signature = base64_encode( $signature ); + $signature = base64_encode( $signature ); // phpcs:ignore - $key_id = get_author_posts_url( $author_id ) . '#main-key'; + $key_id = get_author_posts_url( $user_id ) . '#main-key'; return sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date",signature="%s"', $key_id, $signature ); } - public static function verify_signature() { + public static function verify_signature( $headers, $signature ) { } } diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 82fc2e4..ebc42ed 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -70,4 +70,13 @@ class Activitypub { public static function add_rewrite_endpoint() { add_rewrite_endpoint( 'as2', EP_AUTHORS | EP_PERMALINK | EP_PAGES ); } + + /** + * Marks the post as "no webmentions sent yet" + * + * @param int $post_id + */ + public static function schedule_post_activity( $post_id ) { + wp_schedule_single_event( time() + wp_rand( 0, 120 ), 'activitypub_send_post_activity', array( $post_id ) ); + } } diff --git a/includes/class-db-activitypub-followers.php b/includes/class-db-activitypub-followers.php index 70fd6d9..7caba32 100644 --- a/includes/class-db-activitypub-followers.php +++ b/includes/class-db-activitypub-followers.php @@ -1,68 +1,9 @@ 100, - 'limit_response_size' => 1048576, - 'redirection' => 3, - 'user-agent' => "$user_agent; ActivityPub", - 'headers' => array( 'accept' => 'application/activity+json' ), - ); - - $response = wp_safe_remote_get( $actor, $args ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $metadata = wp_remote_retrieve_body( $response ); - $metadata = json_decode( $metadata, true ); - - if ( ! $metadata ) { - return new WP_Error( 'activitypub_invalid_json', __( 'No valid JSON data', 'activitypub' ), $actor ); - } - - set_transient( 'activitypub_' . $actor, $metadata, WEEK_IN_SECONDS ); - - return $metadata; + public static function get_followers( $author_id ) { + return get_user_option( 'activitypub_followers', $author_id ); } public static function add_follower( $actor, $author_id ) { diff --git a/includes/class-rest-activitypub-followers.php b/includes/class-rest-activitypub-followers.php index 9aa5f7a..7df2714 100644 --- a/includes/class-rest-activitypub-followers.php +++ b/includes/class-rest-activitypub-followers.php @@ -17,10 +17,10 @@ class Rest_Activitypub_Followers { } public static function get( $request ) { - $author_id = $request->get_param( 'id' ); - $author = get_user_by( 'ID', $author_id ); + $user_id = $request->get_param( 'id' ); + $user = get_user_by( 'ID', $user_id ); - if ( ! $author ) { + if ( ! $user ) { return new WP_Error( 'rest_invalid_param', __( 'User not found', 'activitypub' ), array( 'status' => 404, 'params' => array( 'user_id' => __( 'User not found', 'activitypub' ) @@ -39,7 +39,7 @@ class Rest_Activitypub_Followers { $json->{'@context'} = get_activitypub_context(); - $followers = get_user_option( 'activitypub_followers', $author_id ); + $followers = Db_Activitypub_Followers::get_followers( $user_id ); if ( ! is_array( $followers ) ) { $followers = array(); diff --git a/includes/class-rest-activitypub-inbox.php b/includes/class-rest-activitypub-inbox.php index 48e7580..ba5cfaf 100644 --- a/includes/class-rest-activitypub-inbox.php +++ b/includes/class-rest-activitypub-inbox.php @@ -29,6 +29,44 @@ class Rest_Activitypub_Inbox { ); } + /** + * Hooks into the REST API request to verify the signature. + * + * @param bool $served Whether the request has already been served. + * @param WP_HTTP_ResponseInterface $result Result to send to the client. Usually a WP_REST_Response. + * @param WP_REST_Request $request Request used to generate the response. + * @param WP_REST_Server $server Server instance. + * + * @return true + */ + public static function serve_request( $served, $result, $request, $server ) { + if ( '/activitypub' !== substr( $request->get_route(), 0, 12 ) ) { + return $served; + } + + if ( 'POST' !== $request->get_method() ) { + return $served; + } + + $signature = $request->get_header( 'signature' ); + + if ( ! $signature ) { + return $served; + } + + $headers = $request->get_headers(); + + //Activitypub_Signature::verify_signature( $headers, $key ); + + return $served; + } + + /** + * Renders the user-inbox + * + * @param WP_REST_Request $request + * @return WP_REST_Response + */ public static function user_inbox( $request ) { $author_id = $request->get_param( 'id' ); $author = get_user_by( 'ID', $author_id ); @@ -40,14 +78,14 @@ class Rest_Activitypub_Inbox { $type = strtolower( $data['type'] ); } - do_action( 'activitypub_inbox', $data, $author_id, $type ); - do_action( "activitypub_inbox_{$type}", $data, $author_id ); - if ( ! is_array( $data ) || ! array_key_exists( 'type', $data ) ) { return new WP_Error( 'rest_invalid_data', __( 'Invalid payload', 'activitypub' ), array( 'status' => 422 ) ); } - return new WP_REST_Response( null, 202 ); + do_action( 'activitypub_inbox', $data, $author_id, $type ); + do_action( "activitypub_inbox_{$type}", $data, $author_id ); + + return new WP_REST_Response( array(), 202 ); } /** @@ -81,4 +119,120 @@ class Rest_Activitypub_Inbox { return $params; } + + /** + * Handles "Follow" requests + * + * @param array $object The activity-object + * @param int $user_id The id of the local blog-user + */ + public static function handle_follow( $object, $user_id ) { + if ( ! array_key_exists( 'actor', $object ) ) { + return new WP_Error( 'activitypub_no_actor', __( 'No "Actor" found', 'activitypub' ), $metadata ); + } + + // save follower + Db_Activitypub_Followers::add_follower( $object['actor'], $user_id ); + + // get inbox + $inbox = activitypub_get_inbox_by_actor( $object['actor'] ); + + // send "Accept" activity + $activity = new Activitypub_Activity( 'Accept', Activitypub_Activity::TYPE_SIMPLE ); + $activity->set_object( $object ); + $activity->set_actor( get_author_posts_url( $user_id ) ); + $activity->set_to( $object['actor'] ); + + $activity = $activity->to_simple_json(); + + $response = activitypub_safe_remote_post( $inbox, $activity, $user_id ); + } + + /** + * Handles "Unfollow" requests + * + * @param array $object The activity-object + * @param int $user_id The id of the local blog-user + */ + public static function handle_unfollow( $object, $user_id ) { + if ( ! array_key_exists( 'actor', $object ) ) { + return new WP_Error( 'activitypub_no_actor', __( 'No "Actor" found', 'activitypub' ), $metadata ); + } + + Db_Activitypub_Followers::remove_follower( $object['actor'], $user_id ); + } + + /** + * Handles "Unfollow" requests + * + * @param array $object The activity-object + * @param int $user_id The id of the local blog-user + */ + public static function handle_reaction( $object, $user_id ) { + if ( ! array_key_exists( 'actor', $object ) ) { + return new WP_Error( 'activitypub_no_actor', __( 'No "Actor" found', 'activitypub' ), $metadata ); + } + + $meta = activitypub_get_remote_metadata_by_actor( $object['actor'] ); + + $commentdata = array( + 'comment_post_ID' => url_to_postid( $object['object'] ), + 'comment_author' => esc_attr( $meta['name'] ), + 'comment_author_email' => '', + 'comment_author_url' => esc_url_raw( $object['actor'] ), + 'comment_content' => esc_url_raw( $object['actor'] ), + 'comment_type' => esc_attr( strtolower( $object['type'] ) ), + 'comment_parent' => 0, + 'comment_meta' => array( + 'source_url' => esc_url_raw( $object['id'] ), + 'avatar_url' => esc_url_raw( $meta['icon']['url'] ), + 'protocol' => 'activitypub', + ), + ); + + // disable flood control + remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); + + $state = wp_new_comment( $commentdata, true ); + + // re-add flood control + add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + } + + /** + * Handles "Unfollow" requests + * + * @param array $object The activity-object + * @param int $user_id The id of the local blog-user + */ + public static function handle_create( $object, $user_id ) { + if ( ! array_key_exists( 'actor', $object ) ) { + return new WP_Error( 'activitypub_no_actor', __( 'No "Actor" found', 'activitypub' ), $metadata ); + } + + $meta = activitypub_get_remote_metadata_by_actor( $object['actor'] ); + + $commentdata = array( + 'comment_post_ID' => url_to_postid( $object['object']['inReplyTo'] ), + 'comment_author' => esc_attr( $meta['name'] ), + 'comment_author_url' => esc_url_raw( $object['actor'] ), + 'comment_content' => wp_filter_kses( $object['object']['content'] ), + 'comment_type' => '', + 'comment_author_email' => '', + 'comment_parent' => 0, + 'comment_meta' => array( + 'source_url' => esc_url_raw( $object['object']['url'] ), + 'avatar_url' => esc_url_raw( $meta['icon']['url'] ), + 'protocol' => 'activitypub', + ), + ); + + // disable flood control + remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 ); + + $state = wp_new_comment( $commentdata, true ); + + // re-add flood control + add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + } } diff --git a/includes/class-rest-activitypub-outbox.php b/includes/class-rest-activitypub-outbox.php index 6e7872d..d10cc5f 100644 --- a/includes/class-rest-activitypub-outbox.php +++ b/includes/class-rest-activitypub-outbox.php @@ -5,6 +5,7 @@ * @author Matthias Pfefferle */ class Rest_Activitypub_Outbox { + /** * Register routes */ @@ -13,16 +14,22 @@ class Rest_Activitypub_Outbox { 'activitypub/1.0', '/users/(?P\d+)/outbox', array( array( 'methods' => WP_REST_Server::READABLE, - 'callback' => array( 'Rest_Activitypub_Outbox', 'get' ), + 'callback' => array( 'Rest_Activitypub_Outbox', 'user_outbox' ), 'args' => self::request_parameters(), ), ) ); } - public static function get( $request ) { - $author_id = $request->get_param( 'id' ); - $author = get_user_by( 'ID', $author_id ); + /** + * Renders the user-outbox + * + * @param WP_REST_Request $request + * @return WP_REST_Response + */ + public static function user_outbox( $request ) { + $user_id = $request->get_param( 'id' ); + $author = get_user_by( 'ID', $user_id ); if ( ! $author ) { return new WP_Error( 'rest_invalid_param', __( 'User not found', 'activitypub' ), array( @@ -44,16 +51,16 @@ class Rest_Activitypub_Outbox { $json->{'@context'} = get_activitypub_context(); $json->id = home_url( add_query_arg( NULL, NULL ) ); $json->generator = 'http://wordpress.org/?v=' . get_bloginfo_rss( 'version' ); - $json->actor = get_author_posts_url( $author_id ); + $json->actor = get_author_posts_url( $user_id ); $json->type = 'OrderedCollectionPage'; - $json->partOf = get_rest_url( null, "/activitypub/1.0/users/$author_id/outbox" ); // phpcs:ignore + $json->partOf = get_rest_url( null, "/activitypub/1.0/users/$user_id/outbox" ); // phpcs:ignore $count_posts = wp_count_posts(); $json->totalItems = intval( $count_posts->publish ); $posts = get_posts( array( 'posts_per_page' => 10, - 'author' => $author_id, + 'author' => $user_id, 'offset' => $page * 10, ) ); @@ -66,7 +73,9 @@ class Rest_Activitypub_Outbox { foreach ( $posts as $post ) { $activitypub_post = new Activitypub_Post( $post ); - $json->orderedItems[] = $activitypub_post->to_json_array(); // phpcs:ignore + $activitypub_activity = new Activitypub_Activity( 'Create', Activitypub_Activity::TYPE_NONE ); + $activitypub_activity->from_post( $activitypub_post->to_array() ); + $json->orderedItems[] = $activitypub_activity->to_array(); // phpcs:ignore } // filter output @@ -98,4 +107,21 @@ class Rest_Activitypub_Outbox { return $params; } + + public static function send_post_activity( $post_id ) { + $post = get_post( $post_id ); + $user_id = $post->post_author; + + $activitypub_post = new Activitypub_Post( $post ); + $activitypub_activity = new Activitypub_Activity( 'Create', Activitypub_Activity::TYPE_FULL ); + $activitypub_activity->from_post( $activitypub_post->to_array() ); + + $activity = $activitypub_activity->to_json(); // phpcs:ignore + + $followers = Db_Activitypub_Followers::get_followers( $user_id ); + + foreach ( activitypub_get_follower_inboxes( $user_id, $followers ) as $inbox ) { + $response = activitypub_safe_remote_post( $inbox, $activity, $user_id ); + } + } } diff --git a/includes/functions.php b/includes/functions.php index 3d2d4d7..cf95860 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -39,34 +39,9 @@ function get_activitypub_context() { return apply_filters( 'activitypub_json_context', $context ); } -if ( ! function_exists( 'base64_url_encode' ) ) { - /** - * Encode data - * - * @param string $input input text - * - * @return string the encoded text - */ - function base64_url_encode( $input ) { - return strtr( base64_encode( $input ), '+/', '-_' ); // phpcs:ignore - } -} -if ( ! function_exists( 'base64_url_decode' ) ) { - /** - * Dencode data - * - * @param string $input input text - * - * @return string the decoded text - */ - function base64_url_decode( $input ) { - return base64_decode( strtr( $input, '-_', '+/' ) ); // phpcs:ignore - } -} - -function activitypub_safe_remote_post( $url, $body, $author_id ) { +function activitypub_safe_remote_post( $url, $body, $user_id ) { $date = gmdate( 'D, d M Y H:i:s T' ); - $signature = Activitypub_Signature::generate_signature( $author_id, $url, $date ); + $signature = Activitypub_Signature::generate_signature( $user_id, $url, $date ); $wp_version = get_bloginfo( 'version' ); $user_agent = apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . get_bloginfo( 'url' ) ); @@ -84,5 +59,127 @@ function activitypub_safe_remote_post( $url, $body, $author_id ) { 'body' => $body, ); - $response = wp_safe_remote_post( $url, $args ); + return wp_safe_remote_post( $url, $args ); +} + +/** + * Returns a users WebFinger "resource" + * + * @param int $user_id + * + * @return string The user-resource + */ +function activitypub_get_webfinger_resource( $user_id ) { + // use WebFinger plugin if installed + if ( function_exists( 'get_webfinger_resource' ) ) { + return get_webfinger_resource( $user_id, false ); + } + + $user = get_user_by( 'id', $user_id ); + + return $user->user_login . '@' . wp_parse_url( home_url(), PHP_URL_HOST ); +} + +/** + * [get_metadata_by_actor description] + * + * @param [type] $actor [description] + * @return [type] [description] + */ +function activitypub_get_remote_metadata_by_actor( $actor ) { + $metadata = get_transient( 'activitypub_' . $actor ); + + if ( $metadata ) { + return $metadata; + } + + if ( ! wp_http_validate_url( $actor ) ) { + return new WP_Error( 'activitypub_no_valid_actor_url', __( 'The "actor" is no valid URL', 'activitypub' ), $actor ); + } + + $wp_version = get_bloginfo( 'version' ); + + $user_agent = apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . get_bloginfo( 'url' ) ); + $args = array( + 'timeout' => 100, + 'limit_response_size' => 1048576, + 'redirection' => 3, + 'user-agent' => "$user_agent; ActivityPub", + 'headers' => array( 'accept' => 'application/activity+json' ), + ); + + $response = wp_safe_remote_get( $actor, $args ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $metadata = wp_remote_retrieve_body( $response ); + $metadata = json_decode( $metadata, true ); + + if ( ! $metadata ) { + return new WP_Error( 'activitypub_invalid_json', __( 'No valid JSON data', 'activitypub' ), $actor ); + } + + set_transient( 'activitypub_' . $actor, $metadata, WEEK_IN_SECONDS ); + + return $metadata; +} + +/** + * [get_inbox_by_actor description] + * @param [type] $actor [description] + * @return [type] [description] + */ +function activitypub_get_inbox_by_actor( $actor ) { + $metadata = activitypub_get_remote_metadata_by_actor( $actor ); + + if ( is_wp_error( $metadata ) ) { + return $metadata; + } + + if ( isset( $metadata['endpoints'] ) && isset( $metadata['endpoints']['sharedInbox'] ) ) { + return $metadata['endpoints']['sharedInbox']; + } + + if ( array_key_exists( 'inbox', $metadata ) ) { + return $metadata['inbox']; + } + + return new WP_Error( 'activitypub_no_inbox', __( 'No "Inbox" found', 'activitypub' ), $metadata ); +} + +/** + * [get_inbox_by_actor description] + * @param [type] $actor [description] + * @return [type] [description] + */ +function activitypub_get_publickey_by_actor( $actor, $key_id ) { + $metadata = activitypub_get_remote_metadata_by_actor( $actor ); + + if ( is_wp_error( $metadata ) ) { + return $metadata; + } + + if ( + isset( $metadata['publicKey'] ) && + isset( $metadata['publicKey']['id'] ) && + isset( $metadata['publicKey']['owner'] ) && + isset( $metadata['publicKey']['publicKeyPem'] ) && + $key_id === $metadata['publicKey']['id'] && + $actor === $metadata['publicKey']['owner'] + ) { + return $metadata['publicKey']['publicKeyPem']; + } + + return new WP_Error( 'activitypub_no_public_key', __( 'No "Public-Key" found', 'activitypub' ), $metadata ); +} + +function activitypub_get_follower_inboxes( $user_id, $followers ) { + $inboxes = array(); + foreach ( $followers as $follower ) { + $inboxes[] = activitypub_get_inbox_by_actor( $follower ); + } + + return array_unique( $inboxes ); } diff --git a/languages/activitypub.pot b/languages/activitypub.pot index 133fa1f..c027d48 100644 --- a/languages/activitypub.pot +++ b/languages/activitypub.pot @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: ActivityPub 0.1.0\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/activitypub\n" -"POT-Creation-Date: 2018-12-09 21:09:03+00:00\n" +"POT-Creation-Date: 2018-12-20 10:32:52+00:00\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -13,39 +13,65 @@ msgstr "" "Language-Team: LANGUAGE \n" "X-Generator: grunt-wp-i18n1.0.2\n" -#: includes/class-activitypub-activities.php:13 -#: includes/class-activitypub-activities.php:39 -msgid "No \"Actor\" found" +#: includes/class-activitypub-admin.php:39 +msgid "Overview" msgstr "" -#: includes/class-db-activitypub-actor.php:20 -msgid "No \"Inbox\" found" +#: includes/class-activitypub-admin.php:41 +msgid "" +"ActivityPub is a decentralized social networking protocol based on the " +"ActivityStreams 2.0 data format. ActivityPub is an official W3C recommended " +"standard published by the W3C Social Web Working Group. It provides a " +"client to server API for creating, updating and deleting content, as well " +"as a federated server to server API for delivering notifications and " +"subscribing to content." msgstr "" -#: includes/class-db-activitypub-actor.php:36 -msgid "The \"actor\" is no valid URL" +#: includes/class-activitypub-admin.php:46 +msgid "For more information:" msgstr "" -#: includes/class-db-activitypub-actor.php:60 -msgid "No valid JSON data" +#: includes/class-activitypub-admin.php:47 +msgid "Test Suite" +msgstr "" + +#: includes/class-activitypub-admin.php:48 +msgid "W3C Spec" +msgstr "" + +#: includes/class-activitypub-admin.php:49 +msgid "" +"Give " +"us feedback" +msgstr "" + +#: includes/class-activitypub-admin.php:51 +msgid "Donate" msgstr "" #: includes/class-rest-activitypub-followers.php:24 #: includes/class-rest-activitypub-followers.php:26 -#: includes/class-rest-activitypub-outbox.php:28 -#: includes/class-rest-activitypub-outbox.php:30 +#: includes/class-rest-activitypub-outbox.php:35 +#: includes/class-rest-activitypub-outbox.php:37 #: includes/class-rest-activitypub-webfinger.php:45 msgid "User not found" msgstr "" -#: includes/class-rest-activitypub-inbox.php:47 +#: includes/class-rest-activitypub-inbox.php:82 msgid "Invalid payload" msgstr "" -#: includes/class-rest-activitypub-inbox.php:62 +#: includes/class-rest-activitypub-inbox.php:100 msgid "This method is not yet implemented" msgstr "" +#: includes/class-rest-activitypub-inbox.php:131 +#: includes/class-rest-activitypub-inbox.php:159 +#: includes/class-rest-activitypub-inbox.php:173 +#: includes/class-rest-activitypub-inbox.php:210 +msgid "No \"Actor\" found" +msgstr "" + #: includes/class-rest-activitypub-webfinger.php:32 msgid "Resouce is invalid" msgstr "" @@ -54,11 +80,27 @@ msgstr "" msgid "Resouce host does not match blog host" msgstr "" +#: includes/functions.php:97 +msgid "The \"actor\" is no valid URL" +msgstr "" + +#: includes/functions.php:121 +msgid "No valid JSON data" +msgstr "" + +#: includes/functions.php:149 +msgid "No \"Inbox\" found" +msgstr "" + +#: includes/functions.php:175 +msgid "No \"Public-Key\" found" +msgstr "" + #: templates/json-author.php:48 msgid "Blog" msgstr "" -#: templates/json-author.php:58 +#: templates/json-author.php:58 templates/settings-page.php:9 msgid "Profile" msgstr "" @@ -66,6 +108,51 @@ msgstr "" msgid "Website" msgstr "" +#: templates/settings-page.php:2 +msgid "ActivityPub Settings" +msgstr "" + +#: templates/settings-page.php:4 +msgid "" +"ActivityPub turns your blog into a federated social network. This means you " +"can share and talk to everyone using the ActivityPub protocol, including " +"users of Friendi.ca, Pleroma and Mastodon." +msgstr "" + +#: templates/settings-page.php:11 +msgid "All profile related settings." +msgstr "" + +#: templates/settings-page.php:17 +msgid "Profile identifier" +msgstr "" + +#: templates/settings-page.php:21 +msgid "Try to follow \"@%s\" in the mastodon/friendi.ca search field." +msgstr "" + +#: templates/settings-page.php:29 +msgid "Followers" +msgstr "" + +#: templates/settings-page.php:31 +msgid "All follower related settings." +msgstr "" + +#: templates/settings-page.php:37 +msgid "List of followers" +msgstr "" + +#: templates/settings-page.php:47 +msgid "No followers yet" +msgstr "" + +#: templates/settings-page.php:62 +msgid "" +"If you like this plugin, what about a small donation?" +msgstr "" + #. Plugin Name of the plugin/theme msgid "ActivityPub" msgstr "" diff --git a/readme.txt b/readme.txt index ca29c7a..755a8ef 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: pfefferle Donate link: https://notiz.blog/donate/ Tags: OStatus, fediverse, activitypub, activitystream Requires at least: 4.7 -Tested up to: 5.0 +Tested up to: 5.0.2 Stable tag: 0.1.0 Requires PHP: 5.6 License: MIT @@ -13,9 +13,9 @@ The ActivityPub protocol is a decentralized social networking protocol based upo == Description == -This is **BETA** software, see the FAQ to see what works and what still needs to be implemented or is in planning. +This is **BETA** software, see the FAQ to see the current feature set or rather what is still planned. -This plugin enables ActivityPub for your Blog. Your readers will be able to follow your Blogposts on Mastodon and other Federated Plattforms that support ActivityPub. +This plugin implements the ActivityPub for your Blog. Your readers will be able to follow your Blogposts on Mastodon and other Federated Plattforms that support ActivityPub. == Frequently Asked Questions == @@ -26,30 +26,34 @@ Implemented: * profile pages (JSON representation) * custom links * functional inbox/outbox -* follow (accept follows) +* follow (accept follows) +* share posts +* receive comments/reactions To implement: -* share posts -* share comments +* signature verification +* better WordPress integration +* better configuration possibilities +* threaded comments support = Why does the plugin not support ...? = *ActivityPub* extends WordPress with some fediverse features, but it does not compete with plattforms like Friendi.ca or Mastodon. If you want to have a **decentralized social network**, please use [Mastodon](https://joinmastodon.org/) or [GNU.social](https://gnu.io/social/). -= What are the differences to Pterotype? = += What are the differences between this plugin and Pterotype? = **PHP Version** -*ActivityPub* needs PHP 5.6, *Pterotype* requires 7.2.x +*This plugin* needs PHP 5.6, *Pterotype* requires 7.2.x **Compatibility** -*ActivityPub* is compatible with OStatus and the IndieWeb movement. *Pterotype* implements its own WebFinger endpoint, that is not compatible with the [WebFinger plugin](https://wordpress.org/plugins/webfinger/). +*This plugin* is compatible with OStatus and the IndieWeb movement. *Pterotype* implements for example its own WebFinger endpoint, which is not compatible with the [WebFinger plugin](https://wordpress.org/plugins/webfinger/). **Custom tables** -*Pterotype* creates/uses a bunch of custom tables, *ActivityPub* only uses the native tables and adds as few meta data as possible. +*Pterotype* creates/uses a bunch of custom tables, *this plugin* only uses the native tables and adds as few meta data as possible. == Changelog == @@ -59,7 +63,9 @@ Project maintained on github at [pfefferle/wordpress-activitypub](https://github * added basic WebFinger support * added basic NodeInfo support -* fully functional "follow" activity +* fully functional "follow" activity +* send new posts to your followers +* receive comments from your followers = 0.0.2 = diff --git a/templates/json-post.php b/templates/json-post.php index 1365fba..9be5709 100644 --- a/templates/json-post.php +++ b/templates/json-post.php @@ -2,9 +2,11 @@ $post = get_post(); $activitypub_post = new Activitypub_Post( $post ); +$activitypub_activity = new Activitypub_Activity( 'Create', Activitypub_Activity::TYPE_FULL ); +$activitypub_activity->from_post( $activitypub_post->to_array() ); // filter output -$json = apply_filters( 'activitypub_json_post_array', $activitypub_post->to_json_array( true ) ); +$json = apply_filters( 'activitypub_json_post_array', $activitypub_activity->to_array() ); /* * Action triggerd prior to the ActivityPub profile being created and sent to the client diff --git a/templates/settings-page.php b/templates/settings-page.php new file mode 100644 index 0000000..d7cc110 --- /dev/null +++ b/templates/settings-page.php @@ -0,0 +1,64 @@ +
+

+ +

+ +
+ + +

+ +

+ + + + + + + + +
+ + +

or

+

+
+ + + +

+ +

+ + + + + + + + +
+ + + +
    + +
  • + +
+ +

+ +
+ + + + + + +
+ +

+ donation?', 'activitypub' ); ?> +

+