* added basic WebFinger support
* added basic NodeInfo support
* fully functional "follow" activity
* send new posts to your followers
* receive comments from your followers
This commit is contained in:
Matthias Pfefferle 2018-12-20 11:33:08 +01:00
parent b063220d1c
commit 4e653f5f75
18 changed files with 825 additions and 263 deletions

View file

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

View file

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

View file

@ -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' );

View file

@ -1,64 +0,0 @@
<?php
class Activitypub_Activities {
/**
* [accept description]
* @param [type] $data [description]
* @param [type] $author_id [description]
* @return [type] [description]
*/
public static function accept( $data, $author_id ) {
if ( ! array_key_exists( 'actor', $data ) ) {
return new WP_Error( 'activitypub_no_actor', __( 'No "Actor" found', 'activitypub' ), $metadata );
}
$inbox = Db_Activitypub_Followers::get_inbox_by_actor( $data['actor'] );
$activity = wp_json_encode(
array(
'@context' => 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 ) {
}
}

View file

@ -0,0 +1,84 @@
<?php
/**
* ActivityPub Post Class
*
* @author Matthias Pfefferle
*/
class Activitypub_Activity {
private $context = array( 'https://www.w3.org/ns/activitystreams' );
private $published = '';
private $id = '';
private $type = 'Create';
private $actor = '';
private $to = array( 'https://www.w3.org/ns/activitystreams#Public' );
private $cc = array( 'https://www.w3.org/ns/activitystreams#Public' );
private $object = null;
const TYPE_SIMPLE = 'simple';
const TYPE_FULL = 'full';
const TYPE_NONE = 'none';
public function __construct( $type = 'Create', $context = self::TYPE_SIMPLE ) {
if ( 'none' === $context ) {
$this->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() );
}
}

View file

@ -0,0 +1,54 @@
<?php
/**
* ActivityPub Admin Class
*/
class Activitypub_Admin {
/**
* Add admin menu entry
*/
public static function admin_menu() {
$settings_page = add_options_page(
'ActivityPub',
'ActivityPub',
'manage_options',
'activitypub',
array( 'Activitypub_Admin', 'settings_page' )
);
add_action( 'load-' . $settings_page, array( 'Activitypub_Admin', 'add_help_tab' ) );
}
/**
* Load settings page
*/
public static function settings_page() {
load_template( dirname( __FILE__ ) . '/../templates/settings-page.php' );
}
/**
* Register PubSubHubbub settings
*/
public static function register_settings() {
register_setting( 'activitypub', 'activitypub_feed_use_excerpt' );
}
public static function add_help_tab() {
get_current_screen()->add_help_tab(
array(
'id' => 'overview',
'title' => __( 'Overview', 'activitypub' ),
'content' =>
'<p>' . __( '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' ) . '</p>',
)
);
get_current_screen()->set_help_sidebar(
'<p><strong>' . __( 'For more information:', 'activitypub' ) . '</strong></p>' .
'<p>' . __( '<a href="https://activitypub.rocks/">Test Suite</a>', 'activitypub' ) . '</p>' .
'<p>' . __( '<a href="https://www.w3.org/TR/activitypub/">W3C Spec</a>', 'activitypub' ) . '</p>' .
'<p>' . __( '<a href="https://github.com/pfefferle/wordpress-activitypub/issues">Give us feedback</a>', 'activitypub' ) . '</p>' .
'<hr />' .
'<p>' . __( '<a href="https://notiz.blog/donate">Donate</a>', 'activitypub' ) . '</p>'
);
}
}

View file

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

View file

@ -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 ) {
}
}

View file

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

View file

@ -1,68 +1,9 @@
<?php
class Db_Activitypub_Followers {
/**
* [get_inbox_by_actor description]
* @param [type] $actor [description]
* @return [type] [description]
*/
public static function get_inbox_by_actor( $actor ) {
$metadata = self::get_metadata_by_actor( $actor );
if ( is_wp_error( $metadata ) ) {
return $metadata;
}
if ( array_key_exists( 'inbox', $metadata ) ) {
return $metadata['inbox'];
}
return new WP_Error( 'activitypub_no_inbox', __( 'No "Inbox" found', 'activitypub' ), $metadata );
}
/**
* [get_metadata_by_actor description]
* @param [type] $actor [description]
* @return [type] [description]
*/
public static function get_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;
public static function get_followers( $author_id ) {
return get_user_option( 'activitypub_followers', $author_id );
}
public static function add_follower( $actor, $author_id ) {

View file

@ -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();

View file

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

View file

@ -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<id>\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 );
}
}
}

View file

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

View file

@ -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 <LL@li.org>\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 "<a href=\"https://activitypub.rocks/\">Test Suite</a>"
msgstr ""
#: includes/class-activitypub-admin.php:48
msgid "<a href=\"https://www.w3.org/TR/activitypub/\">W3C Spec</a>"
msgstr ""
#: includes/class-activitypub-admin.php:49
msgid ""
"<a href=\"https://github.com/pfefferle/wordpress-activitypub/issues\">Give "
"us feedback</a>"
msgstr ""
#: includes/class-activitypub-admin.php:51
msgid "<a href=\"https://notiz.blog/donate\">Donate</a>"
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 <a "
"href=\"https://notiz.blog/donate\">donation</a>?"
msgstr ""
#. Plugin Name of the plugin/theme
msgid "ActivityPub"
msgstr ""

View file

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

View file

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

View file

@ -0,0 +1,64 @@
<div class="wrap">
<h1><?php esc_html_e( 'ActivityPub Settings', 'activitypub' ); ?></h1>
<p><?php esc_html_e( '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.', 'activitypub' ); ?></p>
<form method="post" action="options.php">
<?php settings_fields( 'activitypub' ); ?>
<h2><?php esc_html_e( 'Profile', 'activitypub' ); ?></h2>
<p><?php esc_html_e( 'All profile related settings.', 'activitypub' ); ?></p>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<label><?php esc_html_e( 'Profile identifier', 'activitypub' ); ?></label>
</th>
<td>
<p><code><?php echo activitypub_get_webfinger_resource( get_current_user_id() ); ?></code> or <code><?php echo get_author_posts_url( get_current_user_id() ); ?></code></p>
<p class="description"><?php printf( __( 'Try to follow "@%s" in the mastodon/friendi.ca search field.', 'activitypub' ), activitypub_get_webfinger_resource( get_current_user_id() ) ); ?></p>
</td>
</tr>
</tbody>
</table>
<?php do_settings_fields( 'activitypub', 'profile' ); ?>
<h2><?php esc_html_e( 'Followers', 'activitypub' ); ?></h2>
<p><?php esc_html_e( 'All follower related settings.', 'activitypub' ); ?></p>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<label><?php esc_html_e( 'List of followers', 'activitypub' ); ?></label>
</th>
<td>
<?php if ( Db_Activitypub_Followers::get_followers( get_current_user_id() ) ) { ?>
<ul>
<?php foreach( Db_Activitypub_Followers::get_followers( get_current_user_id() ) as $follower ) { ?>
<li><?php echo esc_attr( $follower ); ?></li>
<?php } ?>
</ul>
<?php } else { ?>
<p><?php esc_html_e( 'No followers yet', 'activitypub' ); ?></p>
<?php } ?>
</td>
</tr>
</tbody>
</table>
<?php do_settings_fields( 'activitypub', 'followers' ); ?>
<?php do_settings_sections( 'activitypub' ); ?>
<?php // submit_button(); ?>
</form>
<p>
<small><?php _e( 'If you like this plugin, what about a small <a href="https://notiz.blog/donate">donation</a>?', 'activitypub' ); ?></small>
</p>
</div>