Merge branch 'master' into Comments

This commit is contained in:
Django Doucet 2023-08-25 13:34:49 -06:00
commit 43452e4443
88 changed files with 8367 additions and 2218 deletions

View file

@ -36,3 +36,4 @@ phpunit.xml.dist
tests
node_modules
vendor
src

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,22 @@
<!--- Provide a general summary of your changes in the Title above -->
Fixes #
## Proposed changes:
<!--- Explain what functional changes your PR includes -->
*
### Other information:
- [ ] Have you written new tests for your changes, if applicable?
## Testing instructions:
<!-- If you were reviewing this PR, how would you like the instructions to be presented? -->
<!-- Please include detailed testing steps, explaining how to test your change. -->
<!-- Bear in mind that context you working on is not obvious for everyone. -->
<!-- Adding "simple" configuration steps will help reviewers to get to your PR as quickly as possible. -->
<!-- "Before / After" screenshots can also be very helpful when the change is visual. -->
* Go to '..'
*

View file

@ -15,10 +15,15 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=10s --health-retries=10
strategy:
matrix:
php-versions: ['5.6', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2']
php-versions: ['5.6', '7.0', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2']
include:
- wp-version: latest
- wp-version: '6.2'
php-versions: '5.6'
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
@ -26,10 +31,13 @@ jobs:
coverage: none
tools: composer, phpunit-polyfills
extensions: mysql
- name: Install Composer dependencies for PHP
uses: "ramsey/composer-install@v1"
- name: Setup Test Environment
run: bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1 latest
run: bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1 ${{ matrix.wp-version }}
- name: Unit Testing
run: ./vendor/bin/phpunit
env:

View file

@ -1,9 +1,9 @@
# ActivityPub #
**Contributors:** [pfefferle](https://profiles.wordpress.org/pfefferle/), [mediaformat](https://profiles.wordpress.org/mediaformat/), [akirk](https://profiles.wordpress.org/akirk/), [automattic](https://profiles.wordpress.org/automattic/)
**Contributors:** [automattic](https://profiles.wordpress.org/automattic/), [pfefferle](https://profiles.wordpress.org/pfefferle/), [mediaformat](https://profiles.wordpress.org/mediaformat/), [mattwiebe](https://profiles.wordpress.org/mattwiebe/), [akirk](https://profiles.wordpress.org/akirk/), [jeherve](https://profiles.wordpress.org/jeherve/), [nuriapena](https://profiles.wordpress.org/nuriapena/)
**Tags:** OStatus, fediverse, activitypub, activitystream
**Requires at least:** 4.7
**Tested up to:** 6.1
**Stable tag:** 0.17.0
**Tested up to:** 6.3
**Stable tag:** 1.0.0
**Requires PHP:** 5.6
**License:** MIT
**License URI:** http://opensource.org/licenses/MIT
@ -12,22 +12,59 @@ The ActivityPub protocol is a decentralized social networking protocol based upo
## Description ##
This is **BETA** software, see the FAQ to see the current feature set or rather what is still planned.
This is BETA software, see the FAQ to see the current feature set or rather what is still planned.
The plugin implements the ActivityPub protocol for your blog. Your readers will be able to follow your blogposts on Mastodon and other federated platforms that support ActivityPub.
The plugin implements the ActivityPub protocol for your blog, which means that your readers will be able to follow your blog posts on Mastodon and other federated platforms that support ActivityPub. In addition, replies to your posts on Mastodon and related platforms will automatically become comments on your blog post.
The plugin works with the following federated platforms:
The plugin works with the following tested federated platforms, but there may be more that it works with as well:
* [Mastodon](https://joinmastodon.org/)
* [Pleroma](https://pleroma.social/)
* [Friendica](https://friendi.ca/)
* [HubZilla](https://hubzilla.org/)
* [friendica](https://friendi.ca/)
* [Hubzilla](https://hubzilla.org/)
* [Pixelfed](https://pixelfed.org/)
* [SocialHome](https://socialhome.network/)
* [Socialhome](https://socialhome.network/)
* [Misskey](https://join.misskey.page/)
* [Calckey](https://calckey.org/)
Heres what that means and what you can expect.
Once the ActivityPub plugin is installed, each authors page on your WordPress blog will become its own federated instance. In other words, if you have two authors, Jane and Bob, on your website, `example.com`, then your authors would have their own author pages at `example.com/author/jane` and `example.com/author/bob`. Each of those author pages would now be available to Mastodon users (and all other federated platform users) as a profile that can be followed. Lets break that down further. Lets say you have a friend on Mastodon who tells you to follow them and they give you their profile name `@janelivesheresomeofthetime@mastodon.social`. You search for her name, see her profile, and click the follow button, right? From then on, everything Jane posts on her profile shows up in your Home feed. Okay, similarly, now that Jane has installed the ActivityPub plugin on her `example.com` site, her friends can also follow her on Mastodon by searching for `@jane@example.com` and clicking the Follow button on that profile.
From now on, every blog post Jane publishes on example.com will show up on your Home feed because you follow her `@jane@example.com` profile.
Of course, if no one follows your author instance, then no one will ever see the posts - including you! So the easiest way to even know if the plugin is working is to follow your new profile yourself. If you already have a Mastodon profile, just follow your new one from there.
Some things to note:
1. Many single-author blogs have chosen to turn off or redirect their author profile pages, usually via an SEO plugin like Yoast or Rank Math. This is usually done to avoid duplicate content with your blogs home page. If your author page has been deactivated in this way, then ActivityPub wont work for you. Instead, you can turn your author profile page back on, and then use the option in your SEO plugin to noindex the author page. This will enable the page to be live and ActivityPub will now work, but the live page wont cause any duplicate content issues with search engines.
1. Once ActivityPub is installed, only new posts going forward will be available in the fediverse. Likewise, even if youve been using ActivityPub for a while, anyone who follows your site, will only see new posts you publish from that moment on. They will never see previously-published posts in their Home feed. This process is very similar to subscribing to a newsletter. If you subscribe to a newsletter, you will only receive future emails, but not the old archived ones. With ActivityPub, if someone follows your site, they will only receive new blog posts you publish from then on.
So whats the process?
1. Install the ActivityPub plugin.
1. Go to the plugins settings page and adjust the settings to your liking. Click the Save button when ready.
1. Make sure your blogs author profile page is active.
1. Go to Mastodon or any other federated platform, search for your authors new federated profile, and follow it. Your new profile will be in the form of @yourauthorname@yourwebsite.com, so that is what youll search for.
1. On your blog, publish a new post.
1. From Mastodon, check to see if the new post appears in your Home feed.
Please note that it may take up to 15 minutes or so for the new post to show up in your federated feed. This is because the messages are sent to the federated platforms using a delayed cron. This avoids breaking the publishing process for those cases where users might have lots of followers. So please dont assume that just because you didnt see it show up right away that something is broken. Give it some time. In most cases, it will show up within a few minutes, and youll know everything is working as expected.
## Frequently Asked Questions ##
### tl;dr ###
This plugin connects your WordPress blog to popular social platforms like Mastodon, making your posts more accessible to a wider audience. Once installed, your blog's author pages can be followed by users on these platforms, allowing them to receive your new posts in their feeds.
Here's how it works:
1. Install the plugin and adjust settings as needed.
1. Ensure your blog's author profile page is active.
1. On Mastodon or other supported platforms, search for and follow your author's new profile (e.g., `@yourauthorname@yourwebsite.com`).
1. Publish a new post on your blog and check if it appears in your Mastodon feed.
Please note that it may take up to 15 minutes for a new post to appear in your feed, as messages are sent on a delay to avoid overwhelming your followers. Be patient and give it some time.
### What is the status of this plugin? ###
Implemented:
@ -38,11 +75,10 @@ Implemented:
* follow (accept follows)
* share posts
* receive comments/reactions
* signature verification
To implement:
* signature verification
* better WordPress integration
* better configuration possibilities
* threaded comments support
@ -50,16 +86,6 @@ To implement:
*ActivityPub for WordPress* extends WordPress with some Fediverse features, but it does not compete with platforms like Friendica or Mastodon. If you want to run a **decentralized social network**, please use [Mastodon](https://joinmastodon.org/) or [GNU social](https://gnusocial.network/).
### What are the differences between this plugin and Pterotype? ###
**Compatibility**
*ActivityPub for WordPress* is compatible with OStatus and IndieWeb plugin suites. *Pterotype* is incompatible with the standalone [WebFinger plugin](https://wordpress.org/plugins/webfinger/), so it can't be run together with OStatus.
**Custom tables**
*Pterotype* creates/uses a bunch of custom tables, *ActivityPub for WordPress* only uses the native tables and adds as little meta data as possible.
### What if you are running your blog in a subdirectory? ###
In order for webfinger to work, it must be mapped to the root directory of the URL on which your blog resides.
@ -68,7 +94,7 @@ In order for webfinger to work, it must be mapped to the root directory of the U
Add the following to the .htaccess file in the root directory:
RedirectMatch "^\/\.well-known(.*)$" "\/blog\/\.well-known$1"
RedirectMatch "^\/\.well-known/(webfinger|nodeinfo|x-nodeinfo2)(.*)$" "\/blog\/\.well-known$1$2"
Where 'blog' is the path to the subdirectory at which your blog resides.
@ -85,7 +111,24 @@ Where 'blog' is the path to the subdirectory at which your blog resides.
## Changelog ##
Project maintained on GitHub at [pfefferle/wordpress-activitypub](https://github.com/pfefferle/wordpress-activitypub).
Project maintained on GitHub at [automattic/wordpress-activitypub](https://github.com/automattic/wordpress-activitypub).
### 1.0.0 ###
* Add: blog-wide Account (catchall, like `mydomain.com@mydomain.com`)
* Add: Signature Verification: https://docs.joinmastodon.org/spec/security/ .
* Add: a Followers Block.
* Add: Simple caching
* Add: Collection endpoints for Featured Tags and Featured Posts
* Update: Complete rewrite of the Follower-System based on Custom Post Types.
* Update: Improved linter (PHPCS)
* Compatibility: Add a new conditional, `\Activitypub\is_activitypub_request()`, to allow third-party plugins to detect ActivityPub requests.
* Compatibility: Add hooks to allow modifying images returned in ActivityPub requests.
* Compatibility: Indicate that the plugin is compatible and has been tested with the latest version of WordPress, 6.3.
* Compatibility: Avoid PHP notice on sites using PHP 8.2.
* Fixed: Load the plugin later in the WordPress code lifecycle to avoid errors in some requests.
* Fixed: Updating posts
* Fixed: Hashtag now support CamelCase and UTF-8
### 0.17.0 ###
@ -347,6 +390,12 @@ Project maintained on GitHub at [pfefferle/wordpress-activitypub](https://github
* initial
## Upgrade Notice ##
### 1.0.0 ###
For version 1.0.0 we have completely rebuilt the followers lists. There is a migration from the old format to the new, but it may take some time until the migration is complete. No data will be lost in the process, please give the migration some time.
## Installation ##
Follow the normal instructions for [installing WordPress plugins](https://wordpress.org/support/article/managing-plugins/).

View file

@ -3,9 +3,9 @@
* Plugin Name: ActivityPub
* Plugin URI: https://github.com/pfefferle/wordpress-activitypub/
* Description: The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format.
* Version: 0.17.0
* Author: Matthias Pfefferle
* Author URI: https://notiz.blog/
* Version: 1.0.0
* Author: Matthias Pfefferle & Automattic
* Author URI: https://automattic.com/
* License: MIT
* License URI: http://opensource.org/licenses/MIT
* Requires PHP: 5.6
@ -15,89 +15,105 @@
namespace Activitypub;
\defined( 'ACTIVITYPUB_REST_NAMESPACE' ) || \define( 'ACTIVITYPUB_REST_NAMESPACE', 'activitypub/1.0' );
/**
* Initialize plugin
*/
function init() {
\defined( 'ACTIVITYPUB_EXCERPT_LENGTH' ) || \define( 'ACTIVITYPUB_EXCERPT_LENGTH', 400 );
\defined( 'ACTIVITYPUB_SHOW_PLUGIN_RECOMMENDATIONS' ) || \define( 'ACTIVITYPUB_SHOW_PLUGIN_RECOMMENDATIONS', true );
\defined( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS' ) || \define( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS', 3 );
\defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || \define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|(?<=<p>)|(?<=<br>)|^)#([A-Za-z0-9_]+)(?:(?=\s|[[:punct:]]|$))' );
\defined( 'ACTIVITYPUB_USERNAME_REGEXP' ) || \define( 'ACTIVITYPUB_USERNAME_REGEXP', '(?:([A-Za-z0-9_-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))' );
\defined( 'ACTIVITYPUB_ALLOWED_HTML' ) || \define( 'ACTIVITYPUB_ALLOWED_HTML', '<strong><a><p><ul><ol><li><code><blockquote><pre><img>' );
\defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "<p><strong>[ap_title]</strong></p>\n\n[ap_content]\n\n<p>[ap_hashtags]</p>\n\n<p>[ap_shortlink]</p>" );
\defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "<strong>[ap_title]</strong>\n\n[ap_content]\n\n[ap_hashtags]\n\n[ap_shortlink]" );
\defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) || \define( 'ACTIVITYPUB_AUTHORIZED_FETCH', false );
\define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
\define( 'ACTIVITYPUB_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
\define( 'ACTIVITYPUB_PLUGIN_FILE', plugin_dir_path( __FILE__ ) . '/' . basename( __FILE__ ) );
\define( 'ACTIVITYPUB_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
require_once \dirname( __FILE__ ) . '/includes/table/followers-list.php';
require_once \dirname( __FILE__ ) . '/includes/class-signature.php';
require_once \dirname( __FILE__ ) . '/includes/class-webfinger.php';
require_once \dirname( __FILE__ ) . '/includes/peer/class-followers.php';
require_once \dirname( __FILE__ ) . '/includes/functions.php';
require_once \dirname( __FILE__ ) . '/includes/model/class-activity.php';
require_once \dirname( __FILE__ ) . '/includes/model/class-post.php';
require_once \dirname( __FILE__ ) . '/includes/model/class-comment.php';
require_once \dirname( __FILE__ ) . '/includes/class-activity-dispatcher.php';
\Activitypub\Activity_Dispatcher::init();
require_once \dirname( __FILE__ ) . '/includes/class-activitypub.php';
\Activitypub\Activitypub::init();
require_once \dirname( __FILE__ ) . '/includes/class-comments.php';
\Activitypub\Comments::init();
Migration::init();
Activitypub::init();
Activity_Dispatcher::init();
Collection\Followers::init();
Comments::init();
// Configure the REST API route
require_once \dirname( __FILE__ ) . '/includes/rest/class-outbox.php';
\Activitypub\Rest\Outbox::init();
Rest\Users::init();
Rest\Outbox::init();
Rest\Inbox::init();
Rest\Followers::init();
Rest\Following::init();
Rest\Webfinger::init();
Rest\Server::init();
Rest\Collection::init();
require_once \dirname( __FILE__ ) . '/includes/rest/class-inbox.php';
\Activitypub\Rest\Inbox::init();
require_once \dirname( __FILE__ ) . '/includes/rest/class-followers.php';
\Activitypub\Rest\Followers::init();
require_once \dirname( __FILE__ ) . '/includes/rest/class-following.php';
\Activitypub\Rest\Following::init();
require_once \dirname( __FILE__ ) . '/includes/rest/class-webfinger.php';
\Activitypub\Rest\Webfinger::init();
// load NodeInfo endpoints only if blog is public
if ( true === (bool) \get_option( 'blog_public', 1 ) ) {
require_once \dirname( __FILE__ ) . '/includes/rest/class-nodeinfo.php';
\Activitypub\Rest\NodeInfo::init();
}
require_once \dirname( __FILE__ ) . '/includes/class-admin.php';
\Activitypub\Admin::init();
require_once \dirname( __FILE__ ) . '/includes/class-hashtag.php';
\Activitypub\Hashtag::init();
require_once \dirname( __FILE__ ) . '/includes/class-shortcodes.php';
\Activitypub\Shortcodes::init();
require_once \dirname( __FILE__ ) . '/includes/class-mention.php';
\Activitypub\Mention::init();
require_once \dirname( __FILE__ ) . '/includes/class-debug.php';
\Activitypub\Debug::init();
require_once \dirname( __FILE__ ) . '/includes/class-health-check.php';
\Activitypub\Health_Check::init();
if ( \WP_DEBUG ) {
require_once \dirname( __FILE__ ) . '/includes/debug.php';
}
Admin::init();
Hashtag::init();
Shortcodes::init();
Blocks::init();
Mention::init();
Health_Check::init();
Scheduler::init();
}
\add_action( 'init', __NAMESPACE__ . '\init' );
/**
* Class Autoloader
*/
spl_autoload_register(
function ( $full_class ) {
$base_dir = __DIR__ . '/includes/';
$base = 'Activitypub\\';
if ( strncmp( $full_class, $base, strlen( $base ) ) === 0 ) {
$maybe_uppercase = str_replace( $base, '', $full_class );
$class = strtolower( $maybe_uppercase );
// All classes should be capitalized. If this is instead looking for a lowercase method, we ignore that.
if ( $maybe_uppercase === $class ) {
return;
}
if ( false !== strpos( $class, '\\' ) ) {
$parts = explode( '\\', $class );
$class = array_pop( $parts );
$sub_dir = implode( '/', $parts );
$base_dir = $base_dir . $sub_dir . '/';
}
$filename = 'class-' . strtr( $class, '_', '-' );
$file = $base_dir . $filename . '.php';
if ( file_exists( $file ) && is_readable( $file ) ) {
require_once $file;
} else {
// translators: %s is the class name
\wp_die( sprintf( esc_html__( 'Required class not found or not readable: %s', 'activitypub' ), esc_html( $full_class ) ) );
}
}
}
);
require_once __DIR__ . '/includes/functions.php';
// load NodeInfo endpoints only if blog is public
if ( \get_option( 'blog_public', 1 ) ) {
Rest\NodeInfo::init();
}
$debug_file = __DIR__ . '/includes/debug.php';
if ( \WP_DEBUG && file_exists( $debug_file ) && is_readable( $debug_file ) ) {
require_once $debug_file;
Debug::init();
}
\add_action( 'plugins_loaded', '\Activitypub\init' );
/**
* Add plugin settings link
*/
function plugin_settings_link( $actions ) {
$settings_link = array();
$settings_link[] = \sprintf(
'<a href="%1s">%2s</a>',
\menu_page_url( 'activitypub', false ),
@ -106,53 +122,75 @@ function plugin_settings_link( $actions ) {
return \array_merge( $settings_link, $actions );
}
\add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), '\Activitypub\plugin_settings_link' );
\add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), __NAMESPACE__ . '\plugin_settings_link' );
/**
* Add rewrite rules
*/
function add_rewrite_rules() {
if ( ! \class_exists( 'Webfinger' ) ) {
\add_rewrite_rule( '^.well-known/webfinger', 'index.php?rest_route=/activitypub/1.0/webfinger', 'top' );
}
\register_activation_hook(
__FILE__,
array(
__NAMESPACE__ . '\Activitypub',
'activate',
)
);
if ( ! \class_exists( 'Nodeinfo' ) || ! (bool) \get_option( 'blog_public', 1 ) ) {
\add_rewrite_rule( '^.well-known/nodeinfo', 'index.php?rest_route=/activitypub/1.0/nodeinfo/discovery', 'top' );
\add_rewrite_rule( '^.well-known/x-nodeinfo2', 'index.php?rest_route=/activitypub/1.0/nodeinfo2', 'top' );
}
\register_deactivation_hook(
__FILE__,
array(
__NAMESPACE__ . '\Activitypub',
'deactivate',
)
);
\add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES );
}
\add_action( 'init', '\Activitypub\add_rewrite_rules', 1 );
/**
* Flush rewrite rules;
*/
function flush_rewrite_rules() {
\Activitypub\add_rewrite_rules();
\flush_rewrite_rules();
}
\register_activation_hook( __FILE__, '\Activitypub\flush_rewrite_rules' );
/**
* Activate plugin;
*/
function activate_plugin() {
if ( ! function_exists( 'get_plugin_data' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin_data = \get_plugin_data( __FILE__ );
\add_option( 'activitypub_version', $plugin_data['Version'] );
}
\register_activation_hook( __FILE__, '\Activitypub\activate_plugin' );
\register_deactivation_hook( __FILE__, '\flush_rewrite_rules' );
\register_uninstall_hook(
__FILE__,
array(
__NAMESPACE__ . '\Activitypub',
'uninstall',
)
);
/**
* Only load code that needs BuddyPress to run once BP is loaded and initialized.
*/
function enable_buddypress_features() {
require_once \dirname( __FILE__ ) . '/integration/class-buddypress.php';
\Activitypub\Integration\Buddypress::init();
add_action(
'bp_include',
function() {
require_once __DIR__ . '/integration/class-buddypress.php';
Integration\Buddypress::init();
},
0
);
/**
* `get_plugin_data` wrapper
*
* @return array The plugin metadata array
*/
function get_plugin_meta( $default_headers = array() ) {
if ( ! $default_headers ) {
$default_headers = array(
'Name' => 'Plugin Name',
'PluginURI' => 'Plugin URI',
'Version' => 'Version',
'Description' => 'Description',
'Author' => 'Author',
'AuthorURI' => 'Author URI',
'TextDomain' => 'Text Domain',
'DomainPath' => 'Domain Path',
'Network' => 'Network',
'RequiresWP' => 'Requires at least',
'RequiresPHP' => 'Requires PHP',
'UpdateURI' => 'Update URI',
);
}
return \get_file_data( __FILE__, $default_headers, 'plugin' );
}
/**
* Plugin Version Number used for caching.
*/
function get_plugin_version() {
$meta = get_plugin_meta( array( 'Version' => 'Version' ) );
return $meta['Version'];
}
add_action( 'bp_include', '\Activitypub\enable_buddypress_features' );

View file

@ -1,7 +1,16 @@
.activitypub-settings {
max-width: 800px;
margin: 0 auto;
}
.settings_page_activitypub .notice {
max-width: 800px;
margin: auto;
margin-top: 10px;
margin: 0px auto 30px;
}
.settings_page_activitypub .wrap {
padding-left: 22px;
}
.activitypub-settings-header {
@ -25,10 +34,10 @@
.activitypub-settings-tabs-wrapper {
display: -ms-inline-grid;
-ms-grid-columns: 1fr 1fr;
-ms-grid-columns: auto auto auto;
vertical-align: top;
display: inline-grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: auto auto auto;
}
.activitypub-settings-tab.active {
@ -111,7 +120,8 @@ summary {
flex-grow: 1;
}
.activitypub-settings-accordion-trigger .icon, .activitypub-settings-accordion-viewed .icon {
.activitypub-settings-accordion-trigger .icon,
.activitypub-settings-accordion-viewed .icon {
border: solid #50575e medium;
border-width: 0 2px 2px 0;
height: .5rem;
@ -127,7 +137,8 @@ summary {
transform: translateY(-30%) rotate(-135deg);
}
.activitypub-settings-accordion-trigger:active, .activitypub-settings-accordion-trigger:hover {
.activitypub-settings-accordion-trigger:active,
.activitypub-settings-accordion-trigger:hover {
background: #f6f7f7;
}
@ -139,3 +150,50 @@ summary {
outline: 2px solid #2271b1;
background-color: #f6f7f7;
}
.activitypub-settings
input.blog-user-identifier {
text-align: right;
}
.activitypub-settings
.header-image {
width: 100%;
height: 80px;
position: relative;
display: block;
margin-bottom: 40px;
background-image: rgb(168,165,175);
background-image: linear-gradient(180deg, red, yellow);
background-size: cover;
}
.activitypub-settings
.logo {
height: 80px;
width: 80px;
position: relative;
top: 40px;
left: 40px;
}
.settings_page_activitypub .box {
border: 1px solid #c3c4c7;
background-color: #fff;
padding: 1em 1.5em;
margin-bottom: 1.5em;
}
.settings_page_activitypub .activitypub-welcome-page .box label {
font-weight: bold;
}
.settings_page_activitypub .activitypub-welcome-page input {
font-size: 20px;
width: 95%;
}
.settings_page_activitypub .plugin-recommendations {
border-bottom: none;
margin-bottom: 0;
}

BIN
assets/img/mp.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,57 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "activitypub/followers",
"apiVersion": 3,
"version": "1.0.0",
"title": "Fediverse Followers",
"category": "widgets",
"description": "Display your followers from the Fediverse on your website.",
"textdomain": "activitypub",
"icon": "groups",
"supports": {
"html": false
},
"attributes": {
"title": {
"type": "string",
"default": "Fediverse Followers"
},
"selectedUser": {
"type": "string",
"default": "site"
},
"per_page": {
"type": "number",
"default": 10
},
"order": {
"type": "string",
"default": "desc",
"enum": [
"asc",
"desc"
]
}
},
"styles": [
{
"name": "default",
"label": "No Lines",
"isDefault": true
},
{
"name": "with-lines",
"label": "Lines"
},
{
"name": "compact",
"label": "Compact"
}
],
"editorScript": "file:./index.js",
"viewScript": "file:./view.js",
"style": [
"file:./style-view.css",
"wp-block-query-pagination"
]
}

View file

@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => '284dffd27ea0242085be');

3
build/followers/index.js Normal file
View file

@ -0,0 +1,3 @@
(()=>{var e={184:(e,t)=>{var a;!function(){"use strict";var n={}.hasOwnProperty;function l(){for(var e=[],t=0;t<arguments.length;t++){var a=arguments[t];if(a){var r=typeof a;if("string"===r||"number"===r)e.push(a);else if(Array.isArray(a)){if(a.length){var o=l.apply(null,a);o&&e.push(o)}}else if("object"===r){if(a.toString!==Object.prototype.toString&&!a.toString.toString().includes("[native code]")){e.push(a.toString());continue}for(var i in a)n.call(a,i)&&a[i]&&e.push(i)}}}return e.join(" ")}e.exports?(l.default=l,e.exports=l):void 0===(a=function(){return l}.apply(t,[]))||(e.exports=a)}()}},t={};function a(n){var l=t[n];if(void 0!==l)return l.exports;var r=t[n]={exports:{}};return e[n](r,r.exports,a),r.exports}a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var n in t)a.o(t,n)&&!a.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{"use strict";const e=window.wp.blocks,t=window.wp.element,n=window.wp.primitives,l=(0,t.createElement)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24"},(0,t.createElement)(n.Path,{d:"M15.5 9.5a1 1 0 100-2 1 1 0 000 2zm0 1.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm-2.25 6v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM9.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm1.5 0a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z",fillRule:"evenodd"}));function r(){return r=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var a=arguments[t];for(var n in a)Object.prototype.hasOwnProperty.call(a,n)&&(e[n]=a[n])}return e},r.apply(this,arguments)}const o=window.wp.components,i=window.wp.data,c=window.wp.blockEditor,s=window.wp.i18n,p=window.React,u=window.wp.apiFetch;var v=a.n(u);const m=window.wp.url;var b=a(184),w=a.n(b);function d(e){let{active:a,children:n,page:l,pageClick:r,className:o}=e;const i=w()("wp-block activitypub-pager",o,{current:a});return(0,t.createElement)("a",{className:i,onClick:e=>{e.preventDefault(),!a&&r(l)}},n)}const g={outlined:"outlined",minimal:"minimal"};function f(e){let{compact:a,nextLabel:n,page:l,pageClick:r,perPage:o,prevLabel:i,total:c,variant:s=g.outlined}=e;const p=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,n)=>e>=1&&e<=t&&n.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(l,Math.ceil(c/o)),u=w()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${s}`,{"is-compact":a});return(0,t.createElement)("nav",{className:u},i&&(0,t.createElement)(d,{key:"prev",page:l-1,pageClick:r,active:1===l,"aria-label":i,className:"wp-block-query-pagination-previous block-editor-block-list__block"},i),!a&&(0,t.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},p.map((e=>(0,t.createElement)(d,{key:e,page:e,pageClick:r,active:e===l,className:"page-numbers"},e)))),n&&(0,t.createElement)(d,{key:"next",page:l+1,pageClick:r,active:l===Math.ceil(c/o),"aria-label":n,className:"wp-block-query-pagination-next block-editor-block-list__block"},n))}const{namespace:y}=window._activityPubOptions;function h(e){let{selectedUser:a,per_page:n,order:l,title:o,page:i,setPage:c,className:u="",followLinks:b=!0}=e;const w="site"===a?0:a,[d,g]=(0,p.useState)([]),[h,_]=(0,p.useState)(0),[E,x]=(0,p.useState)(0),[C,S]=function(){const[e,t]=(0,p.useState)(1);return[e,t]}(),O=i||C,N=c||S,P=(0,t.createInterpolateElement)(/* translators: arrow for previous followers link */
(0,s.__)("<span>←</span> Less","activitypub"),{span:(0,t.createElement)("span",{class:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),L=(0,t.createInterpolateElement)(/* translators: arrow for next followers link */
(0,s.__)("More <span>→</span>","activitypub"),{span:(0,t.createElement)("span",{class:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})});return(0,p.useEffect)((()=>{const e=function(e,t,a,n){const l=`/${y}/users/${e}/followers`,r={per_page:t,order:a,page:n,context:"full"};return(0,m.addQueryArgs)(l,r)}(w,n,l,O);v()({path:e}).then((e=>{_(Math.ceil(e.totalItems/n)),x(e.totalItems),g(e.orderedItems)})).catch((e=>console.error(e)))}),[w,n,l,O]),(0,t.createElement)("div",{className:"activitypub-follower-block "+u},(0,t.createElement)("h3",null,o),(0,t.createElement)("ul",null,d&&d.map((e=>(0,t.createElement)("li",{key:e.url},(0,t.createElement)(k,r({},e,{followLinks:b})))))),h>1&&(0,t.createElement)(f,{page:O,perPage:n,total:E,pageClick:N,nextLabel:L,prevLabel:P,compact:"is-style-compact"===u}))}function k(e){let{name:a,icon:n,url:l,preferredUsername:i,followLinks:c=!0}=e;const s=`@${i}`,p={};return c||(p.onClick=e=>e.preventDefault()),(0,t.createElement)(o.ExternalLink,r({className:"activitypub-link",href:l,title:s},p),(0,t.createElement)("img",{width:"40",height:"40",src:n.url,class:"avatar activitypub-avatar"}),(0,t.createElement)("span",{class:"activitypub-actor"},(0,t.createElement)("strong",{className:"activitypub-name"},a),(0,t.createElement)("span",{class:"sep"},"/"),(0,t.createElement)("span",{class:"activitypub-handle"},s)))}const _=window._activityPubOptions?.enabled;(0,e.registerBlockType)("activitypub/followers",{edit:function(e){let{attributes:a,setAttributes:n}=e;const{order:l,per_page:p,selectedUser:u,title:v}=a,m=(0,c.useBlockProps)(),[b,w]=(0,t.useState)(1),d=[{label:(0,s.__)("New to old","activitypub"),value:"desc"},{label:(0,s.__)("Old to new","activitypub"),value:"asc"}],g=function(){const e=_?.users?(0,i.useSelect)((e=>e("core").getUsers({who:"authors"}))):[];return(0,t.useMemo)((()=>{if(!e)return[];const t=_?.site?[{label:(0,s.__)("Whole Site","activitypub"),value:"site"}]:[];return e.reduce(((e,t)=>(e.push({label:t.name,value:t.id}),e)),t)}),[e])}(),f=e=>t=>{w(1),n({[e]:t})};return(0,t.createElement)("div",m,(0,t.createElement)(c.InspectorControls,{key:"setting"},(0,t.createElement)(o.PanelBody,{title:(0,s.__)("Followers Options","activitypub")},(0,t.createElement)(o.TextControl,{label:(0,s.__)("Title","activitypub"),help:(0,s.__)("Title to display above the list of followers. Blank for none.","activitypub"),value:v,onChange:e=>n({title:e})}),(0,t.createElement)(o.SelectControl,{label:(0,s.__)("Select User","activitypub"),value:u,options:g,onChange:f("selectedUser")}),(0,t.createElement)(o.SelectControl,{label:(0,s.__)("Sort","activitypub"),value:l,options:d,onChange:f("order")}),(0,t.createElement)(o.RangeControl,{label:(0,s.__)("Number of Followers","activitypub"),value:p,onChange:f("per_page"),min:1,max:10}))),(0,t.createElement)(h,r({},a,{page:b,setPage:w,followLinks:!1})))},save:()=>null,icon:l})})()})();

View file

@ -0,0 +1 @@
.activitypub-follower-block.is-style-compact .activitypub-handle,.activitypub-follower-block.is-style-compact .sep{display:none}.activitypub-follower-block.is-style-with-lines ul li{border-bottom:.5px solid;margin-bottom:.5rem;padding-bottom:.5rem}.activitypub-follower-block.is-style-with-lines ul li:last-child{border-bottom:none}.activitypub-follower-block.is-style-with-lines .activitypub-handle,.activitypub-follower-block.is-style-with-lines .activitypub-name{text-decoration:none}.activitypub-follower-block.is-style-with-lines .activitypub-handle:hover,.activitypub-follower-block.is-style-with-lines .activitypub-name:hover{text-decoration:underline}.activitypub-follower-block ul{margin:0!important;padding:0!important}.activitypub-follower-block li{display:flex;margin-bottom:1rem}.activitypub-follower-block img{border-radius:50%;height:40px;margin-right:var(--wp--preset--spacing--20,.5rem);width:40px}.activitypub-follower-block .activitypub-link{align-items:center;color:inherit!important;display:flex;flex-flow:row nowrap;max-width:100%;text-decoration:none!important}.activitypub-follower-block .activitypub-handle,.activitypub-follower-block .activitypub-name{text-decoration:underline;text-decoration-thickness:.8px;text-underline-position:under}.activitypub-follower-block .activitypub-handle:hover,.activitypub-follower-block .activitypub-name:hover{text-decoration:none}.activitypub-follower-block .activitypub-name{font-size:var(--wp--preset--font-size--normal,16px)}.activitypub-follower-block .activitypub-actor{font-size:var(--wp--preset--font-size--small,13px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follower-block .sep{padding:0 .2rem}.activitypub-follower-block .wp-block-query-pagination{margin-top:1.5rem}.activitypub-follower-block .activitypub-pager{cursor:default}.activitypub-follower-block .activitypub-pager.current{opacity:.33}.activitypub-follower-block .page-numbers{padding:0 .2rem}.activitypub-follower-block .page-numbers.current{font-weight:700;opacity:1}

View file

@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => 'd645fd4aa610b479e8f4');

3
build/followers/view.js Normal file
View file

@ -0,0 +1,3 @@
(()=>{var e,t={189:(e,t,a)=>{"use strict";const r=window.wp.element;function n(){return n=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var a=arguments[t];for(var r in a)Object.prototype.hasOwnProperty.call(a,r)&&(e[r]=a[r])}return e},n.apply(this,arguments)}const l=window.React,o=window.wp.apiFetch;var i=a.n(o);const c=window.wp.url,s=window.wp.i18n;var p=a(184),u=a.n(p);function m(e){let{active:t,children:a,page:n,pageClick:l,className:o}=e;const i=u()("wp-block activitypub-pager",o,{current:t});return(0,r.createElement)("a",{className:i,onClick:e=>{e.preventDefault(),!t&&l(n)}},a)}const v={outlined:"outlined",minimal:"minimal"};function b(e){let{compact:t,nextLabel:a,page:n,pageClick:l,perPage:o,prevLabel:i,total:c,variant:s=v.outlined}=e;const p=((e,t)=>{let a=[1,e-2,e-1,e,e+1,e+2,t];a.sort(((e,t)=>e-t)),a=a.filter(((e,a,r)=>e>=1&&e<=t&&r.lastIndexOf(e)===a));for(let e=a.length-2;e>=0;e--)a[e]===a[e+1]&&a.splice(e+1,1);return a})(n,Math.ceil(c/o)),b=u()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${s}`,{"is-compact":t});return(0,r.createElement)("nav",{className:b},i&&(0,r.createElement)(m,{key:"prev",page:n-1,pageClick:l,active:1===n,"aria-label":i,className:"wp-block-query-pagination-previous block-editor-block-list__block"},i),!t&&(0,r.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},p.map((e=>(0,r.createElement)(m,{key:e,page:e,pageClick:l,active:e===n,className:"page-numbers"},e)))),a&&(0,r.createElement)(m,{key:"next",page:n+1,pageClick:l,active:n===Math.ceil(c/o),"aria-label":a,className:"wp-block-query-pagination-next block-editor-block-list__block"},a))}const f=window.wp.components,{namespace:d}=window._activityPubOptions;function w(e){let{selectedUser:t,per_page:a,order:o,title:p,page:u,setPage:m,className:v="",followLinks:f=!0}=e;const w="site"===t?0:t,[y,k]=(0,l.useState)([]),[h,E]=(0,l.useState)(0),[O,x]=(0,l.useState)(0),[_,N]=function(){const[e,t]=(0,l.useState)(1);return[e,t]}(),j=u||_,S=m||N,C=(0,r.createInterpolateElement)(/* translators: arrow for previous followers link */
(0,s.__)("<span>←</span> Less","activitypub"),{span:(0,r.createElement)("span",{class:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),L=(0,r.createInterpolateElement)(/* translators: arrow for next followers link */
(0,s.__)("More <span>→</span>","activitypub"),{span:(0,r.createElement)("span",{class:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})});return(0,l.useEffect)((()=>{const e=function(e,t,a,r){const n=`/${d}/users/${e}/followers`,l={per_page:t,order:a,page:r,context:"full"};return(0,c.addQueryArgs)(n,l)}(w,a,o,j);i()({path:e}).then((e=>{E(Math.ceil(e.totalItems/a)),x(e.totalItems),k(e.orderedItems)})).catch((e=>console.error(e)))}),[w,a,o,j]),(0,r.createElement)("div",{className:"activitypub-follower-block "+v},(0,r.createElement)("h3",null,p),(0,r.createElement)("ul",null,y&&y.map((e=>(0,r.createElement)("li",{key:e.url},(0,r.createElement)(g,n({},e,{followLinks:f})))))),h>1&&(0,r.createElement)(b,{page:j,perPage:a,total:O,pageClick:S,nextLabel:L,prevLabel:C,compact:"is-style-compact"===v}))}function g(e){let{name:t,icon:a,url:l,preferredUsername:o,followLinks:i=!0}=e;const c=`@${o}`,s={};return i||(s.onClick=e=>e.preventDefault()),(0,r.createElement)(f.ExternalLink,n({className:"activitypub-link",href:l,title:c},s),(0,r.createElement)("img",{width:"40",height:"40",src:a.url,class:"avatar activitypub-avatar"}),(0,r.createElement)("span",{class:"activitypub-actor"},(0,r.createElement)("strong",{className:"activitypub-name"},t),(0,r.createElement)("span",{class:"sep"},"/"),(0,r.createElement)("span",{class:"activitypub-handle"},c)))}const y=window.wp.domReady;a.n(y)()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-follower-block"),(e=>{const t=JSON.parse(e.dataset.attrs);(0,r.render)((0,r.createElement)(w,t),e)}))}))},184:(e,t)=>{var a;!function(){"use strict";var r={}.hasOwnProperty;function n(){for(var e=[],t=0;t<arguments.length;t++){var a=arguments[t];if(a){var l=typeof a;if("string"===l||"number"===l)e.push(a);else if(Array.isArray(a)){if(a.length){var o=n.apply(null,a);o&&e.push(o)}}else if("object"===l){if(a.toString!==Object.prototype.toString&&!a.toString.toString().includes("[native code]")){e.push(a.toString());continue}for(var i in a)r.call(a,i)&&a[i]&&e.push(i)}}}return e.join(" ")}e.exports?(n.default=n,e.exports=n):void 0===(a=function(){return n}.apply(t,[]))||(e.exports=a)}()}},a={};function r(e){var n=a[e];if(void 0!==n)return n.exports;var l=a[e]={exports:{}};return t[e](l,l.exports,r),l.exports}r.m=t,e=[],r.O=(t,a,n,l)=>{if(!a){var o=1/0;for(p=0;p<e.length;p++){for(var[a,n,l]=e[p],i=!0,c=0;c<a.length;c++)(!1&l||o>=l)&&Object.keys(r.O).every((e=>r.O[e](a[c])))?a.splice(c--,1):(i=!1,l<o&&(o=l));if(i){e.splice(p--,1);var s=n();void 0!==s&&(t=s)}}return t}l=l||0;for(var p=e.length;p>0&&e[p-1][2]>l;p--)e[p]=e[p-1];e[p]=[a,n,l]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var a in t)r.o(t,a)&&!r.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={638:0,962:0};r.O.j=t=>0===e[t];var t=(t,a)=>{var n,l,[o,i,c]=a,s=0;if(o.some((t=>0!==e[t]))){for(n in i)r.o(i,n)&&(r.m[n]=i[n]);if(c)var p=c(r)}for(t&&t(a);s<o.length;s++)l=o[s],r.o(e,l)&&e[l]&&e[l][0](),e[l]=0;return r.O(p)},a=globalThis.webpackChunkwordpress_activitypub=globalThis.webpackChunkwordpress_activitypub||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))})();var n=r.O(void 0,[962],(()=>r(189)));n=r.O(n)})();

View file

@ -11,9 +11,11 @@
"phpcompatibility/php-compatibility": "*",
"phpcompatibility/phpcompatibility-wp": "*",
"squizlabs/php_codesniffer": "3.*",
"wp-coding-standards/wpcs": "*",
"yoast/phpunit-polyfills": "^1.0",
"dealerdirect/phpcodesniffer-composer-installer": "^0.7.1"
"wp-coding-standards/wpcs": "dev-develop",
"yoast/phpunit-polyfills": "^2.0",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0.0",
"sirbrillig/phpcs-variable-analysis": "^2.11",
"phpcsstandards/phpcsextra": "^1.1.0"
},
"config": {
"allow-plugins": true
@ -36,6 +38,9 @@
"composer install",
"bin/install-wp-tests.sh activitypub-test root activitypub-test test-db latest true",
"vendor/bin/phpunit"
],
"lint": [
"vendor/bin/phpcs -n -q"
]
}
}

View file

@ -6,11 +6,19 @@ services:
environment:
MYSQL_DATABASE: activitypub-test
MYSQL_ROOT_PASSWORD: activitypub-test
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3306"]
interval: 5s
timeout: 2s
retries: 5
test-php:
build:
context: .
dockerfile: Dockerfile
depends_on:
test-db:
condition: service_healthy
links:
- test-db
volumes:

View file

@ -0,0 +1,210 @@
<?php
/**
* Inspired by the PHP ActivityPub Library by @Landrok
*
* @link https://github.com/landrok/activitypub
*/
namespace Activitypub\Activity;
use Activitypub\Activity\Base_Object;
/**
* \Activitypub\Activity\Activity implements the common
* attributes of an Activity.
*
* @see https://www.w3.org/TR/activitystreams-core/#activities
* @see https://www.w3.org/TR/activitystreams-core/#intransitiveactivities
*/
class Activity extends Base_Object {
const CONTEXT = array(
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
array(
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
'PropertyValue' => 'schema:PropertyValue',
'schema' => 'http://schema.org#',
'pt' => 'https://joinpeertube.org/ns#',
'toot' => 'http://joinmastodon.org/ns#',
'litepub' => 'http://litepub.social/ns#',
'value' => 'schema:value',
'Hashtag' => 'as:Hashtag',
'featured' => array(
'@id' => 'toot:featured',
'@type' => '@id',
),
'featuredTags' => array(
'@id' => 'toot:featuredTags',
'@type' => '@id',
),
'discoverable' => 'toot:discoverable',
'sensitive' => 'as:sensitive',
),
);
/**
* The object's unique global identifier
*
* @see https://www.w3.org/TR/activitypub/#obj-id
*
* @var string
*/
protected $id;
/**
* @var string
*/
protected $type = 'Activity';
/**
* The context within which the object exists or an activity was
* performed.
* The notion of "context" used is intentionally vague.
* The intended function is to serve as a means of grouping objects
* and activities that share a common originating context or
* purpose. An example could be all activities relating to a common
* project or event.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context
*
* @var string
* | ObjectType
* | Link
* | null
*/
protected $context = self::CONTEXT;
/**
* Describes the direct object of the activity.
* For instance, in the activity "John added a movie to his
* wishlist", the object of the activity is the movie added.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object-term
*
* @var string
* | Base_Objectr
* | Link
* | null
*/
protected $object;
/**
* Describes one or more entities that either performed or are
* expected to perform the activity.
* Any single activity can have multiple actors.
* The actor MAY be specified using an indirect Link.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-actor
*
* @var string
* | \ActivityPhp\Type\Extended\AbstractActor
* | array<Actor>
* | array<Link>
* | Link
*/
protected $actor;
/**
* The indirect object, or target, of the activity.
* The precise meaning of the target is largely dependent on the
* type of action being described but will often be the object of
* the English preposition "to".
* For instance, in the activity "John added a movie to his
* wishlist", the target of the activity is John's wishlist.
* An activity can have more than one target.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-target
*
* @var string
* | ObjectType
* | array<ObjectType>
* | Link
* | array<Link>
*/
protected $target;
/**
* Describes the result of the activity.
* For instance, if a particular action results in the creation of
* a new resource, the result property can be used to describe
* that new resource.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-result
*
* @var string
* | ObjectType
* | Link
* | null
*/
protected $result;
/**
* An indirect object of the activity from which the
* activity is directed.
* The precise meaning of the origin is the object of the English
* preposition "from".
* For instance, in the activity "John moved an item to List B
* from List A", the origin of the activity is "List A".
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-origin
*
* @var string
* | ObjectType
* | Link
* | null
*/
protected $origin;
/**
* One or more objects used (or to be used) in the completion of an
* Activity.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-instrument
*
* @var string
* | ObjectType
* | Link
* | null
*/
protected $instrument;
/**
* Set the object and copy Object properties to the Activity.
*
* Any to, bto, cc, bcc, and audience properties specified on the object
* MUST be copied over to the new Create activity by the server.
*
* @see https://www.w3.org/TR/activitypub/#object-without-create
*
* @param string|Base_Objectr|Link|null $object
*
* @return void
*/
public function set_object( $object ) {
$this->set( 'object', $object );
if ( ! is_object( $object ) ) {
return;
}
foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
$this->set( $i, $object->get( $i ) );
}
if ( $object->get_published() && ! $this->get_published() ) {
$this->set( 'published', $object->get_published() );
}
if ( $object->get_updated() && ! $this->get_updated() ) {
$this->set( 'updated', $object->get_updated() );
}
if ( $object->get_attributed_to() && ! $this->get_actor() ) {
$this->set( 'actor', $object->get_attributed_to() );
}
if ( $object->get_id() && ! $this->get_id() ) {
$this->set( 'id', $object->get_id() . '#activity' );
}
}
}

View file

@ -0,0 +1,139 @@
<?php
/**
* Inspired by the PHP ActivityPub Library by @Landrok
*
* @link https://github.com/landrok/activitypub
*/
namespace Activitypub\Activity;
/**
* \Activitypub\Activity\Actor is an implementation of
* one an Activity Streams Actor.
*
* Represents an individual actor.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
*/
class Actor extends Base_Object {
/**
* @var string
*/
protected $type = 'Person';
/**
* A reference to an ActivityStreams OrderedCollection comprised of
* all the messages received by the actor.
*
* @see https://www.w3.org/TR/activitypub/#inbox
*
* @var string
* | null
*/
protected $inbox;
/**
* A reference to an ActivityStreams OrderedCollection comprised of
* all the messages produced by the actor.
*
* @see https://www.w3.org/TR/activitypub/#outbox
*
* @var string
* | null
*/
protected $outbox;
/**
* A link to an ActivityStreams collection of the actors that this
* actor is following.
*
* @see https://www.w3.org/TR/activitypub/#following
*
* @var string
*/
protected $following;
/**
* A link to an ActivityStreams collection of the actors that
* follow this actor.
*
* @see https://www.w3.org/TR/activitypub/#followers
*
* @var string
*/
protected $followers;
/**
* A link to an ActivityStreams collection of objects this actor has
* liked.
*
* @see https://www.w3.org/TR/activitypub/#liked
*
* @var string
*/
protected $liked;
/**
* A list of supplementary Collections which may be of interest.
*
* @see https://www.w3.org/TR/activitypub/#streams-property
*
* @var array
*/
protected $streams = array();
/**
* A short username which may be used to refer to the actor, with no
* uniqueness guarantees.
*
* @see https://www.w3.org/TR/activitypub/#preferredUsername
*
* @var string|null
*/
protected $preferred_username;
/**
* A JSON object which maps additional typically server/domain-wide
* endpoints which may be useful either for this actor or someone
* referencing this actor. This mapping may be nested inside the
* actor document as the value or may be a link to a JSON-LD
* document with these properties.
*
* @see https://www.w3.org/TR/activitypub/#endpoints
*
* @var string|array|null
*/
protected $endpoints;
/**
* It's not part of the ActivityPub protocol but it's a quite common
* practice to handle an actor public key with a publicKey array:
* [
* 'id' => 'https://my-example.com/actor#main-key'
* 'owner' => 'https://my-example.com/actor',
* 'publicKeyPem' => '-----BEGIN PUBLIC KEY-----
* MIIBI [...]
* DQIDAQAB
* -----END PUBLIC KEY-----'
* ]
*
* @see https://www.w3.org/wiki/SocialCG/ActivityPub/Authentication_Authorization#Signing_requests_using_HTTP_Signatures
*
* @var string|array|null
*/
protected $public_key;
/**
* It's not part of the ActivityPub protocol but it's a quite common
* practice to lock an account. If anabled, new followers will not be
* automatically accepted, but will instead require you to manually
* approve them.
*
* WordPress does only support 'false' at the moment.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#as
*
* @var boolean
*/
protected $manually_approves_followers = false;
}

View file

@ -0,0 +1,670 @@
<?php
/**
* Inspired by the PHP ActivityPub Library by @Landrok
*
* @link https://github.com/landrok/activitypub
*/
namespace Activitypub\Activity;
use WP_Error;
use ReflectionClass;
use function Activitypub\camel_to_snake_case;
use function Activitypub\snake_to_camel_case;
/**
* Base_Object is an implementation of one of the
* Activity Streams Core Types.
*
* The Object is the primary base type for the Activity Streams
* vocabulary.
*
* Note: Object is a reserved keyword in PHP. It has been suffixed with
* 'Base_' for this reason.
*
* @see https://www.w3.org/TR/activitystreams-core/#object
*/
class Base_Object {
/**
* The object's unique global identifier
*
* @see https://www.w3.org/TR/activitypub/#obj-id
*
* @var string
*/
protected $id;
/**
* @var string
*/
protected $type = 'Object';
/**
* A resource attached or related to an object that potentially
* requires special handling.
* The intent is to provide a model that is at least semantically
* similar to attachments in email.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-attachment
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $attachment;
/**
* One or more entities to which this object is attributed.
* The attributed entities might not be Actors. For instance, an
* object might be attributed to the completion of another activity.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-attributedto
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $attributed_to;
/**
* One or more entities that represent the total population of
* entities for which the object can considered to be relevant.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audience
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $audience;
/**
* The content or textual representation of the Object encoded as a
* JSON string. By default, the value of content is HTML.
* The mediaType property can be used in the object to indicate a
* different content type.
*
* The content MAY be expressed using multiple language-tagged
* values.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-content
*
* @var string|null
*/
protected $content;
/**
* The context within which the object exists or an activity was
* performed.
* The notion of "context" used is intentionally vague.
* The intended function is to serve as a means of grouping objects
* and activities that share a common originating context or
* purpose. An example could be all activities relating to a common
* project or event.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context
*
* @var string
* | ObjectType
* | Link
* | null
*/
protected $context;
/**
* The content MAY be expressed using multiple language-tagged
* values.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-content
*
* @var array|null
*/
protected $content_map;
/**
* A simple, human-readable, plain-text name for the object.
* HTML markup MUST NOT be included.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-name
*
* @var string|null xsd:string
*/
protected $name;
/**
* The name MAY be expressed using multiple language-tagged values.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-name
*
* @var array|null rdf:langString
*/
protected $name_map;
/**
* The date and time describing the actual or expected ending time
* of the object.
* When used with an Activity object, for instance, the endTime
* property specifies the moment the activity concluded or
* is expected to conclude.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-endtime
*
* @var string|null
*/
protected $end_time;
/**
* The entity (e.g. an application) that generated the object.
*
* @var string|null
*/
protected $generator;
/**
* An entity that describes an icon for this object.
* The image should have an aspect ratio of one (horizontal)
* to one (vertical) and should be suitable for presentation
* at a small size.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
*
* @var string
* | Image
* | Link
* | array<Image>
* | array<Link>
* | null
*/
protected $icon;
/**
* An entity that describes an image for this object.
* Unlike the icon property, there are no aspect ratio
* or display size limitations assumed.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image-term
*
* @var string
* | Image
* | Link
* | array<Image>
* | array<Link>
* | null
*/
protected $image;
/**
* One or more entities for which this object is considered a
* response.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $in_reply_to;
/**
* One or more physical or logical locations associated with the
* object.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-location
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $location;
/**
* An entity that provides a preview of this object.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-preview
*
* @var string
* | ObjectType
* | Link
* | null
*/
protected $preview;
/**
* The date and time at which the object was published
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-published
*
* @var string|null xsd:dateTime
*/
protected $published;
/**
* A Collection containing objects considered to be responses to
* this object.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies
*
* @var string
* | Collection
* | Link
* | null
*/
protected $replies;
/**
* The date and time describing the actual or expected starting time
* of the object.
* When used with an Activity object, for instance, the startTime
* property specifies the moment the activity began
* or is scheduled to begin.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-starttime
*
* @var string|null xsd:dateTime
*/
protected $start_time;
/**
* A natural language summarization of the object encoded as HTML.
* Multiple language tagged summaries MAY be provided.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary
*
* @var string
* | ObjectType
* | Link
* | null
*/
protected $summary;
/**
* The content MAY be expressed using multiple language-tagged
* values.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary
*
* @var array<string>|null
*/
protected $summary_map;
/**
* One or more "tags" that have been associated with an objects.
* A tag can be any kind of Object.
* The key difference between attachment and tag is that the former
* implies association by inclusion, while the latter implies
* associated by reference.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $tag;
/**
* The date and time at which the object was updated
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-updated
*
* @var string|null xsd:dateTime
*/
protected $updated;
/**
* One or more links to representations of the object.
*
* @var string
* | array<string>
* | Link
* | array<Link>
* | null
*/
protected $url;
/**
* An entity considered to be part of the public primary audience
* of an Object
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $to;
/**
* An Object that is part of the private primary audience of this
* Object.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-bto
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $bto;
/**
* An Object that is part of the public secondary audience of this
* Object.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-cc
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $cc;
/**
* One or more Objects that are part of the private secondary
* audience of this Object.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-bcc
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
*/
protected $bcc;
/**
* The MIME media type of the value of the content property.
* If not specified, the content property is assumed to contain
* text/html content.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mediatype
*
* @var string|null
*/
protected $media_type;
/**
* When the object describes a time-bound resource, such as an audio
* or video, a meeting, etc, the duration property indicates the
* object's approximate duration.
* The value MUST be expressed as an xsd:duration as defined by
* xmlschema11-2, section 3.3.6 (e.g. a period of 5 seconds is
* represented as "PT5S").
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
*
* @var string|null
*/
protected $duration;
/**
* Intended to convey some sort of source from which the content
* markup was derived, as a form of provenance, or to support
* future editing by clients.
*
* @see https://www.w3.org/TR/activitypub/#source-property
*
* @var ObjectType
*/
protected $source;
/**
* Magic function to implement getter and setter
*
* @param string $method The method name.
* @param string $params The method params.
*
* @return void
*/
public function __call( $method, $params ) {
$var = \strtolower( \substr( $method, 4 ) );
if ( \strncasecmp( $method, 'get', 3 ) === 0 ) {
if ( ! $this->has( $var ) ) {
return new WP_Error( 'invalid_key', 'Invalid key' );
}
return $this->$var;
}
if ( \strncasecmp( $method, 'set', 3 ) === 0 ) {
$this->set( $var, $params[0] );
}
if ( \strncasecmp( $method, 'add', 3 ) === 0 ) {
$this->add( $var, $params[0] );
}
}
/**
* Magic function, to transform the object to string.
*
* @return string The object id.
*/
public function __toString() {
return $this->to_string();
}
/**
* Function to transform the object to string.
*
* @return string The object id.
*/
public function to_string() {
return $this->get_id();
}
/**
* Generic getter.
*
* @param string $key The key to get.
*
* @return mixed The value.
*/
public function get( $key ) {
if ( ! $this->has( $key ) ) {
return new WP_Error( 'invalid_key', 'Invalid key' );
}
return call_user_func( array( $this, 'get_' . $key ) );
}
/**
* Check if the object has a key
*
* @param string $key The key to check.
*
* @return boolean True if the object has the key.
*/
public function has( $key ) {
return property_exists( $this, $key );
}
/**
* Generic setter.
*
* @param string $key The key to set.
* @param string $value The value to set.
*
* @return mixed The value.
*/
public function set( $key, $value ) {
if ( ! $this->has( $key ) ) {
return new WP_Error( 'invalid_key', 'Invalid key' );
}
$this->$key = $value;
return $this->$key;
}
/**
* Generic adder.
*
* @param string $key The key to set.
* @param mixed $value The value to add.
*
* @return mixed The value.
*/
public function add( $key, $value ) {
if ( ! $this->has( $key ) ) {
return new WP_Error( 'invalid_key', 'Invalid key' );
}
if ( ! isset( $this->$key ) ) {
$this->$key = array();
}
$attributes = $this->$key;
$attributes[] = $value;
$this->$key = $attributes;
return $this->$key;
}
/**
* Convert JSON input to an array.
*
* @return string The JSON string.
*
* @return \Activitypub\Activity\Base_Object An Object built from the JSON string.
*/
public static function init_from_json( $json ) {
$array = \json_decode( $json, true );
return self::init_from_array( $array );
}
/**
* Convert JSON input to an array.
*
* @return string The object array.
*
* @return \Activitypub\Activity\Base_Object An Object built from the JSON string.
*/
public static function init_from_array( $array ) {
$object = new static();
foreach ( $array as $key => $value ) {
$key = camel_to_snake_case( $key );
$object->set( $key, $value );
}
return $object;
}
/**
* Convert JSON input to an array and pre-fill the object.
*
* @param string $json The JSON string.
*/
public function from_json( $json ) {
$array = \json_decode( $json, true );
$this->from_array( $array );
}
/**
* Convert JSON input to an array and pre-fill the object.
*
* @param array $array The array.
*/
public function from_array( $array ) {
foreach ( $array as $key => $value ) {
if ( $value ) {
$key = camel_to_snake_case( $key );
$this->set( $key, $value );
}
}
}
/**
* Convert Object to an array.
*
* It tries to get the object attributes if they exist
* and falls back to the getters. Empty values are ignored.
*
* @return array An array built from the Object.
*/
public function to_array() {
$array = array();
$vars = get_object_vars( $this );
foreach ( $vars as $key => $value ) {
// ignotre all _prefixed keys.
if ( '_' === substr( $key, 0, 1 ) ) {
continue;
}
// if value is empty, try to get it from a getter.
if ( ! $value ) {
$value = call_user_func( array( $this, 'get_' . $key ) );
}
if ( is_object( $value ) ) {
$value = $value->to_array();
}
// if value is still empty, ignore it for the array and continue.
if ( $value ) {
$array[ snake_to_camel_case( $key ) ] = $value;
}
}
// replace 'context' key with '@context' and move it to the top.
if ( array_key_exists( 'context', $array ) ) {
$context = $array['context'];
unset( $array['context'] );
$array = array_merge( array( '@context' => $context ), $array );
}
$class = new ReflectionClass( $this );
$class = strtolower( $class->getShortName() );
$array = \apply_filters( 'activitypub_activity_object_array', $array, $class, $this->id, $this );
$array = \apply_filters( "activitypub_activity_{$class}_object_array", $array, $this->id, $this );
return $array;
}
/**
* Convert Object to JSON.
*
* @return string The JSON string.
*/
public function to_json() {
$array = $this->to_array();
return \wp_json_encode( $array, \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT );
}
}

View file

@ -1,6 +1,16 @@
<?php
namespace Activitypub;
use WP_Post;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users;
use Activitypub\Collection\Followers;
use Activitypub\Transformer\Post;
use function Activitypub\is_single_user;
use function Activitypub\is_user_disabled;
use function Activitypub\safe_remote_post;
/**
* ActivityPub Activity_Dispatcher Class
*
@ -13,127 +23,103 @@ class Activity_Dispatcher {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_send_post_activity', array( '\Activitypub\Activity_Dispatcher', 'send_post_activity' ) );
\add_action( 'activitypub_send_announce_activity', array( '\Activitypub\Activity_Dispatcher', 'send_announce_activity' ), 10, 2 );
\add_action( 'activitypub_send_update_activity', array( '\Activitypub\Activity_Dispatcher', 'send_update_activity' ) );
\add_action( 'activitypub_send_delete_activity', array( '\Activitypub\Activity_Dispatcher', 'send_delete_activity' ) );
\add_action( 'activitypub_send_delete_url_activity', array( '\Activitypub\Activity_Dispatcher', 'send_delete_url_activity' ), 10, 2 );
\add_action( 'activitypub_send_comment_activity', array( '\Activitypub\Activity_Dispatcher', 'send_comment_activity' ) );
\add_action( 'activitypub_send_update_comment_activity', array( '\Activitypub\Activity_Dispatcher', 'send_update_comment_activity' ) );
\add_action( 'activitypub_inbox_forward_activity', array( '\Activitypub\Activity_Dispatcher', 'inbox_forward_activity' ) );
\add_action( 'activitypub_send_delete_comment_activity', array( '\Activitypub\Activity_Dispatcher', 'send_delete_comment_activity' ) );
\add_action( 'activitypub_send_activity', array( self::class, 'send_activity' ), 10, 2 );
\add_action( 'activitypub_send_activity', array( self::class, 'send_activity_or_announce' ), 10, 2 );
}
/**
* Send "create" activities.
* Send Activities to followers and mentioned users or `Announce` (boost) a blog post.
*
* @param \Activitypub\Model\Post $activitypub_post
* @param WP_Post $wp_post The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_post_activity( Model\Post $activitypub_post ) {
// get latest version of post
$user_id = $activitypub_post->get_post_author();
public static function send_activity_or_announce( WP_Post $wp_post, $type ) {
// check if a migration is needed before sending new posts
Migration::maybe_migrate();
$activitypub_activity = new \Activitypub\Model\Activity( 'Create', \Activitypub\Model\Activity::TYPE_FULL );
$activitypub_activity->from_post( $activitypub_post );
$inboxes = \Activitypub\get_follower_inboxes( $user_id );
$followers_url = \get_rest_url( null, '/activitypub/1.0/users/' . intval( $user_id ) . '/followers' );
foreach ( $activitypub_activity->get_cc() as $cc ) {
if ( $cc === $followers_url ) {
continue;
}
$inbox = \Activitypub\get_inbox_by_actor( $cc );
if ( ! $inbox || \is_wp_error( $inbox ) ) {
continue;
}
// init array if empty
if ( ! isset( $inboxes[ $inbox ] ) ) {
$inboxes[ $inbox ] = array();
}
$inboxes[ $inbox ][] = $cc;
if ( is_user_type_disabled( 'blog' ) ) {
return;
}
foreach ( $inboxes as $inbox => $to ) {
$to = array_values( array_unique( $to ) );
$activitypub_activity->set_to( $to );
$activity = $activitypub_activity->to_json();
$wp_post->post_author = Users::BLOG_USER_ID;
\Activitypub\safe_remote_post( $inbox, $activity, $user_id );
}
}
/**
* Send "announce" activities.
*
* @param str $activitypub_url (ActivityPub object ID)
* @param absint $user_id
*/
public static function send_announce_activity( $activitypub_url, $user_id ) {
// get latest version of post
$time = \current_datetime()->format( DATE_ISO8601 );
$post = \get_post( \url_to_postid( $activitypub_url ) );
$activitypub_announce = new \Activitypub\Model\Activity( 'Announce', \Activitypub\Model\Activity::TYPE_SIMPLE );
$activitypub_announce->set_published( $time );
if ( $post ) {
$activitypub_id = $post->guid;
$activitypub_post = new \Activitypub\Model\Post( $post );
$activitypub_announce->from_post( $activitypub_post->to_array() );
if ( is_single_user() ) {
self::send_activity( $wp_post, $type );
} else {
$activitypub_id = $activitypub_url;
$activitypub_announce->set_object( $activitypub_id );
}
$activitypub_announce->set_id( add_query_arg( 'activity', 'announce', $activitypub_id ) );
$activitypub_announce->set_actor( \get_author_posts_url( $user_id ) );
foreach ( \Activitypub\get_follower_inboxes( $user_id ) as $inbox => $to ) {
$activitypub_announce->set_to( $to );
$activity = $activitypub_announce->to_json(); // phpcs:ignore
\Activitypub\safe_remote_post( $inbox, $activity, $user_id );
self::send_announce( $wp_post, $type );
}
}
/**
* Send "update" activities.
* Send Activities to followers and mentioned users.
*
* @param \Activitypub\Model\Post $activitypub_post
* @param WP_Post $wp_post The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_update_activity( $activitypub_post ) {
// get latest version of post
$user_id = $activitypub_post->get_post_author();
$updated = \wp_date( 'Y-m-d\TH:i:s\Z', \strtotime( $activitypub_post->get_updated() ) );
public static function send_activity( WP_Post $wp_post, $type ) {
if ( is_user_disabled( $wp_post->post_author ) ) {
return;
}
$activitypub_activity = new \Activitypub\Model\Activity( 'Update', \Activitypub\Model\Activity::TYPE_FULL );
$activitypub_activity->from_post( $activitypub_post );
$object = Post::transform( $wp_post )->to_object();
foreach ( \Activitypub\get_follower_inboxes( $user_id ) as $inbox => $to ) {
$activitypub_activity->set_to( $to );
$activity = $activitypub_activity->to_json(); // phpcs:ignore
$activity = new Activity();
$activity->set_type( $type );
$activity->set_object( $object );
\Activitypub\safe_remote_post( $inbox, $activity, $user_id );
$follower_inboxes = Followers::get_inboxes( $wp_post->post_author );
$mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() );
$inboxes = array_merge( $follower_inboxes, $mentioned_inboxes );
$inboxes = array_unique( $inboxes );
$json = $activity->to_json();
foreach ( $inboxes as $inbox ) {
safe_remote_post( $inbox, $json, $wp_post->post_author );
}
}
/**
* Send "delete" activities.
* Send Announces to followers and mentioned users.
*
* @param \Activitypub\Model\Post $activitypub_post
* @param WP_Post $wp_post The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_delete_activity( $activitypub_post ) {
// get latest version of post
$user_id = $activitypub_post->get_post_author();
public static function send_announce( WP_Post $wp_post, $type ) {
if ( ! in_array( $type, array( 'Create', 'Update' ), true ) ) {
return;
}
$activitypub_activity = new \Activitypub\Model\Activity( 'Delete', \Activitypub\Model\Activity::TYPE_FULL );
$activitypub_activity->from_post( $activitypub_post );
if ( is_user_disabled( Users::BLOG_USER_ID ) ) {
return;
}
foreach ( \Activitypub\get_follower_inboxes( $user_id ) as $inbox => $to ) {
$activitypub_activity->set_to( $to );
$activity = $activitypub_activity->to_json(); // phpcs:ignore
$object = Post::transform( $wp_post )->to_object();
\Activitypub\safe_remote_post( $inbox, $activity, $user_id );
$activity = new Activity();
$activity->set_type( 'Announce' );
// to pre-fill attributes like "published" and "id"
$activity->set_object( $object );
// send only the id
$activity->set_object( $object->get_id() );
$follower_inboxes = Followers::get_inboxes( $wp_post->post_author );
$mentioned_inboxes = Mention::get_inboxes( $activity->get_cc() );
$inboxes = array_merge( $follower_inboxes, $mentioned_inboxes );
$inboxes = array_unique( $inboxes );
$json = $activity->to_json();
foreach ( $inboxes as $inbox ) {
safe_remote_post( $inbox, $json, $wp_post->post_author );
}
}

View file

@ -1,6 +1,9 @@
<?php
namespace Activitypub;
use Activitypub\Signature;
use Activitypub\Collection\Users;
/**
* ActivityPub Class
*
@ -11,9 +14,9 @@ class Activitypub {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'template_include', array( '\Activitypub\Activitypub', 'render_json_template' ), 99 );
\add_filter( 'query_vars', array( '\Activitypub\Activitypub', 'add_query_vars' ) );
\add_filter( 'pre_get_avatar_data', array( '\Activitypub\Activitypub', 'pre_get_avatar_data' ), 11, 2 );
\add_filter( 'template_include', array( self::class, 'render_json_template' ), 99 );
\add_filter( 'query_vars', array( self::class, 'add_query_vars' ) );
\add_filter( 'pre_get_avatar_data', array( self::class, 'pre_get_avatar_data' ), 11, 2 );
// Add support for ActivityPub to custom post types
$post_types = \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ) ? \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ) : array();
@ -22,9 +25,45 @@ class Activitypub {
\add_post_type_support( $post_type, 'activitypub' );
}
\add_action( 'transition_post_status', array( '\Activitypub\Activitypub', 'schedule_post_activity' ), 33, 3 );
\add_action( 'wp_trash_post', array( '\Activitypub\Activitypub', 'trash_post' ), 1 );
\add_action( 'untrash_post', array( '\Activitypub\Activitypub', 'untrash_post' ), 1 );
\add_action( 'wp_trash_post', array( self::class, 'trash_post' ), 1 );
\add_action( 'untrash_post', array( self::class, 'untrash_post' ), 1 );
\add_action( 'init', array( self::class, 'add_rewrite_rules' ), 11 );
\add_action( 'after_setup_theme', array( self::class, 'theme_compat' ), 99 );
\add_action( 'in_plugin_update_message-' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'plugin_update_message' ) );
}
/**
* Activation Hook
*
* @return void
*/
public static function activate() {
self::flush_rewrite_rules();
Scheduler::register_schedules();
}
/**
* Deactivation Hook
*
* @return void
*/
public static function deactivate() {
self::flush_rewrite_rules();
Scheduler::deregister_schedules();
}
/**
* Uninstall Hook
*
* @return void
*/
public static function uninstall() {
Scheduler::deregister_schedules();
}
/**
@ -35,12 +74,18 @@ class Activitypub {
* @return string The new path to the JSON template.
*/
public static function render_json_template( $template ) {
if ( ! \is_author() && ! \is_singular() && ! \is_home() && ! \Activitypub\is_ap_comment() && ! \Activitypub\is_ap_replies() ) {
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return $template;
}
if ( ! is_activitypub_request() ) {
return $template;
}
$json_template = false;
// check if user can publish posts
if ( \is_author() && ! user_can( \get_the_author_meta( 'ID' ), 'publish_posts' ) ) {
if ( \is_author() && is_wp_error( Users::get_by_id( \get_the_author_meta( 'ID' ) ) ) ) {
return $template;
}
@ -56,38 +101,15 @@ class Activitypub {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/blog-json.php';
}
global $wp_query;
if ( isset( $wp_query->query_vars['activitypub'] ) ) {
return $json_template;
if ( ACTIVITYPUB_AUTHORIZED_FETCH ) {
$verification = Signature::verify_http_signature( $_SERVER );
if ( \is_wp_error( $verification ) ) {
// fallback as template_loader can't return http headers
return $template;
}
}
if ( ! isset( $_SERVER['HTTP_ACCEPT'] ) ) {
return $template;
}
$accept_header = $_SERVER['HTTP_ACCEPT'];
if (
\stristr( $accept_header, 'application/activity+json' ) ||
\stristr( $accept_header, 'application/ld+json' )
) {
return $json_template;
}
// Accept header as an array.
$accept = \explode( ',', \trim( $accept_header ) );
if (
\in_array( 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', $accept, true ) ||
\in_array( 'application/activity+json', $accept, true ) ||
\in_array( 'application/ld+json', $accept, true ) ||
\in_array( 'application/json', $accept, true )
) {
return $json_template;
}
return $template;
return $json_template;
}
/**
@ -104,36 +126,6 @@ class Activitypub {
return $vars;
}
/**
* Schedule Activities.
*
* @param string $new_status New post status.
* @param string $old_status Old post status.
* @param WP_Post $post Post object.
*/
public static function schedule_post_activity( $new_status, $old_status, $post ) {
// Do not send activities if post is password protected.
if ( \post_password_required( $post ) ) {
return;
}
// Check if post-type supports ActivityPub.
$post_types = \get_post_types_by_support( 'activitypub' );
if ( ! \in_array( $post->post_type, $post_types, true ) ) {
return;
}
$activitypub_post = new \Activitypub\Model\Post( $post );
if ( 'publish' === $new_status && 'publish' !== $old_status ) {
\wp_schedule_single_event( \time(), 'activitypub_send_post_activity', array( $activitypub_post ) );
} elseif ( 'publish' === $new_status ) { //this triggers when restored post from trash, which may not be desired
\wp_schedule_single_event( \time(), 'activitypub_send_update_activity', array( $activitypub_post ) );
} elseif ( 'trash' === $new_status ) {
\wp_schedule_single_event( \time(), 'activitypub_send_delete_activity', array( $activitypub_post ) );
}
}
/**
* Replaces the default avatar.
*
@ -151,8 +143,15 @@ class Activitypub {
return $args;
}
$allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment', 'activitypub' ) );
if ( ! empty( $id_or_email->comment_type ) && ! \in_array( $id_or_email->comment_type, (array) $allowed_comment_types, true ) ) {
$allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment' ) );
if (
! empty( $id_or_email->comment_type ) &&
! \in_array(
$id_or_email->comment_type,
(array) $allowed_comment_types,
true
)
) {
$args['url'] = false;
/** This filter is documented in wp-includes/link-template.php */
return \apply_filters( 'get_avatar_data', $args, $id_or_email );
@ -190,14 +189,19 @@ class Activitypub {
}
/**
* Store permalink in meta, to send delete Activity
* Store permalink in meta, to send delete Activity.
*
* @param string $post_id The Post ID
* @param string $post_id The Post ID.
*
* @return void
*/
public static function trash_post( $post_id ) {
\add_post_meta( $post_id, 'activitypub_canonical_url', \get_permalink( $post_id ), true );
\add_post_meta(
$post_id,
'activitypub_canonical_url',
\get_permalink( $post_id ),
true
);
}
/**
@ -210,4 +214,104 @@ class Activitypub {
public static function untrash_post( $post_id ) {
\delete_post_meta( $post_id, 'activitypub_canonical_url' );
}
/**
* Add rewrite rules
*/
public static function add_rewrite_rules() {
if ( ! \class_exists( 'Webfinger' ) ) {
\add_rewrite_rule(
'^.well-known/webfinger',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger',
'top'
);
}
if ( ! \class_exists( 'Nodeinfo_Endpoint' ) && true === (bool) \get_option( 'blog_public', 1 ) ) {
\add_rewrite_rule(
'^.well-known/nodeinfo',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo/discovery',
'top'
);
\add_rewrite_rule(
'^.well-known/x-nodeinfo2',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo2',
'top'
);
}
\add_rewrite_rule(
'^@([\w\-\.]+)',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/users/$matches[1]',
'top'
);
\add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES );
}
/**
* Flush rewrite rules;
*/
public static function flush_rewrite_rules() {
self::add_rewrite_rules();
\flush_rewrite_rules();
}
/**
* Theme compatibility stuff
*
* @return void
*/
public static function theme_compat() {
$site_icon = get_theme_support( 'custom-logo' );
if ( ! $site_icon ) {
// custom logo support
add_theme_support(
'custom-logo',
array(
'height' => 80,
'width' => 80,
)
);
}
$custom_header = get_theme_support( 'custom-header' );
if ( ! $custom_header ) {
// This theme supports a custom header
$custom_header_args = array(
'width' => 1250,
'height' => 600,
'header-text' => true,
);
add_theme_support( 'custom-header', $custom_header_args );
}
}
/**
* Display plugin upgrade notice to users
*
* @param array $data The plugin data
*
* @return void
*/
public static function plugin_update_message( $data ) {
if ( ! isset( $data['upgrade_notice'] ) ) {
return;
}
printf(
'<div class="update-message">%s</div>',
wp_kses(
wpautop( $data['upgrade_notice '] ),
array(
'p' => array(),
'a' => array( 'href', 'title' ),
'strong' => array(),
'em' => array(),
)
)
);
}
}

View file

@ -11,11 +11,15 @@ class Admin {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'admin_menu', array( '\Activitypub\Admin', 'admin_menu' ) );
\add_action( 'admin_init', array( '\Activitypub\Admin', 'register_settings' ) );
\add_action( 'show_user_profile', array( '\Activitypub\Admin', 'add_fediverse_profile' ) );
\add_filter( 'comment_row_actions', array( '\Activitypub\Admin', 'reply_comments_actions' ), 10, 2 );
\add_action( 'admin_enqueue_scripts', array( '\Activitypub\Admin', 'enqueue_scripts' ) );
\add_action( 'admin_menu', array( self::class, 'admin_menu' ) );
\add_action( 'admin_init', array( self::class, 'register_settings' ) );
\add_action( 'personal_options_update', array( self::class, 'save_user_description' ) );
\add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_scripts' ) );
\add_filter( 'comment_row_actions', array( self::class, 'reply_comments_actions' ), 10, 2 );
if ( ! is_user_disabled( get_current_user_id() ) ) {
\add_action( 'show_user_profile', array( self::class, 'add_profile' ) );
}
}
/**
@ -27,15 +31,17 @@ class Admin {
'ActivityPub',
'manage_options',
'activitypub',
array( '\Activitypub\Admin', 'settings_page' )
array( self::class, 'settings_page' )
);
\add_action( 'load-' . $settings_page, array( '\Activitypub\Admin', 'add_settings_help_tab' ) );
\add_action( 'load-' . $settings_page, array( self::class, 'add_settings_help_tab' ) );
$followers_list_page = \add_users_page( \__( 'Followers', 'activitypub' ), \__( 'Followers (Fediverse)', 'activitypub' ), 'read', 'activitypub-followers-list', array( '\Activitypub\Admin', 'followers_list_page' ) );
\add_action( 'load-' . $followers_list_page, array( '\Activitypub\Admin', 'add_followers_list_help_tab' ) );
// user has to be able to publish posts
if ( ! is_user_disabled( get_current_user_id() ) ) {
$followers_list_page = \add_users_page( \__( 'Followers', 'activitypub' ), \__( 'Followers', 'activitypub' ), 'read', 'activitypub-followers-list', array( self::class, 'followers_list_page' ) );
\add_action( 'load-' . $followers_list_page, array( self::class, 'add_followers_list_help_tab' ) );
}
}
/**
@ -52,9 +58,10 @@ class Admin {
switch ( $tab ) {
case 'settings':
\Activitypub\Model\Post::upgrade_post_content_template();
\load_template( \dirname( __FILE__ ) . '/../templates/settings.php' );
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/settings.php' );
break;
case 'followers':
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/blog-user-followers-list.php' );
break;
case 'welcome':
default:
@ -62,7 +69,7 @@ class Admin {
add_thickbox();
wp_enqueue_script( 'updates' );
\load_template( \dirname( __FILE__ ) . '/../templates/welcome.php' );
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/welcome.php' );
break;
}
}
@ -71,7 +78,10 @@ class Admin {
* Load user settings page
*/
public static function followers_list_page() {
\load_template( \dirname( __FILE__ ) . '/../templates/followers-list.php' );
// user has to be able to publish posts
if ( ! is_user_disabled( get_current_user_id() ) ) {
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/user-followers-list.php' );
}
}
/**
@ -86,7 +96,11 @@ class Admin {
'description' => \__( 'Use title and link, summary, full or custom content', 'activitypub' ),
'show_in_rest' => array(
'schema' => array(
'enum' => array( 'title', 'excerpt', 'content' ),
'enum' => array(
'title',
'excerpt',
'content',
),
),
),
'default' => 'content',
@ -119,7 +133,11 @@ class Admin {
'description' => \__( 'The Activity-Object-Type', 'activitypub' ),
'show_in_rest' => array(
'schema' => array(
'enum' => array( 'note', 'article', 'wordpress-post-format' ),
'enum' => array(
'note',
'article',
'wordpress-post-format',
),
),
),
'default' => 'note',
@ -131,7 +149,7 @@ class Admin {
array(
'type' => 'boolean',
'description' => \__( 'Add hashtags in the content as native tags and replace the #tag with the tag-link', 'activitypub' ),
'default' => 0,
'default' => '0',
)
);
\register_setting(
@ -144,21 +162,82 @@ class Admin {
'default' => array( 'post', 'pages' ),
)
);
\register_setting(
'activitypub',
'activitypub_blog_user_identifier',
array(
'type' => 'string',
'description' => \esc_html__( 'The Identifier of the Blog-User', 'activitypub' ),
'show_in_rest' => true,
'default' => \Activitypub\Model\Blog_User::get_default_username(),
'sanitize_callback' => function( $value ) {
// hack to allow dots in the username
$parts = explode( '.', $value );
$sanitized = array();
foreach ( $parts as $part ) {
$sanitized[] = \sanitize_title( $part );
}
return implode( '.', $sanitized );
},
)
);
\register_setting(
'activitypub',
'activitypub_enable_users',
array(
'type' => 'boolean',
'description' => \__( 'Every Author on this Blog (with the publish_posts capability) gets his own ActivityPub enabled Profile.', 'activitypub' ),
'default' => '1',
)
);
\register_setting(
'activitypub',
'activitypub_enable_blog_user',
array(
'type' => 'boolean',
'description' => \__( 'Your Blog becomes an ActivityPub compatible Profile.', 'activitypub' ),
'default' => '0',
)
);
}
public static function add_settings_help_tab() {
require_once \dirname( __FILE__ ) . '/help.php';
require_once ACTIVITYPUB_PLUGIN_DIR . 'includes/help.php';
}
public static function add_followers_list_help_tab() {
// todo
}
public static function add_fediverse_profile( $user ) {
?>
<h2 id="activitypub"><?php \esc_html_e( 'ActivityPub', 'activitypub' ); ?></h2>
<?php
\Activitypub\get_identifier_settings( $user->ID );
public static function add_profile( $user ) {
$description = get_user_meta( $user->ID, 'activitypub_user_description', true );
\load_template(
ACTIVITYPUB_PLUGIN_DIR . 'templates/user-settings.php',
true,
array(
'description' => $description,
)
);
}
public static function save_user_description( $user_id ) {
if ( ! isset( $_REQUEST['_apnonce'] ) ) {
return false;
}
$nonce = sanitize_text_field( wp_unslash( $_REQUEST['_apnonce'] ) );
if (
! wp_verify_nonce( $nonce, 'activitypub-user-description' ) ||
! current_user_can( 'edit_user', $user_id )
) {
return false;
}
$description = ! empty( $_POST['activitypub-user-description'] ) ? sanitize_text_field( wp_unslash( $_POST['activitypub-user-description'] ) ) : false;
if ( $description ) {
update_user_meta( $user_id, 'activitypub_user_description', $description );
}
}
/**

96
includes/class-blocks.php Normal file
View file

@ -0,0 +1,96 @@
<?php
namespace Activitypub;
use Activitypub\Collection\Followers;
use Activitypub\is_user_type_disabled;
class Blocks {
public static function init() {
// this is already being called on the init hook, so just add it.
self::register_blocks();
\add_action( 'wp_enqueue_scripts', array( self::class, 'add_data' ) );
\add_action( 'enqueue_block_editor_assets', array( self::class, 'add_data' ) );
}
public static function add_data() {
$handle = is_admin() ? 'activitypub-followers-editor-script' : 'activitypub-followers-view-script';
$data = array(
'namespace' => ACTIVITYPUB_REST_NAMESPACE,
'enabled' => array(
'site' => ! is_user_type_disabled( 'blog' ),
'users' => ! is_user_type_disabled( 'user' ),
),
);
$js = sprintf( 'var _activityPubOptions = %s;', wp_json_encode( $data ) );
\wp_add_inline_script( $handle, $js, 'before' );
}
public static function register_blocks() {
\register_block_type_from_metadata(
ACTIVITYPUB_PLUGIN_DIR . '/build/followers',
array(
'render_callback' => array( self::class, 'render_follower_block' ),
)
);
}
private static function get_user_id( $user_string ) {
if ( is_numeric( $user_string ) ) {
return absint( $user_string );
}
// any other non-numeric falls back to 0, including the `site` string used in the UI
return 0;
}
public static function render_follower_block( $attrs, $content, $block ) {
$followee_user_id = self::get_user_id( $attrs['selectedUser'] );
$per_page = absint( $attrs['per_page'] );
$followers = Followers::get_followers( $followee_user_id, $per_page );
$title = $attrs['title'];
$wrapper_attributes = get_block_wrapper_attributes(
array(
'aria-label' => __( 'Fediverse Followers', 'activitypub' ),
'class' => 'activitypub-follower-block',
'data-attrs' => wp_json_encode( $attrs ),
)
);
$html = '<div class="activitypub-follower-block" ' . $wrapper_attributes . '>';
if ( $title ) {
$html .= '<h3>' . $title . '</h3>';
}
$html .= '<ul>';
foreach ( $followers as $follower ) {
$html .= '<li>' . self::render_follower( $follower ) . '</li>';
}
// We are only pagination on the JS side. Could be revisited but we gotta ship!
$html .= '</ul></div>';
return $html;
}
public static function render_follower( $follower ) {
$external_svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="components-external-link__icon css-rvs7bx esh4a730" aria-hidden="true" focusable="false"><path d="M18.2 17c0 .7-.6 1.2-1.2 1.2H7c-.7 0-1.2-.6-1.2-1.2V7c0-.7.6-1.2 1.2-1.2h3.2V4.2H7C5.5 4.2 4.2 5.5 4.2 7v10c0 1.5 1.2 2.8 2.8 2.8h10c1.5 0 2.8-1.2 2.8-2.8v-3.6h-1.5V17zM14.9 3v1.5h3.7l-6.4 6.4 1.1 1.1 6.4-6.4v3.7h1.5V3h-6.3z"></path></svg>';
$template =
'<a href="%s" title="%s" class="components-external-link activitypub-link" target="_blank" rel="external noreferrer noopener">
<img width="40" height="40" src="%s" class="avatar activitypub-avatar" />
<span class="activitypub-actor">
<strong class="activitypub-name">%s</strong>
<span class="sep">/</span>
<span class="activitypub-handle">@%s</span>
</span>
%s
</a>';
$data = $follower->to_array();
return sprintf(
$template,
esc_url( $data['url'] ),
esc_attr( $data['name'] ),
esc_attr( $data['icon']['url'] ),
esc_html( $data['name'] ),
esc_html( $data['preferredUsername'] ),
$external_svg
);
}
}

View file

@ -1,6 +1,9 @@
<?php
namespace Activitypub;
use WP_DEBUG;
use WP_DEBUG_LOG;
/**
* ActivityPub Debug Class
*
@ -12,7 +15,7 @@ class Debug {
*/
public static function init() {
if ( WP_DEBUG && WP_DEBUG_LOG ) {
\add_action( 'activitypub_safe_remote_post_response', array( '\Activitypub\Debug', 'log_remote_post_responses' ), 10, 4 );
\add_action( 'activitypub_safe_remote_post_response', array( self::class, 'log_remote_post_responses' ), 10, 4 );
}
}

View file

@ -12,8 +12,8 @@ class Hashtag {
*/
public static function init() {
if ( '1' === \get_option( 'activitypub_use_hashtags', '1' ) ) {
\add_filter( 'wp_insert_post', array( '\Activitypub\Hashtag', 'insert_post' ), 10, 2 );
\add_filter( 'the_content', array( '\Activitypub\Hashtag', 'the_content' ), 10, 2 );
\add_filter( 'wp_insert_post', array( self::class, 'insert_post' ), 10, 2 );
\add_filter( 'the_content', array( self::class, 'the_content' ), 10, 2 );
}
}
@ -45,8 +45,12 @@ class Hashtag {
public static function the_content( $the_content ) {
$protected_tags = array();
$protect = function( $m ) use ( &$protected_tags ) {
$c = count( $protected_tags );
$c = \wp_rand( 100000, 999999 );
$protect = '!#!#PROTECT' . $c . '#!#!';
while ( isset( $protected_tags[ $protect ] ) ) {
$c = \wp_rand( 100000, 999999 );
$protect = '!#!#PROTECT' . $c . '#!#!';
}
$protected_tags[ $protect ] = $m[0];
return $protect;
};

View file

@ -14,19 +14,19 @@ class Health_Check {
* @return void
*/
public static function init() {
\add_filter( 'site_status_tests', array( '\Activitypub\Health_Check', 'add_tests' ) );
\add_filter( 'debug_information', array( '\Activitypub\Health_Check', 'debug_information' ) );
\add_filter( 'site_status_tests', array( self::class, 'add_tests' ) );
\add_filter( 'debug_information', array( self::class, 'debug_information' ) );
}
public static function add_tests( $tests ) {
$tests['direct']['activitypub_test_author_url'] = array(
'label' => \__( 'Author URL test', 'activitypub' ),
'test' => array( '\Activitypub\Health_Check', 'test_author_url' ),
'test' => array( self::class, 'test_author_url' ),
);
$tests['direct']['activitypub_test_webfinger'] = array(
'label' => __( 'WebFinger Test', 'activitypub' ),
'test' => array( '\Activitypub\Health_Check', 'test_webfinger' ),
'test' => array( self::class, 'test_webfinger' ),
);
return $tests;
@ -35,7 +35,7 @@ class Health_Check {
/**
* Author URL tests
*
* @return void
* @return array
*/
public static function test_author_url() {
$result = array(
@ -73,7 +73,7 @@ class Health_Check {
/**
* WebFinger tests
*
* @return void
* @return array
*/
public static function test_webfinger() {
$result = array(
@ -85,7 +85,7 @@ class Health_Check {
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'Your WebFinger endpoint is accessible and returns the correct informations.', 'activitypub' )
\__( 'Your WebFinger endpoint is accessible and returns the correct information.', 'activitypub' )
),
'actions' => '',
'test' => 'test_webfinger',
@ -109,9 +109,9 @@ class Health_Check {
}
/**
* Check if `author_posts_url` is accessible and that requerst returns correct JSON
* Check if `author_posts_url` is accessible and that request returns correct JSON
*
* @return boolean|WP_Error
* @return boolean|\WP_Error
*/
public static function is_author_url_accessible() {
$user = \wp_get_current_user();
@ -125,7 +125,7 @@ class Health_Check {
\sprintf(
// translators: %s: Author URL
\__(
'<p>Your author URL <code>%s</code> was replaced, this is often done by plugins.</p>',
'Your author URL <code>%s</code> was replaced, this is often done by plugins.',
'activitypub'
),
$author_url
@ -148,7 +148,7 @@ class Health_Check {
\sprintf(
// translators: %s: Author URL
\__(
'<p>Your author URL <code>%s</code> is not accessible. Please check your WordPress setup or permalink structure. If the setup seems fine, maybe check if a plugin might restrict the access.</p>',
'Your author URL <code>%s</code> is not accessible. Please check your WordPress setup or permalink structure. If the setup seems fine, maybe check if a plugin might restrict the access.',
'activitypub'
),
$author_url
@ -165,7 +165,7 @@ class Health_Check {
\sprintf(
// translators: %s: Author URL
\__(
'<p>Your author URL <code>%s</code> is redirecting to another page, this is often done by SEO plugins like "Yoast SEO".</p>',
'Your author URL <code>%s</code> is redirecting to another page, this is often done by SEO plugins like "Yoast SEO".',
'activitypub'
),
$author_url
@ -182,7 +182,7 @@ class Health_Check {
\sprintf(
// translators: %s: Author URL
\__(
'<p>Your author URL <code>%s</code> does not return valid JSON for <code>application/activity+json</code>. Please check if your hosting supports alternate <code>Accept</code> headers.</p>',
'Your author URL <code>%s</code> does not return valid JSON for <code>application/activity+json</code>. Please check if your hosting supports alternate <code>Accept</code> headers.',
'activitypub'
),
$author_url
@ -194,9 +194,9 @@ class Health_Check {
}
/**
* Check if WebFinger endoint is accessible and profile requerst returns correct JSON
* Check if WebFinger endpoint is accessible and profile request returns correct JSON
*
* @return boolean|WP_Error
* @return boolean|\WP_Error
*/
public static function is_webfinger_endpoint_accessible() {
$user = \wp_get_current_user();
@ -204,21 +204,32 @@ class Health_Check {
$url = \Activitypub\Webfinger::resolve( $account );
if ( \is_wp_error( $url ) ) {
$allowed = array( 'code' => array() );
$not_accessible = wp_kses(
// translators: %s: Author URL
\__(
'Your WebFinger endpoint <code>%s</code> is not accessible. Please check your WordPress setup or permalink structure.',
'activitypub'
),
$allowed
);
$invalid_response = wp_kses(
// translators: %s: Author URL
\__(
'Your WebFinger endpoint <code>%s</code> does not return valid JSON for <code>application/jrd+json</code>.',
'activitypub'
),
$allowed
);
$health_messages = array(
'webfinger_url_not_accessible' => \sprintf(
// translators: %s: Author URL
\__(
'<p>Your WebFinger endpoint <code>%s</code> is not accessible. Please check your WordPress setup or permalink structure.</p>',
'activitypub'
),
$not_accessible,
$url->get_error_data()
),
'webfinger_url_invalid_response' => \sprintf(
// translators: %s: Author URL
\__(
'<p>Your WebFinger endpoint <code>%s</code> does not return valid JSON for <code>application/jrd+json</code>.</p>',
'activitypub'
),
$invalid_response,
$url->get_error_data()
),
);
@ -272,7 +283,7 @@ class Health_Check {
* Static function for generating site debug data when required.
*
* @param array $info The debug information to be added to the core information page.
* @return array The filtered informations
* @return array The filtered information
*/
public static function debug_information( $info ) {
$info['activitypub'] = array(

111
includes/class-http.php Normal file
View file

@ -0,0 +1,111 @@
<?php
namespace Activitypub;
use WP_Error;
use Activitypub\Collection\Users;
/**
* ActivityPub HTTP Class
*
* @author Matthias Pfefferle
*/
class Http {
/**
* Send a POST Request with the needed HTTP Headers
*
* @param string $url The URL endpoint
* @param string $body The Post Body
* @param int $user_id The WordPress User-ID
*
* @return array|WP_Error The POST Response or an WP_ERROR
*/
public static function post( $url, $body, $user_id ) {
do_action( 'activitypub_pre_http_post', $url, $body, $user_id );
$date = \gmdate( 'D, d M Y H:i:s T' );
$digest = Signature::generate_digest( $body );
$signature = Signature::generate_signature( $user_id, 'post', $url, $date, $digest );
$wp_version = \get_bloginfo( 'version' );
/**
* Filter the HTTP headers user agent.
*
* @param string $user_agent The user agent string.
*/
$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',
'Content-Type' => 'application/activity+json',
'Digest' => $digest,
'Signature' => $signature,
'Date' => $date,
),
'body' => $body,
);
$response = \wp_safe_remote_post( $url, $args );
$code = \wp_remote_retrieve_response_code( $response );
if ( $code >= 400 ) {
$response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ) );
}
\do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
return $response;
}
/**
* Send a GET Request with the needed HTTP Headers
*
* @param string $url The URL endpoint
* @param int $user_id The WordPress User-ID
*
* @return array|WP_Error The GET Response or an WP_ERROR
*/
public static function get( $url ) {
do_action( 'activitypub_pre_http_get', $url );
$date = \gmdate( 'D, d M Y H:i:s T' );
$signature = Signature::generate_signature( Users::APPLICATION_USER_ID, 'get', $url, $date );
$wp_version = \get_bloginfo( 'version' );
/**
* Filter the HTTP headers user agent.
*
* @param string $user_agent The user agent string.
*/
$user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
$args = array(
'timeout' => apply_filters( 'activitypub_remote_get_timeout', 100 ),
'limit_response_size' => 1048576,
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
'Signature' => $signature,
'Date' => $date,
),
);
$response = \wp_safe_remote_get( $url, $args );
$code = \wp_remote_retrieve_response_code( $response );
if ( $code >= 400 ) {
$response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ) );
}
\do_action( 'activitypub_safe_remote_get_response', $response, $url );
return $response;
}
}

View file

@ -1,6 +1,8 @@
<?php
namespace Activitypub;
use WP_Error;
/**
* ActivityPub Mention Class
*
@ -11,8 +13,8 @@ class Mention {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_filter( 'the_content', array( '\Activitypub\Mention', 'the_content' ), 99, 2 );
\add_filter( 'activitypub_extract_mentions', array( '\Activitypub\Mention', 'extract_mentions' ), 99, 2 );
\add_filter( 'the_content', array( self::class, 'the_content' ), 99, 2 );
\add_filter( 'activitypub_extract_mentions', array( self::class, 'extract_mentions' ), 99, 2 );
}
/**
@ -25,8 +27,12 @@ class Mention {
public static function the_content( $the_content ) {
$protected_tags = array();
$protect = function( $m ) use ( &$protected_tags ) {
$c = count( $protected_tags );
$c = \wp_rand( 100000, 999999 );
$protect = '!#!#PROTECT' . $c . '#!#!';
while ( isset( $protected_tags[ $protect ] ) ) {
$c = \wp_rand( 100000, 999999 );
$protect = '!#!#PROTECT' . $c . '#!#!';
}
$protected_tags[ $protect ] = $m[0];
return $protect;
};
@ -46,9 +52,14 @@ class Mention {
$the_content
);
$the_content = \preg_replace_callback( '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/', array( '\Activitypub\Mention', 'replace_with_links' ), $the_content );
$the_content = preg_replace_callback(
'#<img.*?[^>]+>#i',
$protect,
$the_content
);
$the_content = str_replace( array_reverse( array_keys( $protected_tags ) ), array_reverse( array_values( $protected_tags ) ), $the_content );
$the_content = \preg_replace_callback( '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/', array( self::class, 'replace_with_links' ), $the_content );
$the_content = \str_replace( array_reverse( array_keys( $protected_tags ) ), array_reverse( array_values( $protected_tags ) ), $the_content );
return $the_content;
}
@ -57,10 +68,12 @@ class Mention {
* A callback for preg_replace to build the user links
*
* @param array $result the preg_match results
*
* @return string the final string
*/
public static function replace_with_links( $result ) {
$metadata = \ActivityPub\get_remote_metadata_by_actor( $result[0] );
$metadata = get_remote_metadata_by_actor( $result[0] );
if ( ! is_wp_error( $metadata ) && ! empty( $metadata['url'] ) ) {
$username = ltrim( $result[0], '@' );
if ( ! empty( $metadata['name'] ) ) {
@ -69,29 +82,74 @@ class Mention {
if ( ! empty( $metadata['preferredUsername'] ) ) {
$username = $metadata['preferredUsername'];
}
$username = '@<span>' . $username . '</span>';
return \sprintf( '<a rel="mention" class="u-url mention" href="%s">%s</a>', $metadata['url'], $username );
return \sprintf( '<a rel="mention" class="u-url mention" href="%s">@<span>%s</span></a>', esc_url( $metadata['url'] ), esc_html( $username ) );
}
return $result[0];
}
/**
* Get the Inboxes for the mentioned Actors
*
* @param array $mentioned The list of Actors that were mentioned
*
* @return array The list of Inboxes
*/
public static function get_inboxes( $mentioned ) {
$inboxes = array();
foreach ( $mentioned as $actor ) {
$inbox = self::get_inbox_by_mentioned_actor( $actor );
if ( ! is_wp_error( $inbox ) && $inbox ) {
$inboxes[] = $inbox;
}
}
return $inboxes;
}
/**
* Get the inbox from the Remote-Profile of a mentioned Actor
*
* @param string $actor The Actor-URL
*
* @return string The Inbox-URL
*/
public static function get_inbox_by_mentioned_actor( $actor ) {
$metadata = 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 );
}
/**
* Extract the mentions from the post_content.
*
* @param array $mentions The already found mentions.
* @param string $post_content The post content.
*
* @return mixed The discovered mentions.
*/
public static function extract_mentions( $mentions, $post_content ) {
\preg_match_all( '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/i', $post_content, $matches );
foreach ( $matches[0] as $match ) {
$link = \Activitypub\Webfinger::resolve( $match );
$link = Webfinger::resolve( $match );
if ( ! is_wp_error( $link ) ) {
$mentions[ $match ] = $link;
}
}
return $mentions;
}
}

View file

@ -0,0 +1,179 @@
<?php
namespace Activitypub;
use Activitypub\Activitypub;
use Activitypub\Model\Blog_User;
use Activitypub\Collection\Followers;
/**
* ActivityPub Migration Class
*
* @author Matthias Pfefferle
*/
class Migration {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'activitypub_schedule_migration', array( self::class, 'maybe_migrate' ) );
}
/**
* Get the target version.
*
* This is the version that the database structure will be updated to.
* It is the same as the plugin version.
*
* @return string The target version.
*/
public static function get_target_version() {
return get_plugin_version();
}
/**
* The current version of the database structure.
*
* @return string The current version.
*/
public static function get_version() {
return get_option( 'activitypub_db_version', 0 );
}
/**
* Locks the database migration process to prevent simultaneous migrations.
*
* @return void
*/
public static function lock() {
\update_option( 'activitypub_migration_lock', \time() );
}
/**
* Unlocks the database migration process.
*
* @return void
*/
public static function unlock() {
\delete_option( 'activitypub_migration_lock' );
}
/**
* Whether the database migration process is locked.
*
* @return boolean
*/
public static function is_locked() {
$lock = \get_option( 'activitypub_migration_lock' );
if ( ! $lock ) {
return false;
}
$lock = (int) $lock;
if ( $lock < \time() - 1800 ) {
self::unlock();
return false;
}
return true;
}
/**
* Whether the database structure is up to date.
*
* @return bool True if the database structure is up to date, false otherwise.
*/
public static function is_latest_version() {
return (bool) version_compare(
self::get_version(),
self::get_target_version(),
'=='
);
}
/**
* Updates the database structure if necessary.
*/
public static function maybe_migrate() {
if ( self::is_latest_version() ) {
return;
}
if ( self::is_locked() ) {
return;
}
self::lock();
$version_from_db = self::get_version();
if ( version_compare( $version_from_db, '0.17.0', '<' ) ) {
self::migrate_from_0_16();
}
if ( version_compare( $version_from_db, '1.0.0', '<' ) ) {
self::migrate_from_0_17();
}
update_option( 'activitypub_db_version', self::get_target_version() );
self::unlock();
}
/**
* Updates the DB-schema of the followers-list
*
* @return void
*/
private static function migrate_from_0_17() {
// migrate followers
foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
$followers = get_user_meta( $user_id, 'activitypub_followers', true );
if ( $followers ) {
foreach ( $followers as $actor ) {
Followers::add_follower( $user_id, $actor );
}
}
}
Activitypub::flush_rewrite_rules();
}
/**
* Updates the custom template to use shortcodes instead of the deprecated templates.
*
* @return void
*/
private static function migrate_from_0_16() {
// Get the custom template.
$old_content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
// If the old content exists but is a blank string, we're going to need a flag to updated it even
// after setting it to the default contents.
$need_update = false;
// If the old contents is blank, use the defaults.
if ( '' === $old_content ) {
$old_content = ACTIVITYPUB_CUSTOM_POST_CONTENT;
$need_update = true;
}
// Set the new content to be the old content.
$content = $old_content;
// Convert old templates to shortcodes.
$content = \str_replace( '%title%', '[ap_title]', $content );
$content = \str_replace( '%excerpt%', '[ap_excerpt]', $content );
$content = \str_replace( '%content%', '[ap_content]', $content );
$content = \str_replace( '%permalink%', '[ap_permalink type="html"]', $content );
$content = \str_replace( '%shortlink%', '[ap_shortlink type="html"]', $content );
$content = \str_replace( '%hashtags%', '[ap_hashtags]', $content );
$content = \str_replace( '%tags%', '[ap_hashtags]', $content );
// Store the new template if required.
if ( $content !== $old_content || $need_update ) {
\update_option( 'activitypub_custom_post_content', $content );
}
}
}

View file

@ -0,0 +1,157 @@
<?php
namespace Activitypub;
use Activitypub\Collection\Users;
use Activitypub\Collection\Followers;
use Activitypub\Transformer\Post;
/**
* ActivityPub Scheduler Class
*
* @author Matthias Pfefferle
*/
class Scheduler {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 );
\add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) );
\add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) );
\add_action( 'admin_init', array( self::class, 'schedule_migration' ) );
}
/**
* Schedule all ActivityPub schedules.
*
* @return void
*/
public static function register_schedules() {
if ( ! \wp_next_scheduled( 'activitypub_update_followers' ) ) {
\wp_schedule_event( time(), 'hourly', 'activitypub_update_followers' );
}
if ( ! \wp_next_scheduled( 'activitypub_cleanup_followers' ) ) {
\wp_schedule_event( time(), 'daily', 'activitypub_cleanup_followers' );
}
}
/**
* Unscedule all ActivityPub schedules.
*
* @return void
*/
public static function deregister_schedules() {
wp_unschedule_hook( 'activitypub_update_followers' );
wp_unschedule_hook( 'activitypub_cleanup_followers' );
}
/**
* Schedule Activities.
*
* @param string $new_status New post status.
* @param string $old_status Old post status.
* @param WP_Post $post Post object.
*/
public static function schedule_post_activity( $new_status, $old_status, $post ) {
// Do not send activities if post is password protected.
if ( \post_password_required( $post ) ) {
return;
}
// Check if post-type supports ActivityPub.
$post_types = \get_post_types_by_support( 'activitypub' );
if ( ! \in_array( $post->post_type, $post_types, true ) ) {
return;
}
$type = false;
if ( 'publish' === $new_status && 'publish' !== $old_status ) {
$type = 'Create';
} elseif ( 'publish' === $new_status ) {
$type = 'Update';
} elseif ( 'trash' === $new_status ) {
$type = 'Delete';
}
if ( ! $type ) {
return;
}
\wp_schedule_single_event(
\time(),
'activitypub_send_activity',
array( $post, $type )
);
\wp_schedule_single_event(
\time(),
sprintf(
'activitypub_send_%s_activity',
\strtolower( $type )
),
array( $post )
);
}
/**
* Update followers
*
* @return void
*/
public static function update_followers() {
$followers = Followers::get_outdated_followers();
foreach ( $followers as $follower ) {
$meta = get_remote_metadata_by_actor( $follower->get_url(), true );
if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
Followers::add_error( $follower->get__id(), $meta );
} else {
$follower->from_array( $meta );
$follower->update();
}
}
}
/**
* Cleanup followers
*
* @return void
*/
public static function cleanup_followers() {
$followers = Followers::get_faulty_followers();
foreach ( $followers as $follower ) {
$meta = get_remote_metadata_by_actor( $follower->get_url(), true );
if ( is_tombstone( $meta ) ) {
$follower->delete();
} elseif ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
if ( $follower->count_errors() >= 5 ) {
$follower->delete();
} else {
Followers::add_error( $follower->get__id(), $meta );
}
} else {
$follower->reset_errors();
}
}
}
/**
* Schedule migration if DB-Version is not up to date.
*
* @return void
*/
public static function schedule_migration() {
if ( ! \wp_next_scheduled( 'activitypub_schedule_migration' ) && ! Migration::is_latest_version() ) {
\wp_schedule_single_event( \time(), 'activitypub_schedule_migration' );
}
}
}

View file

@ -1,37 +1,42 @@
<?php
namespace Activitypub;
use function Activitypub\esc_hashtag;
class Shortcodes {
/**
* Class constructor, registering WordPress then shortcodes
*
* @param WP_Post $post A WordPress Post Object
* Class constructor, registering WordPress then Shortcodes
*/
public static function init() {
foreach ( get_class_methods( 'Activitypub\Shortcodes' ) as $shortcode ) {
// do not load on admin pages
if ( is_admin() ) {
return;
}
foreach ( get_class_methods( self::class ) as $shortcode ) {
if ( 'init' !== $shortcode ) {
add_shortcode( 'ap_' . $shortcode, array( 'Activitypub\Shortcodes', $shortcode ) );
add_shortcode( 'ap_' . $shortcode, array( self::class, $shortcode ) );
}
}
}
/**
* Generates output for the ap_hashtags shortcode
* Generates output for the 'ap_hashtags' shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post tags as hashtags.
*/
public static function hashtags( $atts, $content, $tag ) {
$post_id = get_the_ID();
$item = self::get_item();
if ( ! $post_id ) {
if ( ! $item ) {
return '';
}
$tags = \get_the_tags( $post_id );
$tags = \get_the_tags( $item->ID );
if ( ! $tags ) {
return '';
@ -41,9 +46,9 @@ class Shortcodes {
foreach ( $tags as $tag ) {
$hash_tags[] = \sprintf(
'<a rel="tag" class="u-tag u-category" href="%s">#%s</a>',
\get_tag_link( $tag ),
$tag->slug
'<a rel="tag" class="u-tag u-category" href="%s">%s</a>',
\esc_url( \get_tag_link( $tag ) ),
esc_hashtag( $tag->name )
);
}
@ -51,38 +56,37 @@ class Shortcodes {
}
/**
* Generates output for the ap_title shortcode
* Generates output for the 'ap_title' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post title.
*/
public static function title( $atts, $content, $tag ) {
$post_id = get_the_ID();
$item = self::get_item();
if ( ! $post_id ) {
if ( ! $item ) {
return '';
}
return \get_the_title( $post_id );
return \wp_strip_all_tags( \get_the_title( $item->ID ), true );
}
/**
* Generates output for the ap_excerpt shortcode
* Generates output for the 'ap_excerpt' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post excerpt.
*/
public static function excerpt( $atts, $content, $tag ) {
$post = get_post();
$item = self::get_item();
if ( ! $post || \post_password_required( $post ) ) {
if ( ! $item ) {
return '';
}
@ -98,11 +102,11 @@ class Shortcodes {
$excerpt_length = ACTIVITYPUB_EXCERPT_LENGTH;
}
$excerpt = \get_post_field( 'post_excerpt', $post );
$excerpt = \get_post_field( 'post_excerpt', $item );
if ( '' === $excerpt ) {
$content = \get_post_field( 'post_content', $post );
$content = \get_post_field( 'post_content', $item );
// An empty string will make wp_trim_excerpt do stuff we do not want.
if ( '' !== $content ) {
@ -174,28 +178,31 @@ class Shortcodes {
}
/**
* Generates output for the ap_content shortcode
* Generates output for the 'ap_content' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post content.
*/
public static function content( $atts, $content, $tag ) {
$post = get_post();
$item = self::get_item();
if ( ! $post || \post_password_required( $post ) ) {
if ( ! $item ) {
return '';
}
// prevent inception
remove_shortcode( 'ap_content' );
$atts = shortcode_atts(
array( 'apply_filters' => 'yes' ),
$atts,
$tag
);
$content = \get_post_field( 'post_content', $post );
$content = \get_post_field( 'post_content', $item );
if ( 'yes' === $atts['apply_filters'] ) {
$content = \apply_filters( 'the_content', $content );
@ -207,25 +214,27 @@ class Shortcodes {
// replace script and style elements
$content = \preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $content );
$content = strip_shortcodes( $content );
$content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) );
add_shortcode( 'ap_content', array( 'Activitypub\Shortcodes', 'content' ) );
return $content;
}
/**
* Generates output for the ap_permalink shortcode
* Generates output for the 'ap_permalink' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post permalink.
*/
public static function permalink( $atts, $content, $tag ) {
$post = get_post();
$item = self::get_item();
if ( ! $post ) {
if ( ! $item ) {
return '';
}
@ -238,25 +247,28 @@ class Shortcodes {
);
if ( 'url' === $atts['type'] ) {
return \esc_url( \get_permalink( $post->ID ) );
return \esc_url( \get_permalink( $item->ID ) );
}
return \sprintf( '<a href="%1$s">%1$s</a>', \esc_url( \get_permalink( $post->ID ) ) );
return \sprintf(
'<a href="%1$s">%1$s</a>',
\esc_url( \get_permalink( $item->ID ) )
);
}
/**
* Generates output for the ap_shortlink shortcode
* Generates output for the 'ap_shortlink' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post shortlink.
*/
public static function shortlink( $atts, $content, $tag ) {
$post = get_post();
$item = self::get_item();
if ( ! $post ) {
if ( ! $item ) {
return '';
}
@ -269,25 +281,28 @@ class Shortcodes {
);
if ( 'url' === $atts['type'] ) {
return \esc_url( \wp_get_shortlink( $post->ID ) );
return \esc_url( \wp_get_shortlink( $item->ID ) );
}
return \sprintf( '<a href="%1$s">%1$s</a>', \esc_url( \wp_get_shortlink( $post->ID ) ) );
return \sprintf(
'<a href="%1$s">%1$s</a>',
\esc_url( \wp_get_shortlink( $item->ID ) )
);
}
/**
* Generates output for the ap_image shortcode
* Generates output for the 'ap_image' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
*/
public static function image( $atts, $content, $tag ) {
$post_id = get_the_ID();
$item = self::get_item();
if ( ! $post_id ) {
if ( ! $item ) {
return '';
}
@ -309,7 +324,7 @@ class Shortcodes {
$size = $atts['type'];
}
$image = \get_the_post_thumbnail_url( $post_id, $size );
$image = \get_the_post_thumbnail_url( $item->ID, $size );
if ( ! $image ) {
return '';
@ -319,22 +334,22 @@ class Shortcodes {
}
/**
* Generates output for the ap_hashcats shortcode
* Generates output for the 'ap_hashcats' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post categories as hashtags.
*/
public static function hashcats( $atts, $content, $tag ) {
$post_id = get_the_ID();
$item = self::get_item();
if ( ! $post_id ) {
if ( ! $item ) {
return '';
}
$categories = \get_the_category( $post_id );
$categories = \get_the_category( $item->ID );
if ( ! $categories ) {
return '';
@ -343,54 +358,58 @@ class Shortcodes {
$hash_tags = array();
foreach ( $categories as $category ) {
$hash_tags[] = \sprintf( '<a rel="tag" class="u-tag u-category" href="%s">#%s</a>', \get_category_link( $category ), $category->slug );
$hash_tags[] = \sprintf(
'<a rel="tag" class="u-tag u-category" href="%s">%s</a>',
\esc_url( \get_category_link( $category ) ),
esc_hashtag( $category->name )
);
}
return \implode( ' ', $hash_tags );
}
/**
* Generates output for the ap_author shortcode
* Generates output for the 'ap_author' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The author name.
*/
public static function author( $atts, $content, $tag ) {
$post = get_post();
$item = self::get_item();
if ( ! $post ) {
if ( ! $item ) {
return '';
}
$name = \get_the_author_meta( 'display_name', $post->post_author );
$name = \get_the_author_meta( 'display_name', $item->post_author );
if ( ! $name ) {
return '';
}
return $name;
return wp_strip_all_tags( $name );
}
/**
* Generates output for the ap_authorurl shortcode
* Generates output for the 'ap_authorurl' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The author URL.
*/
public static function authorurl( $atts, $content, $tag ) {
$post = get_post();
$item = self::get_item();
if ( ! $post ) {
if ( ! $item ) {
return '';
}
$url = \get_the_author_meta( 'user_url', $post->post_author );
$url = \get_the_author_meta( 'user_url', $item->post_author );
if ( ! $url ) {
return '';
@ -400,61 +419,61 @@ class Shortcodes {
}
/**
* Generates output for the ap_blogurl shortcode
* Generates output for the 'ap_blogurl' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The site URL.
*/
public static function blogurl( $atts, $content, $tag ) {
return \esc_url( \get_bloginfo( 'url' ) );
}
/**
* Generates output for the ap_blogname shortcode
* Generates output for the 'ap_blogname' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
*/
public static function blogname( $atts, $content, $tag ) {
return \get_bloginfo( 'name' );
return \wp_strip_all_tags( \get_bloginfo( 'name' ) );
}
/**
* Generates output for the ap_blogdesc shortcode
* Generates output for the 'ap_blogdesc' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The site description.
*/
public static function blogdesc( $atts, $content, $tag ) {
return \get_bloginfo( 'description' );
return \wp_strip_all_tags( \get_bloginfo( 'description' ) );
}
/**
* Generates output for the ap_date shortcode
* Generates output for the 'ap_date' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post date.
*/
public static function date( $atts, $content, $tag ) {
$post = get_post();
$item = self::get_item();
if ( ! $post ) {
if ( ! $item ) {
return '';
}
$datetime = \get_post_datetime( $post );
$datetime = \get_post_datetime( $item );
$dateformat = \get_option( 'date_format' );
$timeformat = \get_option( 'time_format' );
@ -468,22 +487,22 @@ class Shortcodes {
}
/**
* Generates output for the ap_time shortcode
* Generates output for the 'ap_time' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post time.
*/
public static function time( $atts, $content, $tag ) {
$post = get_post();
$item = self::get_item();
if ( ! $post ) {
if ( ! $item ) {
return '';
}
$datetime = \get_post_datetime( $post );
$datetime = \get_post_datetime( $item );
$dateformat = \get_option( 'date_format' );
$timeformat = \get_option( 'time_format' );
@ -497,22 +516,22 @@ class Shortcodes {
}
/**
* Generates output for the ap_datetime shortcode
* Generates output for the 'ap_datetime' Shortcode
*
* @param array $atts shortcode attributes
* @param string $content shortcode content
* @param string $tag shortcode tag name
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
*
* @return string
* @return string The post date/time.
*/
public static function datetime( $atts, $content, $tag ) {
$post = get_post();
$item = self::get_item();
if ( ! $post ) {
if ( ! $item ) {
return '';
}
$datetime = \get_post_datetime( $post );
$datetime = \get_post_datetime( $item );
$dateformat = \get_option( 'date_format' );
$timeformat = \get_option( 'time_format' );
@ -524,4 +543,34 @@ class Shortcodes {
return $date;
}
/**
* Get a WordPress item to federate.
*
* Checks if item (WP_Post) is "public", a supported post type
* and not password protected.
*
* @return null|WP_Post The WordPress item.
*/
protected static function get_item() {
$post = \get_post();
if ( ! $post ) {
return null;
}
if ( 'publish' !== \get_post_status( $post ) ) {
return null;
}
if ( \post_password_required( $post ) ) {
return null;
}
if ( ! \in_array( \get_post_type( $post ), \get_post_types_by_support( 'activitypub' ), true ) ) {
return null;
}
return $post;
}
}

View file

@ -1,55 +1,80 @@
<?php
namespace Activitypub;
use WP_Error;
use DateTime;
use DateTimeZone;
use Activitypub\Model\User;
use Activitypub\Collection\Users;
/**
* ActivityPub Signature Class
*
* @author Matthias Pfefferle
* @author Django Doucet
*/
class Signature {
/**
* @param int $user_id
* Return the public key for a given user.
*
* @return mixed
* @param int $user_id The WordPress User ID.
* @param bool $force Force the generation of a new key pair.
*
* @return mixed The 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];
if ( $force ) {
self::generate_key_pair( $user_id );
}
self::generate_key_pair( $user_id );
$key = \get_user_meta( $user_id, 'magic_sig_public_key' );
if ( User::APPLICATION_USER_ID === $user_id ) {
$key = \get_option( 'activitypub_magic_sig_public_key' );
} else {
$key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
}
return $key[0];
if ( ! $key ) {
return self::get_public_key( $user_id, true );
}
return $key;
}
/**
* @param int $user_id
* Return the private key for a given user.
*
* @return mixed
* @param int $user_id The WordPress User ID.
* @param bool $force Force the generation of a new key pair.
*
* @return mixed The 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];
if ( $force ) {
self::generate_key_pair( $user_id );
}
self::generate_key_pair( $user_id );
$key = \get_user_meta( $user_id, 'magic_sig_private_key' );
if ( User::APPLICATION_USER_ID === $user_id ) {
$key = \get_option( 'activitypub_magic_sig_private_key' );
} else {
$key = \get_user_meta( $user_id, 'magic_sig_private_key', true );
}
return $key[0];
if ( ! $key ) {
return self::get_private_key( $user_id, true );
}
return $key;
}
/**
* Generates the pair keys
*
* @param int $user_id
* @param int $user_id The WordPress User ID.
*
* @return void
*/
public static function generate_key_pair( $user_id ) {
public static function generate_key_pair() {
$config = array(
'digest_alg' => 'sha512',
'private_key_bits' => 2048,
@ -61,17 +86,28 @@ class Signature {
\openssl_pkey_export( $key, $priv_key );
// private key
\update_user_meta( $user_id, 'magic_sig_private_key', $priv_key );
$detail = \openssl_pkey_get_details( $key );
// public key
\update_user_meta( $user_id, 'magic_sig_public_key', $detail['key'] );
return array(
'private_key' => $priv_key,
'public_key' => $detail['key'],
);
}
/**
* Generates the Signature for a HTTP Request
*
* @param int $user_id The WordPress User ID.
* @param string $http_method The HTTP method.
* @param string $url The URL to send the request to.
* @param string $date The date the request is sent.
* @param string $digest The digest of the request body.
*
* @return string The signature.
*/
public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) {
$key = self::get_private_key( $user_id );
$user = Users::get_by_id( $user_id );
$key = $user->get__private_key();
$url_parts = \wp_parse_url( $url );
@ -88,8 +124,10 @@ class Signature {
$path .= '?' . $url_parts['query'];
}
$http_method = \strtolower( $http_method );
if ( ! empty( $digest ) ) {
$signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest";
$signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: $digest";
} else {
$signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date";
}
@ -98,7 +136,8 @@ class Signature {
\openssl_sign( $signed_string, $signature, $key, \OPENSSL_ALGO_SHA256 );
$signature = \base64_encode( $signature ); // phpcs:ignore
$key_id = \get_author_posts_url( $user_id ) . '#main-key';
$user = Users::get_by_id( $user_id );
$key_id = $user->get_url() . '#main-key';
if ( ! empty( $digest ) ) {
return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature );
@ -107,12 +146,262 @@ class Signature {
}
}
public static function verify_signature( $headers, $signature ) {
/**
* Verifies the http signatures
*
* @param WP_REQUEST|array $request The request object or $_SERVER array.
*
* @return mixed A boolean or WP_Error.
*/
public static function verify_http_signature( $request ) {
if ( is_object( $request ) ) { // REST Request object
// check if route starts with "index.php"
if ( str_starts_with( $request->get_route(), '/index.php' ) || ! rest_get_url_prefix() ) {
$route = $request->get_route();
} else {
$route = '/' . rest_get_url_prefix() . '/' . ltrim( $request->get_route(), '/' );
}
$headers = $request->get_headers();
$actor = isset( json_decode( $request->get_body() )->actor ) ? json_decode( $request->get_body() )->actor : '';
$headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' ' . $route;
} else {
$request = self::format_server_request( $request );
$headers = $request['headers']; // $_SERVER array
$actor = null;
$headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
}
if ( ! isset( $headers['signature'] ) ) {
return new WP_Error( 'activitypub_signature', 'Request not signed', array( 'status' => 403 ) );
}
if ( array_key_exists( 'signature', $headers ) ) {
$signature_block = self::parse_signature_header( $headers['signature'] );
} elseif ( array_key_exists( 'authorization', $headers ) ) {
$signature_block = self::parse_signature_header( $headers['authorization'] );
}
if ( ! isset( $signature_block ) || ! $signature_block ) {
return new WP_Error( 'activitypub_signature', 'Incompatible request signature. keyId and signature are required', array( 'status' => 403 ) );
}
$signed_headers = $signature_block['headers'];
if ( ! $signed_headers ) {
$signed_headers = array( 'date' );
}
$signed_data = self::get_signed_data( $signed_headers, $signature_block, $headers );
if ( ! $signed_data ) {
return new WP_Error( 'activitypub_signature', 'Signed request date outside acceptable time window', array( 'status' => 403 ) );
}
$algorithm = self::get_signature_algorithm( $signature_block );
if ( ! $algorithm ) {
return new WP_Error( 'activitypub_signature', 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)', array( 'status' => 403 ) );
}
if ( \in_array( 'digest', $signed_headers, true ) && isset( $body ) ) {
if ( is_array( $headers['digest'] ) ) {
$headers['digest'] = $headers['digest'][0];
}
$digest = explode( '=', $headers['digest'], 2 );
if ( 'SHA-256' === $digest[0] ) {
$hashalg = 'sha256';
}
if ( 'SHA-512' === $digest[0] ) {
$hashalg = 'sha512';
}
if ( \base64_encode( \hash( $hashalg, $body, true ) ) !== $digest[1] ) { // phpcs:ignore
return new WP_Error( 'activitypub_signature', 'Invalid Digest header', array( 'status' => 403 ) );
}
}
if ( $actor ) {
$public_key = self::get_remote_key( $actor );
} else {
$public_key = self::get_remote_key( $signature_block['keyId'] );
}
if ( \is_wp_error( $public_key ) ) {
return $public_key;
}
$verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0;
if ( ! $verified ) {
return new WP_Error( 'activitypub_signature', 'Invalid signature', array( 'status' => 403 ) );
}
return $verified;
}
/**
* Get public key from key_id
*
* @param string $key_id The URL to the public key.
*
* @return string The public key.
*/
public static function get_remote_key( $key_id ) { // phpcs:ignore
$actor = get_remote_metadata_by_actor( strtok( strip_fragment_from_url( $key_id ), '?' ) ); // phpcs:ignore
if ( \is_wp_error( $actor ) ) {
return $actor;
}
if ( isset( $actor['publicKey']['publicKeyPem'] ) ) {
return \rtrim( $actor['publicKey']['publicKeyPem'] ); // phpcs:ignore
}
return null;
}
/**
* Gets the signature algorithm from the signature header
*
* @param array $signature_block
*
* @return string The signature algorithm.
*/
public static function get_signature_algorithm( $signature_block ) {
if ( $signature_block['algorithm'] ) {
switch ( $signature_block['algorithm'] ) {
case 'rsa-sha-512':
return 'sha512'; //hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
default:
return 'sha256';
}
}
return false;
}
/**
* Parses the Signature header
*
* @param array $header The signature header.
*
* @return array signature parts
*/
public static function parse_signature_header( $header ) {
$parsed_header = array();
$matches = array();
$h_string = \implode( ',', (array) $header[0] );
if ( \preg_match( '/keyId="(.*?)"/ism', $h_string, $matches ) ) {
$parsed_header['keyId'] = $matches[1];
}
if ( \preg_match( '/created=([0-9]*)/ism', $h_string, $matches ) ) {
$parsed_header['(created)'] = $matches[1];
}
if ( \preg_match( '/expires=([0-9]*)/ism', $h_string, $matches ) ) {
$parsed_header['(expires)'] = $matches[1];
}
if ( \preg_match( '/algorithm="(.*?)"/ism', $h_string, $matches ) ) {
$parsed_header['algorithm'] = $matches[1];
}
if ( \preg_match( '/headers="(.*?)"/ism', $h_string, $matches ) ) {
$parsed_header['headers'] = \explode( ' ', $matches[1] );
}
if ( \preg_match( '/signature="(.*?)"/ism', $h_string, $matches ) ) {
$parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', $matches[1] ) ); // phpcs:ignore
}
if ( ( $parsed_header['signature'] ) && ( $parsed_header['algorithm'] ) && ( ! $parsed_header['headers'] ) ) {
$parsed_header['headers'] = array( 'date' );
}
return $parsed_header;
}
/**
* Gets the header data from the included pseudo headers
*
* @param array $signed_headers
* @param array $signature_block (pseudo-headers)
* @param array $headers (http headers)
*
* @return string signed headers for comparison
*/
public static function get_signed_data( $signed_headers, $signature_block, $headers ) {
$signed_data = '';
// This also verifies time-based values by returning false if any of these are out of range.
foreach ( $signed_headers as $header ) {
if ( 'host' === $header ) {
if ( isset( $headers['x_original_host'] ) ) {
$signed_data .= $header . ': ' . $headers['x_original_host'][0] . "\n";
continue;
}
}
if ( '(request-target)' === $header ) {
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
continue;
}
if ( str_contains( $header, '-' ) ) {
$signed_data .= $header . ': ' . $headers[ str_replace( '-', '_', $header ) ][0] . "\n";
continue;
}
if ( '(created)' === $header ) {
if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
// created in future
return false;
}
}
if ( '(expires)' === $header ) {
if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
// expired in past
return false;
}
}
if ( 'date' === $header ) {
// allow a bit of leeway for misconfigured clocks.
$d = new DateTime( $headers[ $header ][0] );
$d->setTimeZone( new DateTimeZone( 'UTC' ) );
$c = $d->format( 'U' );
$dplus = time() + ( 3 * HOUR_IN_SECONDS );
$dminus = time() - ( 3 * HOUR_IN_SECONDS );
if ( $c > $dplus || $c < $dminus ) {
// time out of range
return false;
}
}
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
}
return \rtrim( $signed_data, "\n" );
}
/**
* Generates the digest for a HTTP Request
*
* @param string $body The body of the request.
*
* @return string The digest.
*/
public static function generate_digest( $body ) {
$digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore
return "$digest";
return "SHA-256=$digest";
}
/**
* Formats the $_SERVER to resemble the WP_REST_REQUEST array,
* for use with verify_http_signature()
*
* @param array $_SERVER The $_SERVER array.
*
* @return array $request The formatted request array.
*/
public static function format_server_request( $server ) {
$request = array();
foreach ( $server as $param_key => $param_val ) {
$req_param = strtolower( $param_key );
if ( 'REQUEST_URI' === $req_param ) {
$request['headers']['route'][] = $param_val;
} else {
$header_key = str_replace(
'http_',
'',
$req_param
);
$request['headers'][ $header_key ][] = \wp_unslash( $param_val );
}
}
return $request;
}
}

View file

@ -1,6 +1,9 @@
<?php
namespace Activitypub;
use WP_Error;
use Activitypub\Collection\Users;
/**
* ActivityPub WebFinger Class
*
@ -22,25 +25,35 @@ class Webfinger {
return \get_webfinger_resource( $user_id, false );
}
$user = \get_user_by( 'id', $user_id );
$user = Users::get_by_id( $user_id );
if ( ! $user ) {
return '';
}
return $user->user_login . '@' . \wp_parse_url( \home_url(), \PHP_URL_HOST );
return $user->get_resource();
}
public static function resolve( $account ) {
if ( ! preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $account, $m ) ) {
/**
* Resolve a WebFinger resource
*
* @param string $resource The WebFinger resource
*
* @return string|WP_Error The URL or WP_Error
*/
public static function resolve( $resource ) {
if ( ! preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $resource, $m ) ) {
return null;
}
$transient_key = 'activitypub_resolve_' . ltrim( $account, '@' );
$transient_key = 'activitypub_resolve_' . ltrim( $resource, '@' );
$link = \get_transient( $transient_key );
if ( $link ) {
return $link;
}
$url = \add_query_arg( 'resource', 'acct:' . ltrim( $account, '@' ), 'https://' . $m[2] . '/.well-known/webfinger' );
$url = \add_query_arg( 'resource', 'acct:' . ltrim( $resource, '@' ), 'https://' . $m[2] . '/.well-known/webfinger' );
if ( ! \wp_http_validate_url( $url ) ) {
$response = new \WP_Error( 'invalid_webfinger_url', null, $url );
$response = new WP_Error( 'invalid_webfinger_url', null, $url );
\set_transient( $transient_key, $response, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
return $response;
}
@ -49,14 +62,14 @@ class Webfinger {
$response = \wp_remote_get(
$url,
array(
'headers' => array( 'Accept' => 'application/activity+json' ),
'headers' => array( 'Accept' => 'application/jrd+json' ),
'redirection' => 0,
'timeout' => 2,
)
);
if ( \is_wp_error( $response ) ) {
$link = new \WP_Error( 'webfinger_url_not_accessible', null, $url );
$link = new WP_Error( 'webfinger_url_not_accessible', null, $url );
\set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
return $link;
}
@ -65,7 +78,7 @@ class Webfinger {
$body = \json_decode( $body, true );
if ( empty( $body['links'] ) ) {
$link = new \WP_Error( 'webfinger_url_invalid_response', null, $url );
$link = new WP_Error( 'webfinger_url_invalid_response', null, $url );
\set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
return $link;
}
@ -77,8 +90,114 @@ class Webfinger {
}
}
$link = new \WP_Error( 'webfinger_url_no_activity_pub', null, $body );
$link = new WP_Error( 'webfinger_url_no_activitypub', null, $body );
\set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
return $link;
}
/**
* Convert a URI string to an identifier and its host.
* Automatically adds acct: if it's missing.
*
* @param string $url The URI (acct:, mailto:, http:, https:)
*
* @return WP_Error|array Error reaction or array with
* identifier and host as values
*/
public static function get_identifier_and_host( $url ) {
// remove leading @
$url = ltrim( $url, '@' );
if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
$identifier = 'acct:' . $url;
$scheme = 'acct';
} else {
$identifier = $url;
$scheme = $match[1];
}
$host = null;
switch ( $scheme ) {
case 'acct':
case 'mailto':
case 'xmpp':
if ( strpos( $identifier, '@' ) !== false ) {
$host = substr( $identifier, strpos( $identifier, '@' ) + 1 );
}
break;
default:
$host = wp_parse_url( $identifier, PHP_URL_HOST );
break;
}
if ( empty( $host ) ) {
return new WP_Error( 'invalid_identifier', __( 'Invalid Identifier', 'activitypub' ) );
}
return array( $identifier, $host );
}
/**
* Get the WebFinger data for a given URI
*
* @param string $identifier The Identifier: <identifier>@<host>
* @param string $host The Host: <identifier>@<host>
*
* @return WP_Error|array Error reaction or array with
* identifier and host as values
*/
public static function get_data( $identifier, $host ) {
$webfinger_url = 'https://' . $host . '/.well-known/webfinger?resource=' . rawurlencode( $identifier );
$response = wp_safe_remote_get(
$webfinger_url,
array(
'headers' => array( 'Accept' => 'application/jrd+json' ),
'redirection' => 0,
'timeout' => 2,
)
);
if ( is_wp_error( $response ) ) {
return new WP_Error( 'webfinger_url_not_accessible', null, $webfinger_url );
}
$body = wp_remote_retrieve_body( $response );
return json_decode( $body, true );
}
/**
* Undocumented function
*
* @return void
*/
public static function get_remote_follow_endpoint( $uri ) {
$identifier_and_host = self::get_identifier_and_host( $uri );
if ( is_wp_error( $identifier_and_host ) ) {
return $identifier_and_host;
}
list( $identifier, $host ) = $identifier_and_host;
$data = self::get_data( $identifier, $host );
if ( is_wp_error( $data ) ) {
return $data;
}
if ( empty( $data['links'] ) ) {
return new WP_Error( 'webfinger_url_invalid_response', null, $data );
}
foreach ( $data['links'] as $link ) {
if ( 'http://ostatus.org/schema/1.0/subscribe' === $link['rel'] ) {
return $link['template'];
}
}
return new WP_Error( 'webfinger_remote_follow_endpoint_invalid', null, $data );
}
}

View file

@ -0,0 +1,546 @@
<?php
namespace Activitypub\Collection;
use WP_Error;
use Exception;
use WP_Query;
use Activitypub\Http;
use Activitypub\Webfinger;
use Activitypub\Model\Follower;
use Activitypub\Collection\Users;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use function Activitypub\is_tombstone;
use function Activitypub\get_remote_metadata_by_actor;
/**
* ActivityPub Followers Collection
*
* @author Matt Wiebe
* @author Matthias Pfefferle
*/
class Followers {
const POST_TYPE = 'ap_follower';
const CACHE_KEY_INBOXES = 'follower_inboxes_%s';
/**
* Register WordPress hooks/actions and register Taxonomy
*
* @return void
*/
public static function init() {
// register "followers" post_type
self::register_post_type();
\add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow_request' ), 10, 2 );
\add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo_request' ), 10, 2 );
\add_action( 'activitypub_followers_post_follow', array( self::class, 'send_follow_response' ), 10, 4 );
}
/**
* Register the "Followers" Taxonomy
*
* @return void
*/
private static function register_post_type() {
register_post_type(
self::POST_TYPE,
array(
'labels' => array(
'name' => _x( 'Followers', 'post_type plural name', 'activitypub' ),
'singular_name' => _x( 'Follower', 'post_type single name', 'activitypub' ),
),
'public' => false,
'hierarchical' => false,
'rewrite' => false,
'query_var' => false,
'delete_with_user' => false,
'can_export' => true,
'supports' => array(),
)
);
register_post_meta(
self::POST_TYPE,
'activitypub_inbox',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => array( self::class, 'sanitize_url' ),
)
);
register_post_meta(
self::POST_TYPE,
'activitypub_errors',
array(
'type' => 'string',
'single' => false,
'sanitize_callback' => function( $value ) {
if ( ! is_string( $value ) ) {
throw new Exception( 'Error message is no valid string' );
}
return esc_sql( $value );
},
)
);
register_post_meta(
self::POST_TYPE,
'activitypub_user_id',
array(
'type' => 'string',
'single' => false,
'sanitize_callback' => function( $value ) {
return esc_sql( $value );
},
)
);
register_post_meta(
self::POST_TYPE,
'activitypub_actor_json',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => function( $value ) {
return sanitize_text_field( $value );
},
)
);
do_action( 'activitypub_after_register_post_type' );
}
public static function sanitize_url( $value ) {
if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) {
return null;
}
return esc_url_raw( $value );
}
/**
* Handle the "Follow" Request
*
* @param array $object The JSON "Follow" Activity
* @param int $user_id The ID of the ID of the WordPress User
*
* @return void
*/
public static function handle_follow_request( $object, $user_id ) {
// save follower
$follower = self::add_follower( $user_id, $object['actor'] );
do_action( 'activitypub_followers_post_follow', $object['actor'], $object, $user_id, $follower );
}
/**
* Handle "Unfollow" requests
*
* @param array $object The JSON "Undo" Activity
* @param int $user_id The ID of the ID of the WordPress User
*/
public static function handle_undo_request( $object, $user_id ) {
if (
isset( $object['object'] ) &&
isset( $object['object']['type'] ) &&
'Follow' === $object['object']['type']
) {
self::remove_follower( $user_id, $object['actor'] );
}
}
/**
* Add new Follower
*
* @param int $user_id The ID of the WordPress User
* @param string $actor The Actor URL
*
* @return array|WP_Error The Follower (WP_Term array) or an WP_Error
*/
public static function add_follower( $user_id, $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
if ( is_tombstone( $meta ) ) {
return $meta;
}
$error = null;
$follower = new Follower();
if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
$follower->set_id( $actor );
$follower->set_url( $actor );
$error = $meta;
} else {
$follower->from_array( $meta );
}
$follower->upsert();
$meta = get_post_meta( $follower->get__id(), 'activitypub_user_id' );
if ( $error ) {
self::add_error( $follower->get__id(), $error );
}
// phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
if ( is_array( $meta ) && ! in_array( $user_id, $meta ) ) {
add_post_meta( $follower->get__id(), 'activitypub_user_id', $user_id );
wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
}
return $follower;
}
/**
* Remove a Follower
*
* @param int $user_id The ID of the WordPress User
* @param string $actor The Actor URL
*
* @return bool|WP_Error True on success, false or WP_Error on failure.
*/
public static function remove_follower( $user_id, $actor ) {
wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
$follower = self::get_follower( $user_id, $actor );
if ( ! $follower ) {
return false;
}
return delete_post_meta( $follower->get__id(), 'activitypub_user_id', $user_id );
}
/**
* Get a Follower
*
* @param int $user_id The ID of the WordPress User
* @param string $actor The Actor URL
*
* @return \Activitypub\Model\Follower The Follower object
*/
public static function get_follower( $user_id, $actor ) {
global $wpdb;
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT DISTINCT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key = 'activitypub_user_id' AND pm.meta_value = %d AND p.guid = %s",
array(
esc_sql( self::POST_TYPE ),
esc_sql( $user_id ),
esc_sql( $actor ),
)
)
);
if ( $post_id ) {
$post = get_post( $post_id );
return Follower::init_from_cpt( $post );
}
return null;
}
/**
* Send Accept response
*
* @param string $actor The Actor URL
* @param array $object The Activity object
* @param int $user_id The ID of the WordPress User
* @param Activitypub\Model\Follower $follower The Follower object
*
* @return void
*/
public static function send_follow_response( $actor, $object, $user_id, $follower ) {
if ( is_wp_error( $follower ) ) {
// it is not even possible to send a "Reject" because
// we can not get the Remote-Inbox
return;
}
// only send minimal data
$object = array_intersect_key(
$object,
array_flip(
array(
'id',
'type',
'actor',
'object',
)
)
);
$user = Users::get_by_id( $user_id );
// get inbox
$inbox = $follower->get_shared_inbox();
// send "Accept" activity
$activity = new Activity();
$activity->set_type( 'Accept' );
$activity->set_object( $object );
$activity->set_actor( $user->get_id() );
$activity->set_to( $actor );
$activity->set_id( $user->get_id() . '#follow-' . \preg_replace( '~^https?://~', '', $actor ) . '-' . \time() );
$activity = $activity->to_json();
Http::post( $inbox, $activity, $user_id );
}
/**
* Get the Followers of a given user
*
* @param int $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
* @return array List of `Follower` objects.
*/
public static function get_followers( $user_id, $number = -1, $page = null, $args = array() ) {
$data = self::get_followers_with_count( $user_id, $number, $page, $args );
return $data['followers'];
}
/**
* Get the Followers of a given user, along with a total count for pagination purposes.
*
* @param int $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array
* followers List of `Follower` objects.
* total Total number of followers.
*/
public static function get_followers_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
'post_type' => self::POST_TYPE,
'posts_per_page' => $number,
'paged' => $page,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_user_id',
'value' => $user_id,
),
),
);
$args = wp_parse_args( $args, $defaults );
$query = new WP_Query( $args );
$total = $query->found_posts;
$followers = array_map(
function( $post ) {
return Follower::init_from_cpt( $post );
},
$query->get_posts()
);
return compact( 'followers', 'total' );
}
/**
* Get all Followers
*
* @param array $args The WP_Query arguments.
*
* @return array The Term list of Followers.
*/
public static function get_all_followers() {
$args = array(
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(),
);
return self::get_followers( null, null, null, $args );
}
/**
* Count the total number of followers
*
* @param int $user_id The ID of the WordPress User
*
* @return int The number of Followers
*/
public static function count_followers( $user_id ) {
$query = new WP_Query(
array(
'post_type' => self::POST_TYPE,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_user_id',
'value' => $user_id,
),
),
)
);
return $query->found_posts;
}
/**
* Returns all Inboxes fo a Users Followers
*
* @param int $user_id The ID of the WordPress User
*
* @return array The list of Inboxes
*/
public static function get_inboxes( $user_id ) {
$cache_key = sprintf( self::CACHE_KEY_INBOXES, $user_id );
$inboxes = wp_cache_get( $cache_key, 'activitypub' );
if ( $inboxes ) {
return $inboxes;
}
// get all Followers of a ID of the WordPress User
$posts = new WP_Query(
array(
'post_type' => self::POST_TYPE,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_inbox',
'compare' => 'EXISTS',
),
array(
'key' => 'activitypub_user_id',
'value' => $user_id,
),
),
)
);
$posts = $posts->get_posts();
if ( ! $posts ) {
return array();
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
WHERE post_id IN (" . implode( ', ', array_fill( 0, count( $posts ), '%d' ) ) . ")
AND meta_key = 'activitypub_inbox'
AND meta_value IS NOT NULL",
$posts
)
);
$inboxes = array_filter( $results );
wp_cache_set( $cache_key, $inboxes, 'activitypub' );
return $inboxes;
}
/**
* Get all Followers that have not been updated for a given time
*
* @param enum $output The output format, supported ARRAY_N, OBJECT and ACTIVITYPUB_OBJECT.
* @param int $number Limits the result.
* @param int $older_than The time in seconds.
*
* @return mixed The Term list of Followers, the format depends on $output.
*/
public static function get_outdated_followers( $number = 50, $older_than = 86400 ) {
$args = array(
'post_type' => self::POST_TYPE,
'posts_per_page' => $number,
'orderby' => 'modified',
'order' => 'DESC',
'post_status' => 'any', // 'any' includes 'trash
'date_query' => array(
array(
'column' => 'post_modified_gmt',
'before' => gmdate( 'Y-m-d', \time() - $older_than ),
),
),
);
$posts = new WP_Query( $args );
$items = array();
foreach ( $posts->get_posts() as $follower ) {
$items[] = Follower::init_from_cpt( $follower ); // phpcs:ignore
}
return $items;
}
/**
* Get all Followers that had errors
*
* @param enum $output The output format, supported ARRAY_N, OBJECT and ACTIVITYPUB_OBJECT
* @param integer $number The number of Followers to return.
*
* @return mixed The Term list of Followers, the format depends on $output.
*/
public static function get_faulty_followers( $number = 10 ) {
$args = array(
'post_type' => self::POST_TYPE,
'posts_per_page' => $number,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_errors',
'compare' => 'EXISTS',
),
),
);
$posts = new WP_Query( $args );
$items = array();
foreach ( $posts->get_posts() as $follower ) {
$items[] = Follower::init_from_cpt( $follower ); // phpcs:ignore
}
return $items;
}
/**
* This function is used to store errors that occur when
* sending an ActivityPub message to a Follower.
*
* The error will be stored in the
* post meta.
*
* @param int $post_id The ID of the WordPress Custom-Post-Type.
* @param mixed $error The error message. Can be a string or a WP_Error.
*
* @return int|false The meta ID on success, false on failure.
*/
public static function add_error( $post_id, $error ) {
if ( is_string( $error ) ) {
$error_message = $error;
} elseif ( is_wp_error( $error ) ) {
$error_message = $error->get_error_message();
} else {
$error_message = __(
'Unknown Error or misconfigured Error-Message',
'activitypub'
);
}
return add_post_meta(
$post_id,
'activitypub_errors',
$error_message
);
}
}

View file

@ -0,0 +1,209 @@
<?php
namespace Activitypub\Collection;
use WP_Error;
use WP_User_Query;
use Activitypub\Model\User;
use Activitypub\Model\Blog_User;
use Activitypub\Model\Application_User;
use function Activitypub\is_user_disabled;
class Users {
/**
* The ID of the Blog User
*
* @var int
*/
const BLOG_USER_ID = 0;
/**
* The ID of the Application User
*
* @var int
*/
const APPLICATION_USER_ID = -1;
/**
* Get the User by ID
*
* @param int $user_id The User-ID.
*
* @return \Acitvitypub\Model\User The User.
*/
public static function get_by_id( $user_id ) {
if ( is_string( $user_id ) || is_numeric( $user_id ) ) {
$user_id = (int) $user_id;
}
if ( is_user_disabled( $user_id ) ) {
return new WP_Error(
'activitypub_user_not_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
}
if ( self::BLOG_USER_ID === $user_id ) {
return Blog_User::from_wp_user( $user_id );
} elseif ( self::APPLICATION_USER_ID === $user_id ) {
return Application_User::from_wp_user( $user_id );
} elseif ( $user_id > 0 ) {
return User::from_wp_user( $user_id );
}
return new WP_Error(
'activitypub_user_not_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
}
/**
* Get the User by username.
*
* @param string $username The User-Name.
*
* @return \Acitvitypub\Model\User The User.
*/
public static function get_by_username( $username ) {
// check for blog user.
if ( Blog_User::get_default_username() === $username ) {
return self::get_by_id( self::BLOG_USER_ID );
}
if ( get_option( 'activitypub_blog_user_identifier' ) === $username ) {
return self::get_by_id( self::BLOG_USER_ID );
}
// check for application user.
if ( 'application' === $username ) {
return self::get_by_id( self::APPLICATION_USER_ID );
}
// check for 'activitypub_username' meta
$user = new WP_User_Query(
array(
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'activitypub_user_identifier',
'value' => $username,
'compare' => 'LIKE',
),
),
)
);
if ( $user->results ) {
return self::get_by_id( $user->results[0] );
}
// check for login or nicename.
$user = new WP_User_Query(
array(
'search' => $username,
'search_columns' => array( 'user_login', 'user_nicename' ),
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
)
);
if ( $user->results ) {
return self::get_by_id( $user->results[0] );
}
return new WP_Error(
'activitypub_user_not_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
}
/**
* Get the User by resource.
*
* @param string $resource The User-Resource.
*
* @return \Acitvitypub\Model\User The User.
*/
public static function get_by_resource( $resource ) {
if ( \strpos( $resource, '@' ) === false ) {
return new WP_Error(
'activitypub_unsupported_resource',
\__( 'Resource is invalid', 'activitypub' ),
array( 'status' => 400 )
);
}
$resource = \str_replace( 'acct:', '', $resource );
$resource_identifier = \substr( $resource, 0, \strrpos( $resource, '@' ) );
$resource_host = self::normalize_host( \substr( \strrchr( $resource, '@' ), 1 ) );
$blog_host = self::normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );
if ( $blog_host !== $resource_host ) {
return new WP_Error(
'activitypub_wrong_host',
\__( 'Resource host does not match blog host', 'activitypub' ),
array( 'status' => 404 )
);
}
return self::get_by_username( $resource_identifier );
}
/**
* Get the User by resource.
*
* @param string $resource The User-Resource.
*
* @return \Acitvitypub\Model\User The User.
*/
public static function get_by_various( $id ) {
if ( is_numeric( $id ) ) {
return self::get_by_id( $id );
} elseif ( filter_var( $id, FILTER_VALIDATE_URL ) ) {
return self::get_by_resource( $id );
} else {
return self::get_by_username( $id );
}
}
/**
* Normalize the host.
*
* @param string $host The host.
*
* @return string The normalized host.
*/
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;
}
}

View file

@ -6,7 +6,8 @@ namespace Activitypub;
*
* @param array $r Array of HTTP request args.
* @param string $url The request URL.
* @return array $args Array or string of HTTP request arguments.
*
* @return array Array or string of HTTP request arguments.
*/
function allow_localhost( $r, $url ) {
$r['reject_unsafe_urls'] = false;

View file

@ -1,7 +1,10 @@
<?php
namespace Activitypub;
define( 'AS_PUBLIC', 'https://www.w3.org/ns/activitystreams#Public' );
use WP_Error;
use Activitypub\Http;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Followers;
/**
* Returns the ActivityPub default JSON-context
@ -9,134 +12,39 @@ define( 'AS_PUBLIC', 'https://www.w3.org/ns/activitystreams#Public' );
* @return array the activitypub context
*/
function get_context() {
$context = array(
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
array(
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
'PropertyValue' => 'schema:PropertyValue',
'schema' => 'http://schema.org#',
'pt' => 'https://joinpeertube.org/ns#',
'toot' => 'http://joinmastodon.org/ns#',
'value' => 'schema:value',
'Hashtag' => 'as:Hashtag',
'featured' => array(
'@id' => 'toot:featured',
'@type' => '@id',
),
'featuredTags' => array(
'@id' => 'toot:featuredTags',
'@type' => '@id',
),
),
);
$context = Activity::CONTEXT;
return \apply_filters( 'activitypub_json_context', $context );
}
function safe_remote_post( $url, $body, $user_id ) {
$date = \gmdate( 'D, d M Y H:i:s T' );
$digest = \Activitypub\Signature::generate_digest( $body );
$signature = \Activitypub\Signature::generate_signature( $user_id, 'post', $url, $date, $digest );
$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',
'Content-Type' => 'application/activity+json',
'Digest' => "SHA-256=$digest",
'Signature' => $signature,
'Date' => $date,
),
'body' => $body,
);
$response = \wp_safe_remote_post( $url, $args );
\do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
return $response;
return Http::post( $url, $body, $user_id );
}
function forward_remote_post( $url, $body, $user_id ) {
$date = \gmdate( 'D, d M Y H:i:s T' );
$digest = \Activitypub\Signature::generate_digest( $body );
$signature = \Activitypub\Signature::generate_signature( 1, $url, $date );
$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',
'Content-Type' => 'application/activity+json',
'Digest' => "SHA-256=$digest",
'Signature' => $signature,
'Date' => $date,
),
'body' => $body,
);
$response = \wp_safe_remote_post( $url, $args );
\do_action( 'activitypub_forward_remote_post_response', $response, $url, $body, $user_id );
return $response;
}
function safe_remote_get( $url, $user_id ) {
$date = \gmdate( 'D, d M Y H:i:s T' );
$signature = \Activitypub\Signature::generate_signature( $user_id, 'get', $url, $date );
$wp_version = \get_bloginfo( 'version' );
$user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
$args = array(
'timeout' => apply_filters( 'activitypub_remote_get_timeout', 100 ),
'limit_response_size' => 1048576,
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
'Signature' => $signature,
'Date' => $date,
),
);
$response = \wp_safe_remote_get( $url, $args );
\do_action( 'activitypub_safe_remote_get_response', $response, $url, $user_id );
return $response;
function safe_remote_get( $url ) {
return Http::get( $url );
}
/**
* Returns a users WebFinger "resource"
*
* @param int $user_id
* @param int $user_id The User-ID.
*
* @return string The user-resource
* @return string The User-Resource.
*/
function get_webfinger_resource( $user_id ) {
return \Activitypub\Webfinger::get_user_resource( $user_id );
return Webfinger::get_user_resource( $user_id );
}
/**
* [get_metadata_by_actor description]
* Requests the Meta-Data from the Actors profile
*
* @param string $actor
* @param string $actor The Actor URL.
* @param bool $cached If the result should be cached.
*
* @return array
* @return array The Actor profile as array
*/
function get_remote_metadata_by_actor( $actor ) {
function get_remote_metadata_by_actor( $actor, $cached = true ) {
$pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
if ( $pre ) {
return $pre;
@ -154,10 +62,14 @@ function get_remote_metadata_by_actor( $actor ) {
}
$transient_key = 'activitypub_' . $actor;
$metadata = \get_transient( $transient_key );
if ( $metadata ) {
return $metadata;
// only check the cache if needed.
if ( $cached ) {
$metadata = \get_transient( $transient_key );
if ( $metadata ) {
return $metadata;
}
}
if ( ! \wp_http_validate_url( $actor ) ) {
@ -166,21 +78,11 @@ function get_remote_metadata_by_actor( $actor ) {
return $metadata;
}
$user = \get_users(
array(
'number' => 1,
'capability__in' => array( 'publish_posts' ),
'fields' => 'ID',
)
);
// we just need any user to generate a request signature
$user_id = \reset( $user );
$short_timeout = function() {
return 3;
};
add_filter( 'activitypub_remote_get_timeout', $short_timeout );
$response = \Activitypub\safe_remote_get( $actor, $user_id );
$response = Http::get( $actor );
remove_filter( 'activitypub_remote_get_timeout', $short_timeout );
if ( \is_wp_error( $response ) ) {
\set_transient( $transient_key, $response, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
@ -190,140 +92,37 @@ function get_remote_metadata_by_actor( $actor ) {
$metadata = \wp_remote_retrieve_body( $response );
$metadata = \json_decode( $metadata, true );
\set_transient( $transient_key, $metadata, WEEK_IN_SECONDS );
if ( ! $metadata ) {
$metadata = new \WP_Error( 'activitypub_invalid_json', \__( 'No valid JSON data', 'activitypub' ), $actor );
\set_transient( $transient_key, $metadata, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
return $metadata;
}
\set_transient( $transient_key, $metadata, WEEK_IN_SECONDS );
return $metadata;
}
/**
* [get_inbox_by_actor description]
* @param [type] $actor [description]
* @return [type] [description]
* Returns the followers of a given user.
*
* @param int $user_id The User-ID.
*
* @return array The followers.
*/
function 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 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 get_follower_inboxes( $user_id ) {
$followers = \Activitypub\Peer\Followers::get_followers( $user_id );
$inboxes = array();
foreach ( $followers as $follower ) {
$inbox = \Activitypub\get_inbox_by_actor( $follower );
if ( ! $inbox || \is_wp_error( $inbox ) ) {
continue;
}
// init array if empty
if ( ! isset( $inboxes[ $inbox ] ) ) {
$inboxes[ $inbox ] = array();
}
$inboxes[ $inbox ][] = $follower;
}
return $inboxes;
}
/**
* @param $mentions array of mentioned actors, each mention is an array of actor URI (href), and webfinger (name).
* @return array of (shared) inboxes.
*/
function get_mentioned_inboxes( $mentions ) {
$inboxes = array();
foreach ( $mentions as $mention ) {
$inbox = \Activitypub\get_inbox_by_actor( $mention['href'] );
if ( ! $inbox || \is_wp_error( $inbox ) ) {
continue;
}
if ( ! isset( $inboxes[ $inbox ] ) ) {
$inboxes[ $inbox ] = array();
}
$inboxes[ $inbox ][] = $mention;
}
return $inboxes;
}
function get_identifier_settings( $user_id ) {
?>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<label><?php \esc_html_e( 'Profile identifier', 'activitypub' ); ?></label>
</th>
<td>
<p><code><?php echo \esc_html( \Activitypub\get_webfinger_resource( $user_id ) ); ?></code> or <code><?php echo \esc_url( \get_author_posts_url( $user_id ) ); ?></code></p>
<?php // translators: the webfinger resource ?>
<p class="description"><?php \printf( \esc_html__( 'Try to follow "@%s" by searching for it on Mastodon,Friendica & Co.', 'activitypub' ), \esc_html( \Activitypub\get_webfinger_resource( $user_id ) ) ); ?></p>
</td>
</tr>
</tbody>
</table>
<?php
}
function get_followers( $user_id ) {
$followers = \Activitypub\Peer\Followers::get_followers( $user_id );
if ( ! $followers ) {
return array();
}
return $followers;
return Followers::get_followers( $user_id );
}
/**
* Count the number of followers for a given user.
*
* @param int $user_id The User-ID.
*
* @return int The number of followers.
*/
function count_followers( $user_id ) {
$followers = \Activitypub\get_followers( $user_id );
return \count( $followers );
return Followers::count_followers( $user_id );
}
/**
@ -634,5 +433,291 @@ function get_audience( $object ) {
if ( in_array( $author_post_url, $object['to'] ) ) {
return 'private';
}
}
}
/**
* Check for Tombstone Objects
*
* @see https://www.w3.org/TR/activitypub/#delete-activity-outbox
*
* @param WP_Error $wp_error A WP_Error-Response of an HTTP-Request
*
* @return boolean true if HTTP-Code is 410 or 404
*/
function is_tombstone( $wp_error ) {
if ( ! is_wp_error( $wp_error ) ) {
return false;
}
if ( in_array( (int) $wp_error->get_error_code(), array( 404, 410 ), true ) ) {
return true;
}
return false;
}
/**
* Get the REST URL relative to this plugin's namespace.
*
* @param string $path Optional. REST route path. Otherwise this plugin's namespaced root.
*
* @return string REST URL relative to this plugin's namespace.
*/
function get_rest_url_by_path( $path = '' ) {
// we'll handle the leading slash.
$path = ltrim( $path, '/' );
$namespaced_path = sprintf( '/%s/%s', ACTIVITYPUB_REST_NAMESPACE, $path );
return \get_rest_url( null, $namespaced_path );
}
/**
* Convert a string from camelCase to snake_case.
*
* @param string $string The string to convert.
*
* @return string The converted string.
*/
// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound
function camel_to_snake_case( $string ) {
return strtolower( preg_replace( '/(?<!^)[A-Z]/', '_$0', $string ) );
}
/**
* Convert a string from snake_case to camelCase.
*
* @param string $string The string to convert.
*
* @return string The converted string.
*/
// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound
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.
*
* @return bool False by default.
*/
function is_activitypub_request() {
global $wp_query;
/*
* ActivityPub requests are currently only made for
* author archives, singular posts, and the homepage.
*/
if ( ! \is_author() && ! \is_singular() && ! \is_home() && ! defined( '\REST_REQUEST' ) ) {
return false;
}
// One can trigger an ActivityPub request by adding ?activitypub to the URL.
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.VariableRedeclaration
global $wp_query;
if ( isset( $wp_query->query_vars['activitypub'] ) ) {
return true;
}
/*
* The other (more common) option to make an ActivityPub request
* is to send an Accept header.
*/
if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
$accept = sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
/*
* $accept can be a single value, or a comma separated list of values.
* We want to support both scenarios,
* and return true when the header includes at least one of the following:
* - application/activity+json
* - application/ld+json
* - application/json
*/
if ( preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
return true;
}
}
return false;
}
/**
* This function checks if a user is disabled for ActivityPub.
*
* @param int $user_id The User-ID.
*
* @return boolean True if the user is disabled, false otherwise.
*/
function is_user_disabled( $user_id ) {
$return = false;
switch ( $user_id ) {
// if the user is the application user, it's always enabled.
case \Activitypub\Collection\Users::APPLICATION_USER_ID:
$return = false;
break;
// if the user is the blog user, it's only enabled in single-user mode.
case \Activitypub\Collection\Users::BLOG_USER_ID:
if ( is_user_type_disabled( 'blog' ) ) {
$return = true;
break;
}
$return = false;
break;
// if the user is any other user, it's enabled if it can publish posts.
default:
if ( ! \get_user_by( 'id', $user_id ) ) {
$return = true;
break;
}
if ( is_user_type_disabled( 'user' ) ) {
$return = true;
break;
}
if ( ! \user_can( $user_id, 'publish_posts' ) ) {
$return = true;
break;
}
$return = false;
break;
}
return apply_filters( 'activitypub_is_user_disabled', $return, $user_id );
}
/**
* Checks if a User-Type is disabled for ActivityPub.
*
* This function is used to check if the 'blog' or 'user'
* type is disabled for ActivityPub.
*
* @param enum $type Can be 'blog' or 'user'.
*
* @return boolean True if the user type is disabled, false otherwise.
*/
function is_user_type_disabled( $type ) {
switch ( $type ) {
case 'blog':
if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
$return = false;
break;
}
}
if ( \defined( 'ACTIVITYPUB_DISABLE_BLOG_USER' ) ) {
$return = ACTIVITYPUB_DISABLE_BLOG_USER;
break;
}
if ( '1' !== \get_option( 'activitypub_enable_blog_user', '0' ) ) {
$return = true;
break;
}
$return = false;
break;
case 'user':
if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
$return = true;
break;
}
}
if ( \defined( 'ACTIVITYPUB_DISABLE_USER' ) ) {
$return = ACTIVITYPUB_DISABLE_USER;
break;
}
if ( '1' !== \get_option( 'activitypub_enable_users', '1' ) ) {
$return = true;
break;
}
$return = false;
break;
default:
$return = new WP_Error( 'activitypub_wrong_user_type', __( 'Wrong user type', 'activitypub' ) );
break;
}
return apply_filters( 'activitypub_is_user_type_disabled', $return, $type );
}
/**
* Check if the blog is in single-user mode.
*
* @return boolean True if the blog is in single-user mode, false otherwise.
*/
function is_single_user() {
$return = false;
if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
$return = true;
}
} elseif (
false === is_user_type_disabled( 'blog' ) &&
true === is_user_type_disabled( 'user' )
) {
$return = true;
}
return $return;
}
if ( ! function_exists( 'get_self_link' ) ) {
/**
* Returns the link for the currently displayed feed.
*
* @return string Correct link for the atom:self element.
*/
function get_self_link() {
$host = wp_parse_url( home_url() );
$path = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
return esc_url( apply_filters( 'self_link', set_url_scheme( 'http://' . $host['host'] . $path ) ) );
}
}

View file

@ -8,37 +8,37 @@
'<p>' . __( 'The following Template Tags are available:', 'activitypub' ) . '</p>' .
'<dl>' .
'<dt><code>[ap_title]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s title.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s title.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_content apply_filters="yes"]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s content. With <code>apply_filters</code> you can decide if filters should be applied or not (default is <code>yes</code>). The values can be <code>yes</code> or <code>no</code>. <code>apply_filters</code> attribute is optional.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s content. With <code>apply_filters</code> you can decide if filters (<code>apply_filters( \'the_content\', $content )</code>) should be applied or not (default is <code>yes</code>). The values can be <code>yes</code> or <code>no</code>. <code>apply_filters</code> attribute is optional.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_excerpt lenght="400"]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s excerpt (default 400 chars). <code>length</code> attribute is optional.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s excerpt (default 400 chars). <code>length</code> attribute is optional.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_permalink type="url"]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s permalink. <code>type</code> can be either: <code>url</code> or <code>html</code> (an &lt;a /&gt; tag). <code>type</code> attribute is optional.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s permalink. <code>type</code> can be either: <code>url</code> or <code>html</code> (an &lt;a /&gt; tag). <code>type</code> attribute is optional.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_shortlink type="url"]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s shortlink. <code>type</code> can be either <code>url</code> or <code>html</code> (an &lt;a /&gt; tag). I can recommend <a href="https://wordpress.org/plugins/hum/" target="_blank">Hum</a>, to prettify the Shortlinks. <code>type</code> attribute is optional.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s shortlink. <code>type</code> can be either <code>url</code> or <code>html</code> (an &lt;a /&gt; tag). I can recommend <a href="https://wordpress.org/plugins/hum/" target="_blank">Hum</a>, to prettify the Shortlinks. <code>type</code> attribute is optional.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_hashtags]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s tags as hashtags.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s tags as hashtags.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_hashcats]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s categories as hashtags.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s categories as hashtags.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_image type=full]</code></dt>' .
'<dd>' . \wp_kses( __( 'The URL for the post\'s featured image, defaults to full size. The type attribute can be any of the following: <code>thumbnail</code>, <code>medium</code>, <code>large</code>, <code>full</code>. <code>type</code> attribute is optional.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The URL for the post\'s featured image, defaults to full size. The type attribute can be any of the following: <code>thumbnail</code>, <code>medium</code>, <code>large</code>, <code>full</code>. <code>type</code> attribute is optional.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_author]</code></dt>' .
'<dd>' . \wp_kses( __( 'The author\'s name.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The author\'s name.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_authorurl]</code></dt>' .
'<dd>' . \wp_kses( __( 'The URL to the author\'s profile page.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The URL to the author\'s profile page.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_date]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s date.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s date.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_time]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s time.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s time.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_datetime]</code></dt>' .
'<dd>' . \wp_kses( __( 'The post\'s date/time formated as "date @ time".', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The post\'s date/time formated as "date @ time".', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_blogurl]</code></dt>' .
'<dd>' . \wp_kses( __( 'The URL to the site.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The URL to the site.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_blogname]</code></dt>' .
'<dd>' . \wp_kses( __( 'The name of the site.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The name of the site.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'<dt><code>[ap_blogdesc]</code></dt>' .
'<dd>' . \wp_kses( __( 'The description of the site.', 'activitypub' ), 'default' ) . '</dd>' .
'<dd>' . \wp_kses( __( 'The description of the site.', 'activitypub' ), array( 'code' => array() ) ) . '</dd>' .
'</dl>' .
'<p>' . __( 'You may also use any Shortcode normally available to you on your site, however be aware that Shortcodes may significantly increase the size of your content depending on what they do.', 'activitypub' ) . '</p>' .
'<p>' . __( 'Note: the old Template Tags are now deprecated and automatically converted to the new ones.', 'activitypub' ) . '</p>' .
@ -48,8 +48,8 @@
\get_current_screen()->add_help_tab(
array(
'id' => 'glossar',
'title' => \__( 'Glossar', 'activitypub' ),
'id' => 'glossary',
'title' => \__( 'Glossary', 'activitypub' ),
'content' =>
'<p><h2>' . \__( 'Fediverse', 'activitypub' ) . '</h2></p>' .
'<p>' . \__( 'The Fediverse is a new word made of two words: "federation" + "universe"', 'activitypub' ) . '</p>' .
@ -71,7 +71,5 @@
\get_current_screen()->set_help_sidebar(
'<p><strong>' . \__( 'For more information:', 'activitypub' ) . '</strong></p>' .
'<p>' . \__( '<a href="https://wordpress.org/support/plugin/activitypub/">Get support</a>', 'activitypub' ) . '</p>' .
'<p>' . \__( '<a href="https://github.com/pfefferle/wordpress-activitypub/issues">Report an issue</a>', 'activitypub' ) . '</p>' .
'<hr />' .
'<p>' . \__( '<a href="https://notiz.blog/donate">Donate</a>', 'activitypub' ) . '</p>'
'<p>' . \__( '<a href="https://github.com/automattic/wordpress-activitypub/issues">Report an issue</a>', 'activitypub' ) . '</p>'
);

View file

@ -1,137 +0,0 @@
<?php
namespace Activitypub\Model;
/**
* ActivityPub Post Class
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/
*/
class 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;
private $tag = 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 = \Activitypub\get_context();
}
$this->type = \ucfirst( $type );
$this->published = \gmdate( '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( Post $post ) {
$this->object = $post->to_array();
if ( isset( $object['published'] ) ) {
$this->published = $object['published'];
}
$this->cc = array( \get_rest_url( null, '/activitypub/1.0/users/' . intval( $post->get_post_author() ) . '/followers' ) );
if ( isset( $this->object['attributedTo'] ) ) {
$this->actor = $this->object['attributedTo'];
}
foreach ( $post->get_tags() as $tag ) {
if ( 'Mention' === $tag['type'] ) {
$this->cc[] = $tag['href'];
}
}
$type = \strtolower( $this->type );
if ( isset( $this->object['id'] ) ) {
$this->id = add_query_arg( 'activity', $type, $this->object['id'] );
}
}
public function from_comment( $object ) {
$this->object = $object;
$this->published = $object['published'];
$this->actor = $object['attributedTo'];
$this->id = $object['id'] . '-activity';
$this->cc = $object['cc'];
$this->tag = $object['tag'];
}
public function to_comment( $timestamp ) {
if ( $this->trash ) {
$this->deleted = $timestamp['deleted'];
}
if ( $this->updated ) {
$this->updated = $timestamp['updated'];
}
}
public function from_remote_array( $array ) {
}
public function to_array() {
$array = array_filter( \get_object_vars( $this ) );
if ( $this->context ) {
$array = array( '@context' => $this->context ) + $array;
}
unset( $array['context'] );
return $array;
}
/**
* Convert to JSON
*
* @return void
*/
public function to_json() {
return \wp_json_encode( $this->to_array(), \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT );
}
public function to_simple_array() {
$activity = array(
'@context' => $this->context,
'type' => $this->type,
'actor' => $this->actor,
'object' => $this->object,
'to' => $this->to,
'cc' => $this->cc,
);
if ( $this->id ) {
$activity['id'] = $this->id;
}
return $activity;
}
public function to_simple_json() {
return \wp_json_encode( $this->to_simple_array(), \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT );
}
}

View file

@ -0,0 +1,109 @@
<?php
namespace Activitypub\Model;
use WP_Query;
use Activitypub\Signature;
use Activitypub\Collection\Users;
use function Activitypub\get_rest_url_by_path;
class Application_User extends Blog_User {
/**
* The User-ID
*
* @var int
*/
protected $_id = Users::APPLICATION_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
/**
* The User-Type
*
* @var string
*/
protected $type = 'Application';
/**
* Get the User-Url.
*
* @return string The User-Url.
*/
public function get_url() {
return get_rest_url_by_path( 'application' );
}
public function get_name() {
return 'application';
}
public function get_username() {
return $this::get_name();
}
public function get__public_key() {
$key = \get_option( 'activitypub_application_user_public_key' );
if ( $key ) {
return $key;
}
$this->generate_key_pair();
$key = \get_option( 'activitypub_application_user_public_key' );
return $key;
}
/**
* @param int $user_id
*
* @return mixed
*/
public function get__private_key() {
$key = \get_option( 'activitypub_application_user_private_key' );
if ( $key ) {
return $key;
}
$this->generate_key_pair();
return \get_option( 'activitypub_application_user_private_key' );
}
private function generate_key_pair() {
$key_pair = Signature::generate_key_pair();
if ( ! is_wp_error( $key_pair ) ) {
\update_option( 'activitypub_application_user_public_key', $key_pair['public_key'] );
\update_option( 'activitypub_application_user_private_key', $key_pair['private_key'] );
}
}
public function get_inbox() {
return null;
}
public function get_outbox() {
return null;
}
public function get_followers() {
return null;
}
public function get_following() {
return null;
}
public function get_attachment() {
return array();
}
public function get_featured_tags() {
return array();
}
public function get_featured() {
return array();
}
}

View file

@ -0,0 +1,243 @@
<?php
namespace Activitypub\Model;
use WP_Query;
use Activitypub\Signature;
use Activitypub\Collection\Users;
use function Activitypub\is_single_user;
use function Activitypub\is_user_disabled;
class Blog_User extends User {
/**
* The User-ID
*
* @var int
*/
protected $_id = Users::BLOG_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
/**
* The User-Type
*
* @var string
*/
protected $type = 'Group';
/**
* Is Account discoverable?
*
* @var boolean
*/
protected $discoverable = true;
public static function from_wp_user( $user_id ) {
if ( is_user_disabled( $user_id ) ) {
return new WP_Error(
'activitypub_user_not_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
}
$object = new static();
$object->_id = $user_id;
return $object;
}
/**
* Get the User-Name.
*
* @return string The User-Name.
*/
public function get_name() {
return \esc_html( \get_bloginfo( 'name' ) );
}
/**
* Get the User-Description.
*
* @return string The User-Description.
*/
public function get_summary() {
return \wpautop(
\wp_kses(
\get_bloginfo( 'description' ),
'default'
)
);
}
/**
* Get the User-Url.
*
* @return string The User-Url.
*/
public function get_url() {
return \esc_url( \trailingslashit( get_home_url() ) . '@' . $this->get_preferred_username() );
}
/**
* Returns the User-URL with @-Prefix for the username.
*
* @return string The User-URL with @-Prefix for the username.
*/
public function get_at_url() {
return \esc_url( \trailingslashit( get_home_url() ) . '@' . $this->get_preferred_username() );
}
/**
* Generate a default Username.
*
* @return string The auto-generated Username.
*/
public static function get_default_username() {
// check if domain host has a subdomain
$host = \wp_parse_url( \get_home_url(), \PHP_URL_HOST );
$host = \preg_replace( '/^www\./i', '', $host );
/**
* Filter the default blog username.
*
* @param string $host The default username.
*/
return apply_filters( 'activitypub_default_blog_username', $host );
}
/**
* Get the preferred User-Name.
*
* @return string The User-Name.
*/
public function get_preferred_username() {
$username = \get_option( 'activitypub_blog_user_identifier' );
if ( $username ) {
return $username;
}
return self::get_default_username();
}
/**
* Get the User-Icon.
*
* @return array|null The User-Icon.
*/
public function get_icon() {
// try site icon first
$icon_id = get_option( 'site_icon' );
// try custom logo second
if ( ! $icon_id ) {
$icon_id = get_theme_mod( 'custom_logo' );
}
if ( ! $icon_id ) {
return null;
}
$image = wp_get_attachment_image_src( $icon_id, 'full' );
return array(
'type' => 'Image',
'url' => esc_url( $image[0] ),
);
}
/**
* Get the User-Header-Image.
*
* @return array|null The User-Header-Image.
*/
public function get_header_image() {
if ( \has_header_image() ) {
return array(
'type' => 'Image',
'url' => esc_url( \get_header_image() ),
);
}
return null;
}
public function get_published() {
$first_post = new WP_Query(
array(
'orderby' => 'date',
'order' => 'ASC',
'number' => 1,
)
);
if ( ! empty( $first_post->posts[0] ) ) {
$time = \strtotime( $first_post->posts[0]->post_date_gmt );
} else {
$time = \time();
}
return \gmdate( 'Y-m-d\TH:i:s\Z', $time );
}
public function get__public_key() {
$key = \get_option( 'activitypub_blog_user_public_key' );
if ( $key ) {
return $key;
}
$this->generate_key_pair();
$key = \get_option( 'activitypub_blog_user_public_key' );
return $key;
}
/**
* Get the User-Private-Key.
*
* @param int $user_id
*
* @return mixed
*/
public function get__private_key() {
$key = \get_option( 'activitypub_blog_user_private_key' );
if ( $key ) {
return $key;
}
$this->generate_key_pair();
return \get_option( 'activitypub_blog_user_private_key' );
}
private function generate_key_pair() {
$key_pair = Signature::generate_key_pair();
if ( ! is_wp_error( $key_pair ) ) {
\update_option( 'activitypub_blog_user_public_key', $key_pair['public_key'] );
\update_option( 'activitypub_blog_user_private_key', $key_pair['private_key'] );
}
}
public function get_attachment() {
return array();
}
public function get_canonical_url() {
return \home_url();
}
/**
* Get the type of the object.
*
* If the Blog is in "single user" mode, return "Person" insted of "Group".
*
* @return string The type of the object.
*/
public function get_type() {
if ( is_single_user() ) {
return 'Person';
} else {
return $this->type;
}
}
}

View file

@ -0,0 +1,335 @@
<?php
namespace Activitypub\Model;
use WP_Query;
use Activitypub\Activity\Actor;
use Activitypub\Collection\Followers;
/**
* ActivityPub Follower Class
*
* This Object represents a single Follower.
* There is no direct reference to a WordPress User here.
*
* @author Matt Wiebe
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#follow-activity-inbox
*/
class Follower extends Actor {
/**
* The complete Remote-Profile of the Follower
*
* @var array
*/
protected $_id; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
/**
* Get the errors.
*
* @return mixed
*/
public function get_errors() {
return get_post_meta( $this->_id, 'activitypub_errors' );
}
/**
* Get the Summary.
*
* @return int The Summary.
*/
public function get_summary() {
if ( isset( $this->summary ) ) {
return $this->summary;
}
return '';
}
/**
* Getter for URL attribute.
*
* Falls back to ID, if no URL is set. This is relevant for
* Plattforms like Lemmy, where the ID is the URL.
*
* @return string The URL.
*/
public function get_url() {
if ( $this->url ) {
return $this->url;
}
return $this->id;
}
/**
* Reset (delete) all errors.
*
* @return void
*/
public function reset_errors() {
delete_post_meta( $this->_id, 'activitypub_errors' );
}
/**
* Count the errors.
*
* @return int The number of errors.
*/
public function count_errors() {
$errors = $this->get_errors();
if ( is_array( $errors ) && ! empty( $errors ) ) {
return count( $errors );
}
return 0;
}
/**
* Return the latest error message.
*
* @return string The error message.
*/
public function get_latest_error_message() {
$errors = $this->get_errors();
if ( is_array( $errors ) && ! empty( $errors ) ) {
return reset( $errors );
}
return '';
}
/**
* Update the current Follower-Object.
*
* @return void
*/
public function update() {
$this->save();
}
/**
* Save the current Follower-Object.
*
* @return void
*/
public function save() {
if ( ! $this->get__id() ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s",
esc_sql( $this->get_id() )
)
);
if ( $post_id ) {
$post = get_post( $post_id );
$this->set__id( $post->ID );
}
}
$args = array(
'ID' => $this->get__id(),
'guid' => esc_url_raw( $this->get_id() ),
'post_title' => wp_strip_all_tags( sanitize_text_field( $this->get_name() ) ),
'post_author' => 0,
'post_type' => Followers::POST_TYPE,
'post_name' => esc_url_raw( $this->get_id() ),
'post_excerpt' => sanitize_text_field( wp_kses( $this->get_summary(), 'user_description' ) ),
'post_status' => 'publish',
'meta_input' => $this->get_post_meta_input(),
);
$post_id = wp_insert_post( $args );
$this->_id = $post_id;
}
/**
* Upsert the current Follower-Object.
*
* @return void
*/
public function upsert() {
$this->save();
}
/**
* Delete the current Follower-Object.
*
* Beware that this os deleting a Follower for ALL users!!!
*
* To delete only the User connection (unfollow)
* @see \Activitypub\Rest\Followers::remove_follower()
*
* @return void
*/
public function delete() {
wp_delete_post( $this->_id );
}
/**
* Update the post meta.
*
* @return void
*/
protected function get_post_meta_input() {
$meta_input = array();
$meta_input['activitypub_inbox'] = $this->get_shared_inbox();
$meta_input['activitypub_actor_json'] = $this->to_json();
return $meta_input;
}
/**
* Get the icon.
*
* Sets a fallback to better handle API and HTML outputs.
*
* @return array The icon.
*/
public function get_icon() {
if ( isset( $this->icon['url'] ) ) {
return $this->icon;
}
return array(
'type' => 'Image',
'mediaType' => 'image/jpeg',
'url' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
);
}
/**
* Get Name.
*
* Tries to extract a name from the URL or ID if not set.
*
* @return string The name.
*/
public function get_name() {
if ( $this->name ) {
return $this->name;
} elseif ( $this->preferred_username ) {
return $this->preferred_username;
}
return $this->extract_name_from_uri();
}
/**
* The preferred Username.
*
* Tries to extract a name from the URL or ID if not set.
*
* @return string The preferred Username.
*/
public function get_preferred_username() {
if ( $this->preferred_username ) {
return $this->preferred_username;
}
return $this->extract_name_from_uri();
}
/**
* Get the Icon URL (Avatar)
*
* @return string The URL to the Avatar.
*/
public function get_icon_url() {
$icon = $this->get_icon();
if ( ! $icon ) {
return '';
}
if ( is_array( $icon ) ) {
return $icon['url'];
}
return $icon;
}
/**
* Get the shared inbox, with a fallback to the inbox.
*
* @return string|null The URL to the shared inbox, the inbox or null.
*/
public function get_shared_inbox() {
if ( ! empty( $this->get_endpoints()['sharedInbox'] ) ) {
return $this->get_endpoints()['sharedInbox'];
} elseif ( ! empty( $this->get_inbox() ) ) {
return $this->get_inbox();
}
return null;
}
/**
* Convert a Custom-Post-Type input to an Activitypub\Model\Follower.
*
* @return string The JSON string.
*
* @return array Activitypub\Model\Follower
*/
public static function init_from_cpt( $post ) {
$actor_json = get_post_meta( $post->ID, 'activitypub_actor_json', true );
$object = self::init_from_json( $actor_json );
$object->set__id( $post->ID );
$object->set_id( $post->guid );
$object->set_name( $post->post_title );
$object->set_summary( $post->post_excerpt );
$object->set_published( gmdate( 'Y-m-d H:i:s', strtotime( $post->post_published ) ) );
$object->set_updated( gmdate( 'Y-m-d H:i:s', strtotime( $post->post_modified ) ) );
return $object;
}
/**
* Infer a shortname from the Actor ID or URL. Used only for fallbacks,
* we will try to use what's supplied.
*
* @return string Hopefully the name of the Follower.
*/
protected function extract_name_from_uri() {
// prefer the URL, but fall back to the ID.
if ( $this->url ) {
$name = $this->url;
} else {
$name = $this->id;
}
if ( \filter_var( $name, FILTER_VALIDATE_URL ) ) {
$name = \rtrim( $name, '/' );
$path = \wp_parse_url( $name, PHP_URL_PATH );
if ( $path ) {
if ( \strpos( $name, '@' ) !== false ) {
// expected: https://example.com/@user (default URL pattern)
$name = \preg_replace( '|^/@?|', '', $path );
} else {
// expected: https://example.com/users/user (default ID pattern)
$parts = \explode( '/', $path );
$name = \array_pop( $parts );
}
}
} elseif (
\is_email( $name ) ||
\strpos( $name, 'acct' ) === 0 ||
\strpos( $name, '@' ) === 0
) {
// expected: user@example.com or acct:user@example (WebFinger)
$name = \ltrim( $name, '@' );
$name = \ltrim( $name, 'acct:' );
$parts = \explode( '@', $name );
$name = $parts[0];
}
return $name;
}
}

View file

@ -1,12 +1,21 @@
<?php
namespace Activitypub\Model;
use Activitypub\Transformer\Post as Post_Transformer;
/**
* ActivityPub Post Class
*
* @author Matthias Pfefferle
*/
class Post {
/**
* The \Activitypub\Activity\Base_Object object.
*
* @var \Activitypub\Activity\Base_Object
*/
protected $object;
/**
* The WordPress Post Object.
*
@ -14,171 +23,47 @@ class Post {
*/
private $post;
/**
* The Post Author.
*
* @var string
*/
private $post_author;
/**
* The Object ID.
*
* @var string
*/
private $id;
/**
* The Object Summary.
*
* @var string
*/
private $summary;
/**
* The Object Summary
*
* @var string
*/
private $content;
/**
* The Object Attachments. This is usually a list of Images.
*
* @var array
*/
private $attachments;
/**
* The Object Tags. This is usually the list of used Hashtags.
*
* @var array
*/
private $tags;
/**
* The Object Type
*
* @var string
*/
private $object_type;
/**
* The Allowed Tags, used in the content.
*
* @var array
*/
private $allowed_tags = array(
'a' => array(
'href' => array(),
'title' => array(),
'class' => array(),
'rel' => array(),
),
'br' => array(),
'p' => array(
'class' => array(),
),
'span' => array(
'class' => array(),
),
'div' => array(
'class' => array(),
),
'ul' => array(),
'ol' => array(),
'li' => array(),
'strong' => array(
'class' => array(),
),
'b' => array(
'class' => array(),
),
'i' => array(
'class' => array(),
),
'em' => array(
'class' => array(),
),
'blockquote' => array(),
'cite' => array(),
);
private $delete;
private $updated;
private $replies;
/**
* Constructor
*
* @param WP_Post $post
* @param int $post_author
*/
public function __construct( $post ) {
$this->post = \get_post( $post );
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
public function __construct( $post, $post_author = null ) {
_deprecated_function( __CLASS__, '1.0.0', '\Activitypub\Transformer\Post' );
$this->post = $post;
$this->object = Post_Transformer::transform( $post )->to_object();
}
/**
* Magic function to implement getter and setter
* Returns the User ID.
*
* @param string $method
* @param string $params
*
* @return void
* @return int the User ID.
*/
public function __call( $method, $params ) {
$var = \strtolower( \substr( $method, 4 ) );
if ( \strncasecmp( $method, 'get', 3 ) === 0 ) {
if ( empty( $this->$var ) && ! empty( $this->post->$var ) ) {
return $this->post->$var;
}
return $this->$var;
}
if ( \strncasecmp( $method, 'set', 3 ) === 0 ) {
$this->$var = $params[0];
}
public function get_user_id() {
return apply_filters( 'activitypub_post_user_id', $this->post->post_author, $this->post );
}
/**
* Converts this Object into an Array.
*
* @return array
* @return array the array representation of a Post.
*/
public function to_array() {
$post = $this->post;
return \apply_filters( 'activitypub_post', $this->object->to_array(), $this->post );
}
$array = array(
'id' => $this->get_id(),
'type' => $this->get_object_type(),
'published' => \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_date_gmt ) ),
'attributedTo' => \get_author_posts_url( $post->post_author ),
'summary' => $this->get_summary(),
'inReplyTo' => null,
'url' => \get_permalink( $post->ID ),
'content' => $this->get_content(),
'contentMap' => array(
\strstr( \get_locale(), '_', true ) => $this->get_content(),
),
'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'cc' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
'attachment' => $this->get_attachments(),
'tag' => $this->get_tags(),
);
if ( $this->replies ) { //has comments
$array['replies'] = $this->get_replies();
}
if ( $this->deleted ) { //is trash
$array['deleted'] = \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_modified_gmt ) );
$deleted_post_slug = \get_post_meta( $post->ID, 'activitypub_canonical_url', true );
if ( $deleted_post_slug ) {
$array['id'] = $deleted_post_slug;
}
}
if ( $this->updated ) { //post_modified
$array['updated'] = \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_modified_gmt ) );
}
return \apply_filters( 'activitypub_post', $array );
/**
* Returns the Actor of this Object.
*
* @return string The URL of the Actor.
*/
public function get_actor() {
$user = User_Factory::get_by_id( $this->get_user_id() );
return $user->get_url();
}
/**
@ -190,32 +75,22 @@ class Post {
return \wp_json_encode( $this->to_array(), \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT );
}
/**
* Returns the URL of an Activity Object
*
* @return string
*/
public function get_url() {
return $this->object->get_url();
}
/**
* Returns the ID of an Activity Object
*
* @return string
*/
public function get_id() {
if ( $this->id ) {
return $this->id;
}
$post = $this->post;
if ( 'trash' === get_post_status( $post ) && \get_post_meta( $post->ID, 'activitypub_canonical_url', true ) ) {
$object_id = \get_post_meta( $post->ID, 'activitypub_canonical_url', true );
} else {
$object_id = \add_query_arg(
array(
'p' => $post->ID,
),
\trailingslashit( \site_url() )
);
}
$this->id = $object_id;
return $object_id;
return $this->object->get_id();
}
/**
@ -224,73 +99,7 @@ class Post {
* @return array
*/
public function get_attachments() {
if ( $this->attachments ) {
return $this->attachments;
}
$max_images = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) );
$images = array();
// max images can't be negative or zero
if ( $max_images <= 0 ) {
return $images;
}
$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--;
}
if ( $max_images > 0 ) {
// 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, true ) ) {
$image_ids[] = $attachment->ID;
}
}
}
$image_ids = \array_unique( $image_ids );
// get URLs for each image
foreach ( $image_ids as $id ) {
$alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
$thumbnail = \wp_get_attachment_image_src( $id, 'full' );
$mimetype = \get_post_mime_type( $id );
if ( $thumbnail ) {
$image = array(
'type' => 'Image',
'url' => $thumbnail[0],
'mediaType' => $mimetype,
);
if ( $alt ) {
$image['name'] = $alt;
}
$images[] = $image;
}
}
$this->attachments = $images;
return $images;
return $this->object->get_attachment();
}
/**
@ -299,39 +108,7 @@ class Post {
* @return array
*/
public function get_tags() {
if ( $this->tags ) {
return $this->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->slug,
);
$tags[] = $tag;
}
}
$mentions = apply_filters( 'activitypub_extract_mentions', array(), $this->post->post_content, $this );
if ( $mentions ) {
foreach ( $mentions as $mention => $url ) {
$tag = array(
'type' => 'Mention',
'href' => $url,
'name' => $mention,
);
$tags[] = $tag;
}
}
$this->tags = $tags;
return $tags;
return $this->object->get_tag();
}
public function get_replies() {
@ -391,66 +168,7 @@ class Post {
* @return string the object-type
*/
public function get_object_type() {
if ( $this->object_type ) {
return $this->object_type;
}
if ( 'wordpress-post-format' !== \get_option( 'activitypub_object_type', 'note' ) ) {
return \ucfirst( \get_option( 'activitypub_object_type', 'note' ) );
}
$post_type = \get_post_type( $this->post );
switch ( $post_type ) {
case 'post':
$post_format = \get_post_format( $this->post );
switch ( $post_format ) {
case 'aside':
case 'status':
case 'quote':
case 'note':
$object_type = 'Note';
break;
case 'gallery':
case 'image':
$object_type = 'Image';
break;
case 'video':
$object_type = 'Video';
break;
case 'audio':
$object_type = 'Audio';
break;
default:
$object_type = 'Article';
break;
}
break;
case 'page':
$object_type = 'Page';
break;
case 'attachment':
$mime_type = \get_post_mime_type();
$media_type = \preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type );
switch ( $media_type ) {
case 'audio':
$object_type = 'Audio';
break;
case 'video':
$object_type = 'Video';
break;
case 'image':
$object_type = 'Image';
break;
}
break;
default:
$object_type = 'Article';
break;
}
$this->object_type = $object_type;
return $object_type;
return $this->object->get_type();
}
/**
@ -459,93 +177,7 @@ class Post {
* @return string the content
*/
public function get_content() {
global $post;
if ( $this->content ) {
return $this->content;
}
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$post = $this->post;
$content = $this->get_post_content_template();
// Fill in the shortcodes.
setup_postdata( $post );
$content = do_shortcode( $content );
wp_reset_postdata();
$content = \wpautop( \wp_kses( $content, $this->allowed_tags ) );
$content = \trim( \preg_replace( '/[\n\r\t]/', '', $content ) );
$content = \apply_filters( 'activitypub_the_content', $content, $post );
$content = \html_entity_decode( $content, \ENT_QUOTES, 'UTF-8' );
$this->content = $content;
return $content;
}
/**
* Gets the template to use to generate the content of the activitypub item.
*
* @return string the template
*/
public function get_post_content_template() {
if ( 'excerpt' === \get_option( 'activitypub_post_content_type', 'content' ) ) {
return "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
}
if ( 'title' === \get_option( 'activitypub_post_content_type', 'content' ) ) {
return "[ap_title]\n\n[ap_permalink type=\"html\"]";
}
if ( 'content' === \get_option( 'activitypub_post_content_type', 'content' ) ) {
return "[ap_content]\n\n[ap_hashtags]\n\n[ap_permalink type=\"html\"]";
}
// Upgrade from old template codes to shortcodes.
$content = self::upgrade_post_content_template();
return $content;
}
/**
* Updates the custom template to use shortcodes instead of the deprecated templates.
*
* @return string the updated template content
*/
public static function upgrade_post_content_template() {
// Get the custom template.
$old_content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
// If the old content exists but is a blank string, we're going to need a flag to updated it even
// after setting it to the default contents.
$need_update = false;
// If the old contents is blank, use the defaults.
if ( '' === $old_content ) {
$old_content = ACTIVITYPUB_CUSTOM_POST_CONTENT;
$need_update = true;
}
// Set the new content to be the old content.
$content = $old_content;
// Convert old templates to shortcodes.
$content = \str_replace( '%title%', '[ap_title]', $content );
$content = \str_replace( '%excerpt%', '[ap_excerpt]', $content );
$content = \str_replace( '%content%', '[ap_content]', $content );
$content = \str_replace( '%permalink%', '[ap_permalink type="html"]', $content );
$content = \str_replace( '%shortlink%', '[ap_shortlink type="html"]', $content );
$content = \str_replace( '%hashtags%', '[ap_hashtags]', $content );
$content = \str_replace( '%tags%', '[ap_hashtags]', $content );
// Store the new template if required.
if ( $content !== $old_content || $need_update ) {
\update_option( 'activitypub_custom_post_content', $content );
}
return $content;
return $this->object->get_content();
}
/**

View file

@ -0,0 +1,304 @@
<?php
namespace Activitypub\Model;
use WP_Query;
use WP_Error;
use Activitypub\Signature;
use Activitypub\Collection\Users;
use Activitypub\Activity\Actor;
use function Activitypub\is_user_disabled;
use function Activitypub\get_rest_url_by_path;
class User extends Actor {
/**
* The local User-ID (WP_User).
*
* @var int
*/
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
*
* @var string
*/
protected $type = 'Person';
public static function from_wp_user( $user_id ) {
if ( is_user_disabled( $user_id ) ) {
return new WP_Error(
'activitypub_user_not_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
}
$object = new static();
$object->_id = $user_id;
return $object;
}
/**
* Get the User-ID.
*
* @return string The User-ID.
*/
public function get_id() {
return $this->get_url();
}
/**
* Get the User-Name.
*
* @return string The User-Name.
*/
public function get_name() {
return \esc_attr( \get_the_author_meta( 'display_name', $this->_id ) );
}
/**
* Get the User-Description.
*
* @return string The User-Description.
*/
public function get_summary() {
$description = get_user_meta( $this->_id, 'activitypub_user_description', true );
if ( empty( $description ) ) {
$description = get_user_meta( $this->_id, 'description', true );
}
return \wpautop( \wp_kses( $description, 'default' ) );
}
/**
* Get the User-Url.
*
* @return string The User-Url.
*/
public function get_url() {
return \esc_url( \get_author_posts_url( $this->_id ) );
}
/**
* Returns the User-URL with @-Prefix for the username.
*
* @return string The User-URL with @-Prefix for the username.
*/
public function get_at_url() {
return \esc_url( \trailingslashit( get_home_url() ) . '@' . $this->get_username() );
}
public function get_preferred_username() {
return \esc_attr( \get_the_author_meta( 'login', $this->_id ) );
}
public function get_icon() {
$icon = \esc_url(
\get_avatar_url(
$this->_id,
array( 'size' => 120 )
)
);
return array(
'type' => 'Image',
'url' => $icon,
);
}
public function get_image() {
if ( \has_header_image() ) {
$image = \esc_url( \get_header_image() );
return array(
'type' => 'Image',
'url' => $image,
);
}
return null;
}
public function get_published() {
return \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( \get_the_author_meta( 'registered', $this->_id ) ) );
}
public function get_public_key() {
return array(
'id' => $this->get_id() . '#main-key',
'owner' => $this->get_id(),
'publicKeyPem' => $this->get__public_key(),
);
}
/**
* @param int $this->get__id()
*
* @return mixed
*/
public function get__public_key() {
$key = \get_user_meta( $this->get__id(), 'magic_sig_public_key', true );
if ( $key ) {
return $key;
}
$this->generate_key_pair();
return \get_user_meta( $this->get__id(), 'magic_sig_public_key', true );
}
/**
* @param int $this->get__id()
*
* @return mixed
*/
public function get__private_key() {
$key = \get_user_meta( $this->get__id(), 'magic_sig_private_key', true );
if ( $key ) {
return $key;
}
$this->generate_key_pair();
return \get_user_meta( $this->get__id(), 'magic_sig_private_key', true );
}
private function generate_key_pair() {
$key_pair = Signature::generate_key_pair();
if ( ! is_wp_error( $key_pair ) ) {
\update_user_meta( $this->get__id(), 'magic_sig_public_key', $key_pair['public_key'], true );
\update_user_meta( $this->get__id(), 'magic_sig_private_key', $key_pair['private_key'], true );
}
}
/**
* Returns the Inbox-API-Endpoint.
*
* @return string The Inbox-Endpoint.
*/
public function get_inbox() {
return get_rest_url_by_path( sprintf( 'users/%d/inbox', $this->get__id() ) );
}
/**
* Returns the Outbox-API-Endpoint.
*
* @return string The Outbox-Endpoint.
*/
public function get_outbox() {
return get_rest_url_by_path( sprintf( 'users/%d/outbox', $this->get__id() ) );
}
/**
* Returns the Followers-API-Endpoint.
*
* @return string The Followers-Endpoint.
*/
public function get_followers() {
return get_rest_url_by_path( sprintf( 'users/%d/followers', $this->get__id() ) );
}
/**
* Returns the Following-API-Endpoint.
*
* @return string The Following-Endpoint.
*/
public function get_following() {
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.
*
* @return array The extended User-Output.
*/
public function get_attachment() {
$array = array();
$array[] = array(
'type' => 'PropertyValue',
'name' => \__( 'Blog', 'activitypub' ),
'value' => \html_entity_decode(
'<a rel="me" title="' . \esc_attr( \home_url( '/' ) ) . '" target="_blank" href="' . \home_url( '/' ) . '">' . \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) . '</a>',
\ENT_QUOTES,
'UTF-8'
),
);
$array[] = array(
'type' => 'PropertyValue',
'name' => \__( 'Profile', 'activitypub' ),
'value' => \html_entity_decode(
'<a rel="me" title="' . \esc_attr( \get_author_posts_url( $this->get__id() ) ) . '" target="_blank" href="' . \get_author_posts_url( $this->get__id() ) . '">' . \wp_parse_url( \get_author_posts_url( $this->get__id() ), \PHP_URL_HOST ) . '</a>',
\ENT_QUOTES,
'UTF-8'
),
);
if ( \get_the_author_meta( 'user_url', $this->get__id() ) ) {
$array[] = array(
'type' => 'PropertyValue',
'name' => \__( 'Website', 'activitypub' ),
'value' => \html_entity_decode(
'<a rel="me" title="' . \esc_attr( \get_the_author_meta( 'user_url', $this->get__id() ) ) . '" target="_blank" href="' . \get_the_author_meta( 'user_url', $this->get__id() ) . '">' . \wp_parse_url( \get_the_author_meta( 'user_url', $this->get__id() ), \PHP_URL_HOST ) . '</a>',
\ENT_QUOTES,
'UTF-8'
),
);
}
return $array;
}
/**
* Returns a user@domain type of identifier for the user.
*
* @return string The Webfinger-Identifier.
*/
public function get_resource() {
return $this->get_preferred_username() . '@' . \wp_parse_url( \home_url(), \PHP_URL_HOST );
}
public function get_canonical_url() {
return $this->get_url();
}
}

View file

@ -9,76 +9,26 @@ namespace Activitypub\Peer;
class Followers {
public static function get_followers( $author_id ) {
$followers = \get_user_option( 'activitypub_followers', $author_id );
_deprecated_function( __METHOD__, '1.0.0', '\Activitypub\Collection\Followers::get_followers' );
if ( ! $followers ) {
return array();
}
foreach ( $followers as $key => $follower ) {
if (
\is_array( $follower ) &&
isset( $follower['type'] ) &&
'Person' === $follower['type'] &&
isset( $follower['id'] ) &&
false !== \filter_var( $follower['id'], \FILTER_VALIDATE_URL )
) {
$followers[ $key ] = $follower['id'];
}
}
return $followers;
return \Activitypub\Collection\Followers::get_followers( $author_id );
}
public static function count_followers( $author_id ) {
$followers = self::get_followers( $author_id );
_deprecated_function( __METHOD__, '1.0.0', '\Activitypub\Collection\Followers::count_followers' );
return \count( $followers );
return \Activitypub\Collection\Followers::count_followers( $author_id );
}
public static function add_follower( $actor, $author_id ) {
$followers = \get_user_option( 'activitypub_followers', $author_id );
_deprecated_function( __METHOD__, '1.0.0', '\Activitypub\Collection\Followers::add_follower' );
if ( ! \is_string( $actor ) ) {
if (
\is_array( $actor ) &&
isset( $actor['type'] ) &&
'Person' === $actor['type'] &&
isset( $actor['id'] ) &&
false !== \filter_var( $actor['id'], \FILTER_VALIDATE_URL )
) {
$actor = $actor['id'];
}
return new \WP_Error(
'invalid_actor_object',
\__( 'Unknown Actor schema', 'activitypub' ),
array(
'status' => 404,
)
);
}
if ( ! \is_array( $followers ) ) {
$followers = array( $actor );
} else {
$followers[] = $actor;
}
$followers = \array_unique( $followers );
\update_user_meta( $author_id, 'activitypub_followers', $followers );
return \Activitypub\Collection\Followers::add_follower( $author_id, $actor );
}
public static function remove_follower( $actor, $author_id ) {
$followers = \get_user_option( 'activitypub_followers', $author_id );
_deprecated_function( __METHOD__, '1.0.0', '\Activitypub\Collection\Followers::remove_follower' );
foreach ( $followers as $key => $value ) {
if ( $value === $actor ) {
unset( $followers[ $key ] );
}
}
\update_user_meta( $author_id, 'activitypub_followers', $followers );
return \Activitypub\Collection\Followers::remove_follower( $author_id, $actor );
}
}

View file

@ -1,67 +0,0 @@
<?php
namespace Activitypub\Peer;
/**
* ActivityPub Users DB-Class
*
* @author Matthias Pfefferle
*/
class Users {
/**
* Undocumented function
*
* @return void
*/
public static function get_user_by_various( $data ) {
}
/**
* Examine a url and try to determine the author ID it represents.
*
* Checks are supposedly from the hosted site blog.
*
* @param string $url Permalink to check.
*
* @return int User ID, or 0 on failure.
*/
public static function url_to_authorid( $url ) {
global $wp_rewrite;
// check if url hase the same host
if ( \wp_parse_url( \site_url(), \PHP_URL_HOST ) !== \wp_parse_url( $url, \PHP_URL_HOST ) ) {
return 0;
}
// first, check to see if there is a 'author=N' to match against
if ( \preg_match( '/[?&]author=(\d+)/i', $url, $values ) ) {
$id = \absint( $values[1] );
if ( $id ) {
return $id;
}
}
// check to see if we are using rewrite rules
$rewrite = $wp_rewrite->wp_rewrite_rules();
// not using rewrite rules, and 'author=N' method failed, so we're out of options
if ( empty( $rewrite ) ) {
return 0;
}
// generate rewrite rule for the author url
$author_rewrite = $wp_rewrite->get_author_permastruct();
$author_regexp = \str_replace( '%author%', '', $author_rewrite );
// match the rewrite rule with the passed url
if ( \preg_match( '/https?:\/\/(.+)' . \preg_quote( $author_regexp, '/' ) . '([^\/]+)/i', $url, $match ) ) {
$user = \get_user_by( 'slug', $match[2] );
if ( $user ) {
return $user->ID;
}
}
return 0;
}
}

View file

@ -0,0 +1,176 @@
<?php
namespace Activitypub\Rest;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Transformer\Post;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub Collections REST-Class
*
* @author Matthias Pfefferle
*
* @see https://docs.joinmastodon.org/spec/activitypub/#featured
* @see https://docs.joinmastodon.org/spec/activitypub/#featuredTags
*/
class Collection {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
}
/**
* Register routes
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/users/(?P<user_id>[\w\-\.]+)/collections/tags',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'tags_get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/users/(?P<user_id>[\w\-\.]+)/collections/featured',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'featured_get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
}
/**
* The Featured Tags endpoint
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response object.
*/
public static function tags_get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$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' => Activity::CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'users/%d/collections/tags', $user->get__id() ) ),
'type' => 'Collection',
'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' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$sticky_posts = \get_option( 'sticky_posts' );
if ( ! is_single_user() && User_Collection::BLOG_USER_ID === $user->get__id() ) {
$posts = array();
} elseif ( $sticky_posts ) {
$args = array(
'post__in' => $sticky_posts,
'ignore_sticky_posts' => 1,
'orderby' => 'date',
'order' => 'DESC',
);
if ( $user->get__id() > 0 ) {
$args['author'] = $user->get__id();
}
$posts = \get_posts( $args );
} else {
$posts = array();
}
$response = array(
'@context' => Activity::CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'users/%d/collections/featured', $user_id ) ),
'type' => 'OrderedCollection',
'totalItems' => count( $posts ),
'orderedItems' => array(),
);
foreach ( $posts as $post ) {
$response['orderedItems'][] = Post::transform( $post )->to_object()->to_array();
}
return new WP_REST_Response( $response, 200 );
}
/**
* The supported parameters
*
* @return array list of parameters
*/
public static function request_parameters() {
$params = array();
$params['user_id'] = array(
'required' => true,
'type' => 'string',
);
return $params;
}
}

View file

@ -1,6 +1,15 @@
<?php
namespace Activitypub\Rest;
use WP_Error;
use stdClass;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Collection\Users as User_Collection;
use Activitypub\Collection\Followers as Follower_Collection;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub Followers REST-Class
*
@ -13,7 +22,7 @@ class Followers {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'rest_api_init', array( '\Activitypub\Rest\Followers', 'register_routes' ) );
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
}
/**
@ -21,12 +30,12 @@ class Followers {
*/
public static function register_routes() {
\register_rest_route(
'activitypub/1.0',
'/users/(?P<user_id>\d+)/followers',
ACTIVITYPUB_REST_NAMESPACE,
'/users/(?P<user_id>[\w\-\.]+)/followers',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( '\Activitypub\Rest\Followers', 'get' ),
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
@ -43,44 +52,58 @@ class Followers {
*/
public static function get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = \get_user_by( 'ID', $user_id );
$user = User_Collection::get_by_various( $user_id );
if ( ! $user ) {
return new \WP_Error(
'rest_invalid_param',
\__( 'User not found', 'activitypub' ),
array(
'status' => 404,
'params' => array(
'user_id' => \__( 'User not found', 'activitypub' ),
),
)
);
if ( is_wp_error( $user ) ) {
return $user;
}
$order = $request->get_param( 'order' );
$per_page = (int) $request->get_param( 'per_page' );
$page = (int) $request->get_param( 'page' );
$context = $request->get_param( 'context' );
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_outbox_pre' );
\do_action( 'activitypub_rest_followers_pre' );
$json = new \stdClass();
$data = Follower_Collection::get_followers_with_count( $user_id, $per_page, $page, array( 'order' => ucwords( $order ) ) );
$json = new stdClass();
$json->{'@context'} = \Activitypub\get_context();
$json->id = \home_url( \add_query_arg( null, null ) );
$json->id = get_rest_url_by_path( sprintf( 'users/%d/followers', $user->get__id() ) );
$json->generator = 'http://wordpress.org/?v=' . \get_bloginfo_rss( 'version' );
$json->actor = \get_author_posts_url( $user_id );
$json->actor = $user->get_id();
$json->type = 'OrderedCollectionPage';
$json->partOf = \get_rest_url( null, "/activitypub/1.0/users/$user_id/followers" ); // phpcs:ignore
$json->totalItems = \Activitypub\count_followers( $user_id ); // phpcs:ignore
$json->orderedItems = \Activitypub\Peer\Followers::get_followers( $user_id ); // phpcs:ignore
$json->totalItems = $data['total']; // phpcs:ignore
$json->partOf = get_rest_url_by_path( sprintf( 'users/%d/followers', $user->get__id() ) ); // phpcs:ignore
$json->first = $json->partOf; // phpcs:ignore
$json->first = \add_query_arg( 'page', 1, $json->partOf ); // phpcs:ignore
$json->last = \add_query_arg( 'page', \ceil ( $json->totalItems / $per_page ), $json->partOf ); // phpcs:ignore
$json->first = \get_rest_url( null, "/activitypub/1.0/users/$user_id/followers" );
if ( $page && ( ( \ceil ( $json->totalItems / $per_page ) ) > $page ) ) { // phpcs:ignore
$json->next = \add_query_arg( 'page', $page + 1, $json->partOf ); // phpcs:ignore
}
$response = new \WP_REST_Response( $json, 200 );
if ( $page && ( $page > 1 ) ) { // phpcs:ignore
$json->prev = \add_query_arg( 'page', $page - 1, $json->partOf ); // phpcs:ignore
}
// phpcs:ignore
$json->orderedItems = array_map(
function( $item ) use ( $context ) {
if ( 'full' === $context ) {
return $item->to_array();
}
return $item->get_url();
},
$data['followers']
);
$response = new WP_REST_Response( $json, 200 );
$response->header( 'Content-Type', 'application/activity+json' );
return $response;
@ -96,14 +119,29 @@ class Followers {
$params['page'] = array(
'type' => 'integer',
'default' => 1,
);
$params['per_page'] = array(
'type' => 'integer',
'default' => 20,
);
$params['order'] = array(
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
);
$params['user_id'] = array(
'required' => true,
'type' => 'integer',
'validate_callback' => function( $param, $request, $key ) {
return user_can( $param, 'publish_posts' );
},
'type' => 'string',
);
$params['context'] = array(
'type' => 'string',
'default' => 'simple',
'enum' => array( 'simple', 'full' ),
);
return $params;

View file

@ -1,6 +1,11 @@
<?php
namespace Activitypub\Rest;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\is_single_user;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub Following REST-Class
*
@ -13,7 +18,8 @@ class Following {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'rest_api_init', array( '\Activitypub\Rest\Following', 'register_routes' ) );
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
\add_filter( 'activitypub_rest_following', array( self::class, 'default_following' ), 10, 2 );
}
/**
@ -21,12 +27,12 @@ class Following {
*/
public static function register_routes() {
\register_rest_route(
'activitypub/1.0',
'/users/(?P<user_id>\d+)/following',
ACTIVITYPUB_REST_NAMESPACE,
'/users/(?P<user_id>[\w\-\.]+)/following',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( '\Activitypub\Rest\Following', 'get' ),
'callback' => array( self::class, 'get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
@ -43,38 +49,32 @@ class Following {
*/
public static function get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = \get_user_by( 'ID', $user_id );
$user = User_Collection::get_by_various( $user_id );
if ( ! $user ) {
return new \WP_Error(
'rest_invalid_param',
\__( 'User not found', 'activitypub' ),
array(
'status' => 404,
'params' => array(
'user_id' => \__( 'User not found', 'activitypub' ),
),
)
);
if ( is_wp_error( $user ) ) {
return $user;
}
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_outbox_pre' );
\do_action( 'activitypub_rest_following_pre' );
$json = new \stdClass();
$json->{'@context'} = \Activitypub\get_context();
$json->id = \home_url( \add_query_arg( null, null ) );
$json->id = get_rest_url_by_path( sprintf( 'users/%d/following', $user->get__id() ) );
$json->generator = 'http://wordpress.org/?v=' . \get_bloginfo_rss( 'version' );
$json->actor = \get_author_posts_url( $user_id );
$json->actor = $user->get_id();
$json->type = 'OrderedCollectionPage';
$json->partOf = \get_rest_url( null, "/activitypub/1.0/users/$user_id/following" ); // phpcs:ignore
$json->totalItems = 0; // phpcs:ignore
$json->orderedItems = apply_filters( 'activitypub_following', array(), $user ); // phpcs:ignore
$json->partOf = get_rest_url_by_path( sprintf( 'users/%d/following', $user->get__id() ) ); // 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,12 +98,32 @@ class Following {
$params['user_id'] = array(
'required' => true,
'type' => 'integer',
'validate_callback' => function( $param, $request, $key ) {
return user_can( $param, 'publish_posts' );
},
'type' => 'string',
);
return $params;
}
/**
* Add the Blog Authors to the following list of the Blog Actor
* if Blog not in single mode.
*
* @param array $array The array of following urls.
* @param User $user The user object.
*
* @return array The array of following urls.
*/
public static function default_following( $array, $user ) {
if ( 0 !== $user->get__id() || is_single_user() ) {
return $array;
}
$users = User_Collection::get_collection();
foreach ( $users as $user ) {
$array[] = $user->get_url();
}
return $array;
}
}

View file

@ -1,6 +1,17 @@
<?php
namespace Activitypub\Rest;
use WP_Error;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\get_context;
use function Activitypub\url_to_authorid;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\get_remote_metadata_by_actor;
/**
* ActivityPub Inbox REST-Class
*
@ -13,15 +24,9 @@ class Inbox {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'rest_api_init', array( '\Activitypub\Rest\Inbox', 'register_routes' ) );
\add_filter( 'rest_pre_serve_request', array( '\Activitypub\Rest\Inbox', 'serve_request' ), 11, 4 );
\add_action( 'activitypub_inbox_follow', array( '\Activitypub\Rest\Inbox', 'handle_follow' ), 10, 2 );
\add_action( 'activitypub_inbox_undo', array( '\Activitypub\Rest\Inbox', 'handle_unfollow' ), 10, 2 );
//\add_action( 'activitypub_inbox_like', array( '\Activitypub\Rest\Inbox', 'handle_reaction' ), 10, 2 );
//\add_action( 'activitypub_inbox_announce', array( '\Activitypub\Rest\Inbox', 'handle_reaction' ), 10, 2 );
\add_action( 'activitypub_inbox_create', array( '\Activitypub\Rest\Inbox', 'handle_create' ), 10, 2 );
\add_action( 'activitypub_inbox_update', array( '\Activitypub\Rest\Inbox', 'handle_update' ), 10, 2 );
\add_action( 'activitypub_inbox_delete', array( '\Activitypub\Rest\Inbox', 'handle_delete' ), 10, 2 );
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
\add_action( 'activitypub_inbox_create', array( self::class, 'handle_create' ), 10, 2 );
}
/**
@ -29,12 +34,12 @@ class Inbox {
*/
public static function register_routes() {
\register_rest_route(
'activitypub/1.0',
ACTIVITYPUB_REST_NAMESPACE,
'/inbox',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( '\Activitypub\Rest\Inbox', 'shared_inbox_post' ),
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( self::class, 'shared_inbox_post' ),
'args' => self::shared_inbox_post_parameters(),
'permission_callback' => '__return_true',
),
@ -42,18 +47,18 @@ class Inbox {
);
\register_rest_route(
'activitypub/1.0',
'/users/(?P<user_id>\d+)/inbox',
ACTIVITYPUB_REST_NAMESPACE,
'/users/(?P<user_id>[\w\-\.]+)/inbox',
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( '\Activitypub\Rest\Inbox', 'user_inbox_post' ),
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( self::class, 'user_inbox_post' ),
'args' => self::user_inbox_post_parameters(),
'permission_callback' => '__return_true',
),
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( '\Activitypub\Rest\Inbox', 'user_inbox_get' ),
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'user_inbox_get' ),
'args' => self::user_inbox_get_parameters(),
'permission_callback' => '__return_true',
),
@ -61,35 +66,6 @@ class 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;
}
$signature = $request->get_header( 'signature' );
if ( ! $signature ) {
return $served;
}
$headers = $request->get_headers();
// verify signature
//\Activitypub\Signature::verify_signature( $headers, $key );
return $served;
}
/**
* Renders the user-inbox
*
@ -98,20 +74,26 @@ class Inbox {
*/
public static function user_inbox_get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$page = $request->get_param( 'page', 0 );
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_inbox_pre' );
\do_action( 'activitypub_rest_inbox_pre' );
$json = new \stdClass();
$json->{'@context'} = \Activitypub\get_context();
$json->id = \home_url( \add_query_arg( null, null ) );
$json->{'@context'} = get_context();
$json->id = get_rest_url_by_path( sprintf( 'users/%d/inbox', $user->get__id() ) );
$json->generator = 'http://wordpress.org/?v=' . \get_bloginfo_rss( 'version' );
$json->type = 'OrderedCollectionPage';
$json->partOf = \get_rest_url( null, "/activitypub/1.0/users/$user_id/inbox" ); // phpcs:ignore
$json->partOf = get_rest_url_by_path( sprintf( 'users/%d/inbox', $user->get__id() ) ); // phpcs:ignore
$json->totalItems = 0; // phpcs:ignore
@ -120,14 +102,14 @@ 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
*/
\do_action( 'activitypub_inbox_post' );
$response = new \WP_REST_Response( $json, 200 );
$response = new WP_REST_Response( $json, 200 );
$response->header( 'Content-Type', 'application/activity+json' );
@ -143,15 +125,20 @@ class Inbox {
*/
public static function user_inbox_post( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$data = $request->get_params();
$type = $request->get_param( 'type' );
$type = \strtolower( $type );
\do_action( 'activitypub_inbox', $data, $user_id, $type );
\do_action( "activitypub_inbox_{$type}", $data, $user_id );
\do_action( 'activitypub_inbox', $data, $user->get__id(), $type );
\do_action( "activitypub_inbox_{$type}", $data, $user->get__id() );
return new \WP_REST_Response( array(), 202 );
return new WP_REST_Response( array(), 202 );
}
/**
@ -167,7 +154,7 @@ class Inbox {
$users = self::extract_recipients( $data );
if ( ! $users ) {
return new \WP_Error(
return new WP_Error(
'rest_invalid_param',
\__( 'No recipients found', 'activitypub' ),
array(
@ -190,7 +177,7 @@ class Inbox {
\do_action( "activitypub_inbox_{$type}", $data, $user->ID );
}
return new \WP_REST_Response( array(), 202 );
return new WP_REST_Response( array(), 202 );
}
/**
@ -207,10 +194,7 @@ class Inbox {
$params['user_id'] = array(
'required' => true,
'type' => 'integer',
'validate_callback' => function( $param, $request, $key ) {
return user_can( $param, 'publish_posts' );
},
'type' => 'string',
);
return $params;
@ -230,10 +214,7 @@ class Inbox {
$params['user_id'] = array(
'required' => true,
'type' => 'integer',
'validate_callback' => function( $param, $request, $key ) {
return user_can( $param, 'publish_posts' );
},
'type' => 'string',
);
$params['id'] = array(
@ -256,7 +237,7 @@ class Inbox {
//'type' => 'enum',
//'enum' => array( 'Create' ),
//'sanitize_callback' => function( $param, $request, $key ) {
// return \strtolower( $param );
// return \strtolower( $param );
//},
);
@ -301,7 +282,7 @@ class Inbox {
//'type' => 'enum',
//'enum' => array( 'Create' ),
//'sanitize_callback' => function( $param, $request, $key ) {
// return \strtolower( $param );
// return \strtolower( $param );
//},
);
@ -344,43 +325,6 @@ class 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 ) {
// save follower
\Activitypub\Peer\Followers::add_follower( $object['actor'], $user_id );
// get inbox
$inbox = \Activitypub\get_inbox_by_actor( $object['actor'] );
// send "Accept" activity
$activity = new \Activitypub\Model\Activity( 'Accept', \Activitypub\Model\Activity::TYPE_SIMPLE );
$activity->set_object( $object );
$activity->set_actor( \get_author_posts_url( $user_id ) );
$activity->set_to( $object['actor'] );
$activity->set_id( \get_author_posts_url( $user_id ) . '#follow-' . \preg_replace( '~^https?://~', '', $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 ( isset( $object['object'] ) && isset( $object['object']['type'] ) && 'Follow' === $object['object']['type'] ) {
\Activitypub\Peer\Followers::remove_follower( $object['actor'], $user_id );
}
}
/**
* Handles "Reaction" requests
*
@ -388,7 +332,7 @@ class Inbox {
* @param int $user_id The id of the local blog-user
*/
public static function handle_reaction( $object, $user_id ) {
$meta = \Activitypub\get_remote_metadata_by_actor( $object['actor'] );
$meta = get_remote_metadata_by_actor( $object['actor'] );
$comment_post_id = \url_to_postid( $object['object'] );
@ -418,12 +362,22 @@ class Inbox {
// do not require email for AP entries
\add_filter( 'pre_option_require_name_email', '__return_false' );
// No nonce possible for this submission route
\add_filter(
'akismet_comment_nonce',
function() {
return 'inactive';
}
);
$state = \wp_new_comment( $commentdata, true );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// re-add flood control
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
do_action( 'activitypub_handled_reaction', $object, $user_id, $state, $commentdata );
}
/**
@ -483,7 +437,7 @@ class Inbox {
'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_type' => 'comment',
'comment_author_email' => '',
'comment_parent' => $parent_comment ? $parent_comment->comment_ID : 0,
'comment_meta' => array(
@ -514,12 +468,22 @@ class Inbox {
// do not require email for AP entries
\add_filter( 'pre_option_require_name_email', '__return_false' );
// No nonce possible for this submission route
\add_filter(
'akismet_comment_nonce',
function() {
return 'inactive';
}
);
$state = \wp_new_comment( $commentdata, true );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// re-add flood control
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
do_action( 'activitypub_handled_create', $object, $user_id, $state, $commentdata );
}
/**
@ -651,7 +615,7 @@ class Inbox {
$users = array();
foreach ( $recipients as $recipient ) {
$user_id = \Activitypub\url_to_authorid( $recipient );
$user_id = url_to_authorid( $recipient );
$user = get_user_by( 'id', $user_id );

View file

@ -1,6 +1,10 @@
<?php
namespace Activitypub\Rest;
use WP_REST_Response;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub NodeInfo REST-Class
*
@ -13,9 +17,9 @@ class Nodeinfo {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'rest_api_init', array( '\Activitypub\Rest\Nodeinfo', 'register_routes' ) );
\add_filter( 'nodeinfo_data', array( '\Activitypub\Rest\Nodeinfo', 'add_nodeinfo_discovery' ), 10, 2 );
\add_filter( 'nodeinfo2_data', array( '\Activitypub\Rest\Nodeinfo', 'add_nodeinfo2_discovery' ), 10 );
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
\add_filter( 'nodeinfo_data', array( self::class, 'add_nodeinfo_discovery' ), 10, 2 );
\add_filter( 'nodeinfo2_data', array( self::class, 'add_nodeinfo2_discovery' ), 10 );
}
/**
@ -23,36 +27,36 @@ class Nodeinfo {
*/
public static function register_routes() {
\register_rest_route(
'activitypub/1.0',
ACTIVITYPUB_REST_NAMESPACE,
'/nodeinfo/discovery',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( '\Activitypub\Rest\Nodeinfo', 'discovery' ),
'callback' => array( self::class, 'discovery' ),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
'activitypub/1.0',
ACTIVITYPUB_REST_NAMESPACE,
'/nodeinfo',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( '\Activitypub\Rest\Nodeinfo', 'nodeinfo' ),
'callback' => array( self::class, 'nodeinfo' ),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
'activitypub/1.0',
ACTIVITYPUB_REST_NAMESPACE,
'/nodeinfo2',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( '\Activitypub\Rest\Nodeinfo', 'nodeinfo2' ),
'callback' => array( self::class, 'nodeinfo2' ),
'permission_callback' => '__return_true',
),
)
@ -67,6 +71,11 @@ class Nodeinfo {
* @return WP_REST_Response
*/
public static function nodeinfo( $request ) {
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_rest_nodeinfo_pre' );
$nodeinfo = array();
$nodeinfo['version'] = '2.0';
@ -75,13 +84,24 @@ class Nodeinfo {
'version' => \get_bloginfo( 'version' ),
);
$users = \count_users();
$users = \get_users(
array(
'capability__in' => array( 'publish_posts' ),
)
);
if ( is_array( $users ) ) {
$users = count( $users );
} else {
$users = 1;
}
$posts = \wp_count_posts();
$comments = \wp_count_comments();
$nodeinfo['usage'] = array(
'users' => array(
'total' => (int) $users['total_users'],
'total' => $users,
),
'localPosts' => (int) $posts->publish,
'localComments' => (int) $comments->approved,
@ -95,7 +115,7 @@ class Nodeinfo {
'outbound' => array(),
);
return new \WP_REST_Response( $nodeinfo, 200 );
return new WP_REST_Response( $nodeinfo, 200 );
}
/**
@ -106,6 +126,11 @@ class Nodeinfo {
* @return WP_REST_Response
*/
public static function nodeinfo2( $request ) {
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_rest_nodeinfo2_pre' );
$nodeinfo = array();
$nodeinfo['version'] = '1.0';
@ -147,7 +172,7 @@ class Nodeinfo {
'outbound' => array(),
);
return new \WP_REST_Response( $nodeinfo, 200 );
return new WP_REST_Response( $nodeinfo, 200 );
}
/**
@ -162,7 +187,7 @@ class Nodeinfo {
$discovery['links'] = array(
array(
'rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0',
'href' => \get_rest_url( null, 'activitypub/1.0/nodeinfo' ),
'href' => get_rest_url_by_path( 'nodeinfo' ),
),
);

View file

@ -14,7 +14,7 @@ class Ostatus {
*/
public static function register_routes() {
\register_rest_route(
'activitypub/1.0',
ACTIVITYPUB_REST_NAMESPACE,
'/ostatus/remote-follow',
array(
array(

View file

@ -1,6 +1,17 @@
<?php
namespace Activitypub\Rest;
use stdClass;
use WP_Error;
use WP_REST_Server;
use WP_REST_Response;
use Activitypub\Transformer\Post;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\get_context;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub Outbox REST-Class
*
@ -13,7 +24,7 @@ class Outbox {
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'rest_api_init', array( '\Activitypub\Rest\Outbox', 'register_routes' ) );
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
}
/**
@ -21,12 +32,12 @@ class Outbox {
*/
public static function register_routes() {
\register_rest_route(
'activitypub/1.0',
'/users/(?P<user_id>\d+)/outbox',
ACTIVITYPUB_REST_NAMESPACE,
'/users/(?P<user_id>[\w\-\.]+)/outbox',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( '\Activitypub\Rest\Outbox', 'user_outbox_get' ),
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'user_outbox_get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
@ -42,42 +53,31 @@ class Outbox {
*/
public static function user_outbox_get( $request ) {
$user_id = $request->get_param( 'user_id' );
$author = \get_user_by( 'ID', $user_id );
$post_types = \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) );
$user = User_Collection::get_by_various( $user_id );
if ( ! $author ) {
return new \WP_Error(
'rest_invalid_param',
\__( 'User not found', 'activitypub' ),
array(
'status' => 404,
'params' => array(
'user_id' => \__( 'User not found', 'activitypub' ),
),
)
);
if ( is_wp_error( $user ) ) {
return $user;
}
$page = $request->get_param( 'page', 0 );
$post_types = \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) );
$page = $request->get_param( 'page', 1 );
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_outbox_pre' );
\do_action( 'activitypub_rest_outbox_pre' );
$json = new \stdClass();
$json = new stdClass();
$json->{'@context'} = \Activitypub\get_context();
$json->id = \home_url( \add_query_arg( null, null ) );
$json->{'@context'} = get_context();
$json->id = get_rest_url_by_path( sprintf( 'users/%d/outbox', $user_id ) );
$json->generator = 'http://wordpress.org/?v=' . \get_bloginfo_rss( 'version' );
$json->actor = \get_author_posts_url( $user_id );
$json->actor = $user->get_id();
$json->type = 'OrderedCollectionPage';
$json->partOf = \get_rest_url( null, "/activitypub/1.0/users/$user_id/outbox" ); // phpcs:ignore
$json->partOf = get_rest_url_by_path( sprintf( 'users/%d/outbox', $user_id ) ); // phpcs:ignore
$json->totalItems = 0; // phpcs:ignore
// phpcs:ignore
$json->totalItems = 0;
foreach ( $post_types as $post_type ) {
$count_posts = \wp_count_posts( $post_type );
$json->totalItems += \intval( $count_posts->publish ); // phpcs:ignore
@ -90,33 +90,40 @@ class Outbox {
$json->next = \add_query_arg( 'page', $page + 1, $json->partOf ); // phpcs:ignore
}
if ( $page && ( $page > 1 ) ) { // phpcs:ignore
$json->prev = \add_query_arg( 'page', $page - 1, $json->partOf ); // phpcs:ignore
}
if ( $page ) {
$posts = \get_posts(
array(
'posts_per_page' => 10,
'author' => $user_id,
'offset' => ( $page - 1 ) * 10,
'post_type' => $post_types,
'author' => $user_id,
'paged' => $page,
'post_type' => $post_types,
)
);
foreach ( $posts as $post ) {
$activitypub_post = new \Activitypub\Model\Post( $post );
$activitypub_activity = new \Activitypub\Model\Activity( 'Create', \Activitypub\Model\Activity::TYPE_NONE );
$activitypub_activity->from_post( $activitypub_post );
$json->orderedItems[] = $activitypub_activity->to_array(); // phpcs:ignore
$post = Post::transform( $post )->to_object();
$activity = new Activity();
$activity->set_type( 'Create' );
$activity->set_context( null );
$activity->set_object( $post );
$json->orderedItems[] = $activity->to_array(); // phpcs:ignore
}
}
// 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
*/
\do_action( 'activitypub_outbox_post' );
$response = new \WP_REST_Response( $json, 200 );
$response = new WP_REST_Response( $json, 200 );
$response->header( 'Content-Type', 'application/activity+json' );
@ -133,14 +140,12 @@ class Outbox {
$params['page'] = array(
'type' => 'integer',
'default' => 1,
);
$params['user_id'] = array(
'required' => true,
'type' => 'integer',
'validate_callback' => function( $param, $request, $key ) {
return user_can( $param, 'publish_posts' );
},
'type' => 'string',
);
return $params;

View file

@ -0,0 +1,99 @@
<?php
namespace Activitypub\Rest;
use stdClass;
use WP_REST_Response;
use Activitypub\Signature;
use Activitypub\Model\Application_User;
/**
* ActivityPub Server REST-Class
*
* @author Django Doucet
*
* @see https://www.w3.org/TR/activitypub/#security-verification
*/
class Server {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
\add_filter( 'rest_request_before_callbacks', array( self::class, 'authorize_activitypub_requests' ), 10, 3 );
}
/**
* Register routes
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/application',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( self::class, 'application_actor' ),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Render Application actor profile
*
* @return WP_REST_Response The JSON profile of the Application Actor.
*/
public static function application_actor() {
$user = new Application_User();
$json = $user->to_array();
$response = new WP_REST_Response( $json, 200 );
$response->header( 'Content-Type', 'application/activity+json' );
return $response;
}
/**
* Callback function to authorize each api requests
*
* @see WP_REST_Request
*
* @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
* Usually a WP_REST_Response or WP_Error.
* @param array $handler Route handler used for the request.
* @param WP_REST_Request $request Request used to generate the response.
*
* @return mixed|WP_Error The response, error, or modified response.
*/
public static function authorize_activitypub_requests( $response, $handler, $request ) {
$route = $request->get_route();
// check if it is an activitypub request and exclude webfinger and nodeinfo endpoints
if (
! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ||
\str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'webfinger' ) ||
\str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'nodeinfo' )
) {
return $response;
}
// POST-Requets are always signed
if ( 'POST' === $request->get_method() ) {
$verified_request = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_request ) ) {
return $verified_request;
}
} elseif ( 'GET' === $request->get_method() ) { // GET-Requests are only signed in secure mode
if ( ACTIVITYPUB_AUTHORIZED_FETCH ) {
$verified_request = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_request ) ) {
return $verified_request;
}
}
}
return $response;
}
}

View file

@ -0,0 +1,155 @@
<?php
namespace Activitypub\Rest;
use WP_Error;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use Activitypub\Webfinger;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\is_activitypub_request;
/**
* ActivityPub Followers REST-Class
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/#followers
*/
class Users {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
}
/**
* Register routes
*/
public static function register_routes() {
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/users/(?P<user_id>[\w\-\.]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'get' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/users/(?P<user_id>[\w\-\.]+)/remote-follow',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'remote_follow_get' ),
'args' => array(
'resource' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Handle GET request
*
* @param WP_REST_Request $request
*
* @return WP_REST_Response
*/
public static function get( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
// redirect to canonical URL if it is not an ActivityPub request
if ( ! is_activitypub_request() ) {
header( 'Location: ' . $user->get_canonical_url(), true, 301 );
exit;
}
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_rest_users_pre' );
$user->set_context(
Activity::CONTEXT
);
$json = $user->to_array();
$response = new WP_REST_Response( $json, 200 );
$response->header( 'Content-Type', 'application/activity+json' );
return $response;
}
/**
* Endpoint for remote follow UI/Block
*
* @param WP_REST_Request $request The request object.
*
* @return void|string The URL to the remote follow page
*/
public static function remote_follow_get( WP_REST_Request $request ) {
$resource = $request->get_param( 'resource' );
$user_id = $request->get_param( 'user_id' );
$user = User_Collection::get_by_various( $user_id );
if ( is_wp_error( $user ) ) {
return $user;
}
$template = Webfinger::get_remote_follow_endpoint( $resource );
if ( is_wp_error( $template ) ) {
return $template;
}
$resource = $user->get_resource();
$url = str_replace( '{uri}', $resource, $template );
return new WP_REST_Response(
array( 'url' => $url ),
200
);
}
/**
* The supported parameters
*
* @return array list of parameters
*/
public static function request_parameters() {
$params = array();
$params['page'] = array(
'type' => 'string',
);
$params['user_id'] = array(
'required' => true,
'type' => 'string',
);
return $params;
}
}

View file

@ -1,6 +1,10 @@
<?php
namespace Activitypub\Rest;
use WP_Error;
use WP_REST_Response;
use Activitypub\Collection\Users as User_Collection;
/**
* ActivityPub WebFinger REST-Class
*
@ -10,26 +14,29 @@ namespace Activitypub\Rest;
*/
class Webfinger {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*
* @return void
*/
public static function init() {
\add_action( 'rest_api_init', array( '\Activitypub\Rest\Webfinger', 'register_routes' ) );
\add_action( 'webfinger_user_data', array( '\Activitypub\Rest\Webfinger', 'add_webfinger_discovery' ), 10, 3 );
\add_action( 'webfinger_lookup', array( '\Activitypub\Rest\Webfinger', 'webfinger_lookup' ), 10, 3 );
\add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
\add_filter( 'webfinger_user_data', array( self::class, 'add_user_discovery' ), 10, 3 );
\add_filter( 'webfinger_data', array( self::class, 'add_pseudo_user_discovery' ), 99, 2 );
}
/**
* Register routes
* Register routes.
*
* @return void
*/
public static function register_routes() {
\register_rest_route(
'activitypub/1.0',
ACTIVITYPUB_REST_NAMESPACE,
'/webfinger',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( '\Activitypub\Rest\Webfinger', 'webfinger' ),
'callback' => array( self::class, 'webfinger' ),
'args' => self::request_parameters(),
'permission_callback' => '__return_true',
),
@ -38,53 +45,22 @@ class Webfinger {
}
/**
* Render JRD file
* WebFinger endpoint.
*
* @param WP_REST_Request $request
* @return WP_REST_Response
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response object.
*/
public static function webfinger( $request ) {
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_rest_webfinger_pre' );
$resource = $request->get_param( 'resource' );
$response = self::get_profile( $resource );
if ( \strpos( $resource, '@' ) === false ) {
return new \WP_Error( 'activitypub_unsupported_resource', \__( 'Resource is invalid', 'activitypub' ), array( 'status' => 400 ) );
}
$resource = \str_replace( 'acct:', '', $resource );
$resource_identifier = \substr( $resource, 0, \strrpos( $resource, '@' ) );
$resource_host = \substr( \strrchr( $resource, '@' ), 1 );
if ( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) !== $resource_host ) {
return new \WP_Error( 'activitypub_wrong_host', \__( 'Resource host does not match blog host', 'activitypub' ), array( 'status' => 404 ) );
}
$user = \get_user_by( 'login', \esc_sql( $resource_identifier ) );
if ( ! $user || ! user_can( $user, 'publish_posts' ) ) {
return new \WP_Error( 'activitypub_user_not_found', \__( 'User not found', 'activitypub' ), array( 'status' => 404 ) );
}
$json = array(
'subject' => $resource,
'aliases' => array(
\get_author_posts_url( $user->ID ),
),
'links' => array(
array(
'rel' => 'self',
'type' => 'application/activity+json',
'href' => \get_author_posts_url( $user->ID ),
),
array(
'rel' => 'http://webfinger.net/rel/profile-page',
'type' => 'text/html',
'href' => \get_author_posts_url( $user->ID ),
),
),
);
return new \WP_REST_Response( $json, 200 );
return new WP_REST_Response( $response, 200 );
}
/**
@ -110,47 +86,73 @@ class Webfinger {
* @param array $array the jrd array
* @param string $resource the WebFinger resource
* @param WP_User $user the WordPress user
*
* @return array the jrd array
*/
public static function add_webfinger_discovery( $array, $resource, $user ) {
public static function add_user_discovery( $array, $resource, $user ) {
$user = User_Collection::get_by_id( $user->ID );
$array['links'][] = array(
'rel' => 'self',
'type' => 'application/activity+json',
'href' => \get_author_posts_url( $user->ID ),
'href' => $user->get_url(),
);
return $array;
}
/**
* WebFinger Lookup to find user uri
* Add WebFinger discovery links
*
* @param array $array the jrd array
* @param string $resource the WebFinger resource
* @return array ['href', 'name']. ex: href=https://domain.tld/user/webfinger, name=webfinger@domain.tld
* @param WP_User $user the WordPress user
*
* @return array the jrd array
*/
public static function webfinger_lookup( $webfinger ) {
$activity_profile = null;
if ( \substr( $webfinger, 0, 1 ) === '@' ) {
$webfinger = substr( $webfinger, 1 );
}
$url_host = \explode( '@', $webfinger );
$webfinger_query = 'https://' . \end( $url_host ) . '/.well-known/webfinger?resource=acct%3A' . \rawurlencode( $webfinger );
$response = \wp_safe_remote_get( $webfinger_query );
if ( ! is_wp_error( $response ) ) {
$ap_link = json_decode( $response['body'] );
if ( isset( $ap_link->links ) ) {
foreach ( $ap_link->links as $link ) {
if ( ! property_exists( $link, 'type' ) ) {
continue;
}
if ( isset( $link->type ) && 'application/activity+json' === $link->type ) {
$activity_profile['href'] = $link->href;
$activity_profile['name'] = $webfinger;
}
}
}
public static function add_pseudo_user_discovery( $array, $resource ) {
if ( $array ) {
return $array;
}
return $activity_profile;
return self::get_profile( $resource );
}
/**
* Get the WebFinger profile.
*
* @param string $resource the WebFinger resource.
*
* @return array the WebFinger profile.
*/
public static function get_profile( $resource ) {
$user = User_Collection::get_by_resource( $resource );
if ( is_wp_error( $user ) ) {
return $user;
}
$aliases = array(
$user->get_url(),
);
$profile = array(
'subject' => $resource,
'aliases' => array_values( array_unique( $aliases ) ),
'links' => array(
array(
'rel' => 'self',
'type' => 'application/activity+json',
'href' => $user->get_url(),
),
array(
'rel' => 'http://webfinger.net/rel/profile-page',
'type' => 'text/html',
'href' => $user->get_url(),
),
),
);
return $profile;
}
}

View file

@ -0,0 +1,148 @@
<?php
namespace Activitypub\Table;
use WP_List_Table;
use Activitypub\Collection\Users;
use Activitypub\Collection\Followers as FollowerCollection;
if ( ! \class_exists( '\WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
class Followers extends WP_List_Table {
private $user_id;
public function __construct() {
if ( get_current_screen()->id === 'settings_page_activitypub' ) {
$this->user_id = Users::BLOG_USER_ID;
} else {
$this->user_id = \get_current_user_id();
}
parent::__construct(
array(
'singular' => \__( 'Follower', 'activitypub' ),
'plural' => \__( 'Followers', 'activitypub' ),
'ajax' => false,
)
);
}
public function get_columns() {
return array(
'cb' => '<input type="checkbox" />',
'avatar' => \__( 'Avatar', 'activitypub' ),
'name' => \__( 'Name', 'activitypub' ),
'username' => \__( 'Username', 'activitypub' ),
'url' => \__( 'URL', 'activitypub' ),
'updated' => \__( 'Last updated', 'activitypub' ),
//'errors' => \__( 'Errors', 'activitypub' ),
//'latest-error' => \__( 'Latest Error Message', 'activitypub' ),
);
}
public function get_sortable_columns() {
return array();
}
public function prepare_items() {
$columns = $this->get_columns();
$hidden = array();
$this->process_action();
$this->_column_headers = array( $columns, $hidden, $this->get_sortable_columns() );
$page_num = $this->get_pagenum();
$per_page = 20;
$followers = FollowerCollection::get_followers( $this->user_id, $per_page, $page_num );
$counter = FollowerCollection::count_followers( $this->user_id );
$this->items = array();
$this->set_pagination_args(
array(
'total_items' => $counter,
'total_pages' => ceil( $counter / $per_page ),
'per_page' => $per_page,
)
);
foreach ( $followers as $follower ) {
$item = array(
'icon' => esc_attr( $follower->get_icon_url() ),
'name' => esc_attr( $follower->get_name() ),
'username' => esc_attr( $follower->get_preferred_username() ),
'url' => esc_attr( $follower->get_url() ),
'identifier' => esc_attr( $follower->get_id() ),
'updated' => esc_attr( $follower->get_updated() ),
'errors' => $follower->count_errors(),
'latest-error' => $follower->get_latest_error_message(),
);
$this->items[] = $item;
}
}
public function get_bulk_actions() {
return array(
'delete' => __( 'Delete', 'activitypub' ),
);
}
public function column_default( $item, $column_name ) {
if ( ! array_key_exists( $column_name, $item ) ) {
return __( 'None', 'activitypub' );
}
return $item[ $column_name ];
}
public function column_avatar( $item ) {
return sprintf(
'<img src="%s" width="25px;" />',
$item['icon']
);
}
public function column_url( $item ) {
return sprintf(
'<a href="%s" target="_blank">%s</a>',
$item['url'],
$item['url']
);
}
public function column_cb( $item ) {
return sprintf( '<input type="checkbox" name="followers[]" value="%s" />', esc_attr( $item['identifier'] ) );
}
public function process_action() {
if ( ! isset( $_REQUEST['followers'] ) || ! isset( $_REQUEST['_apnonce'] ) ) {
return false;
}
$nonce = sanitize_text_field( wp_unslash( $_REQUEST['_apnonce'] ) );
if ( ! wp_verify_nonce( $nonce, 'activitypub-followers-list' ) ) {
return false;
}
if ( ! current_user_can( 'edit_user', $this->user_id ) ) {
return false;
}
$followers = $_REQUEST['followers']; // phpcs:ignore
switch ( $this->current_action() ) {
case 'delete':
if ( ! is_array( $followers ) ) {
$followers = array( $followers );
}
foreach ( $followers as $follower ) {
FollowerCollection::remove_follower( $this->user_id, $follower );
}
break;
}
}
public function get_user_count() {
return FollowerCollection::count_followers( $this->user_id );
}
}

View file

@ -1,36 +0,0 @@
<?php
namespace Activitypub\Table;
if ( ! \class_exists( '\WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
class Followers_List extends \WP_List_Table {
public function get_columns() {
return array(
'identifier' => \__( 'Identifier', 'activitypub' ),
);
}
public function get_sortable_columns() {
return array();
}
public function prepare_items() {
$columns = $this->get_columns();
$hidden = array();
$this->process_action();
$this->_column_headers = array( $columns, $hidden, $this->get_sortable_columns() );
$this->items = array();
foreach ( \Activitypub\Peer\Followers::get_followers( \get_current_user_id() ) as $follower ) {
$this->items[]['identifier'] = \esc_attr( $follower );
}
}
public function column_default( $item, $column_name ) {
return $item[ $column_name ];
}
}

View file

@ -0,0 +1,464 @@
<?php
namespace Activitypub\Transformer;
use WP_Post;
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;
/**
* WordPress Post Transformer
*
* The Post Transformer is responsible for transforming a WP_Post object into different othe
* Object-Types.
*
* Currently supported are:
*
* - Activitypub\Activity\Base_Object
*/
class Post {
/**
* The WP_Post object.
*
* @var WP_Post
*/
protected $wp_post;
/**
* The Allowed Tags, used in the content.
*
* @var array
*/
protected $allowed_tags = array(
'a' => array(
'href' => array(),
'title' => array(),
'class' => array(),
'rel' => array(),
),
'br' => array(),
'p' => array(
'class' => array(),
),
'span' => array(
'class' => array(),
),
'div' => array(
'class' => array(),
),
'ul' => array(),
'ol' => array(
'reversed' => array(),
'start' => array(),
),
'li' => array(
'value' => array(),
),
'strong' => array(
'class' => array(),
),
'b' => array(
'class' => array(),
),
'i' => array(
'class' => array(),
),
'em' => array(
'class' => array(),
),
'blockquote' => array(),
'cite' => array(),
'code' => array(
'class' => array(),
),
'pre' => array(
'class' => array(),
),
);
/**
* Static function to Transform a WP_Post Object.
*
* This helps to chain the output of the Transformer.
*
* @param WP_Post $wp_post The WP_Post object
*
* @return void
*/
public static function transform( WP_Post $wp_post ) {
return new static( $wp_post );
}
/**
*
*
* @param WP_Post $wp_post
*/
public function __construct( WP_Post $wp_post ) {
$this->wp_post = $wp_post;
}
/**
* Transforms the WP_Post object to an ActivityPub Object
*
* @see \Activitypub\Activity\Base_Object
*
* @return \Activitypub\Activity\Base_Object The ActivityPub Object
*/
public function to_object() {
$wp_post = $this->wp_post;
$object = new Base_Object();
$object->set_id( \esc_url( \get_permalink( $wp_post->ID ) ) );
$object->set_url( \esc_url( \get_permalink( $wp_post->ID ) ) );
$object->set_type( $this->get_object_type() );
$published = \strtotime( $wp_post->post_date_gmt );
$object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) );
$updated = \strtotime( $wp_post->post_modified_gmt );
if ( $updated > $published ) {
$object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) );
}
$object->set_attributed_to( $this->get_attributed_to() );
$object->set_content( $this->get_content() );
$object->set_content_map(
array(
\strstr( \get_locale(), '_', true ) => $this->get_content(),
)
);
$path = sprintf( 'users/%d/followers', intval( $wp_post->post_author ) );
$object->set_to(
array(
'https://www.w3.org/ns/activitystreams#Public',
get_rest_url_by_path( $path ),
)
);
$object->set_cc( $this->get_cc() );
$object->set_attachment( $this->get_attachments() );
$object->set_tag( $this->get_tags() );
return $object;
}
/**
* Returns the User-URL of the Author of the Post.
*
* If `single_user` mode is enabled, the URL of the Blog-User is returned.
*
* @return string The User-URL.
*/
protected function get_attributed_to() {
if ( is_single_user() ) {
$user = new Blog_User();
return $user->get_url();
}
return Users::get_by_id( $this->wp_post->post_author )->get_url();
}
/**
* Generates all Image Attachments for a Post.
*
* @return array The Image Attachments.
*/
protected function get_attachments() {
$max_images = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) );
$images = array();
// max images can't be negative or zero
if ( $max_images <= 0 ) {
return $images;
}
$id = $this->wp_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;
}
if ( $max_images > 0 ) {
// 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, true ) ) {
$image_ids[] = $attachment->ID;
}
}
}
$image_ids = \array_unique( $image_ids );
// get URLs for each image
foreach ( $image_ids as $id ) {
$image_size = 'full';
/**
* Filter the image URL returned for each post.
*
* @param array|false $thumbnail The image URL, or false if no image is available.
* @param int $id The attachment ID.
* @param string $image_size The image size to retrieve. Set to 'full' by default.
*/
$thumbnail = apply_filters(
'activitypub_get_image',
$this->get_image( $id, $image_size ),
$id,
$image_size
);
if ( $thumbnail ) {
$mimetype = \get_post_mime_type( $id );
$alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
$image = array(
'type' => 'Image',
'url' => $thumbnail[0],
'mediaType' => $mimetype,
);
if ( $alt ) {
$image['name'] = $alt;
}
$images[] = $image;
}
}
return $images;
}
/**
* Return details about an image attachment.
*
* @param int $id The attachment ID.
* @param string $image_size The image size to retrieve. Set to 'full' by default.
*
* @return array|false Array of image data, or boolean false if no image is available.
*/
protected function get_image( $id, $image_size = 'full' ) {
/**
* Hook into the image retrieval process. Before image retrieval.
*
* @param int $id The attachment ID.
* @param string $image_size The image size to retrieve. Set to 'full' by default.
*/
do_action( 'activitypub_get_image_pre', $id, $image_size );
$thumbnail = \wp_get_attachment_image_src( $id, $image_size );
/**
* Hook into the image retrieval process. After image retrieval.
*
* @param int $id The attachment ID.
* @param string $image_size The image size to retrieve. Set to 'full' by default.
*/
do_action( 'activitypub_get_image_pre', $id, $image_size );
return $thumbnail;
}
/**
* Returns the ActivityStreams 2.0 Object-Type for a Post based on the
* settings and the Post-Type.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
*
* @return string The Object-Type.
*/
protected function get_object_type() {
if ( 'wordpress-post-format' !== \get_option( 'activitypub_object_type', 'note' ) ) {
return \ucfirst( \get_option( 'activitypub_object_type', 'note' ) );
}
$post_type = \get_post_type( $this->wp_post );
switch ( $post_type ) {
case 'post':
$post_format = \get_post_format( $this->wp_post );
switch ( $post_format ) {
case 'aside':
case 'status':
case 'quote':
case 'note':
$object_type = 'Note';
break;
case 'gallery':
case 'image':
$object_type = 'Image';
break;
case 'video':
$object_type = 'Video';
break;
case 'audio':
$object_type = 'Audio';
break;
default:
$object_type = 'Article';
break;
}
break;
case 'page':
$object_type = 'Page';
break;
case 'attachment':
$mime_type = \get_post_mime_type();
$media_type = \preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type );
switch ( $media_type ) {
case 'audio':
$object_type = 'Audio';
break;
case 'video':
$object_type = 'Video';
break;
case 'image':
$object_type = 'Image';
break;
}
break;
default:
$object_type = 'Article';
break;
}
return $object_type;
}
/**
* Returns a list of Mentions, used in the Post.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#Mention
*
* @return array The list of Mentions.
*/
protected function get_cc() {
$cc = array();
$mentions = $this->get_mentions();
if ( $mentions ) {
foreach ( $mentions as $mention => $url ) {
$cc[] = $url;
}
}
return $cc;
}
/**
* Returns a list of Tags, used in the Post.
*
* This includes Hash-Tags and Mentions.
*
* @return array The list of Tags.
*/
protected function get_tags() {
$tags = array();
$post_tags = \get_the_tags( $this->wp_post->ID );
if ( $post_tags ) {
foreach ( $post_tags as $post_tag ) {
$tag = array(
'type' => 'Hashtag',
'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ),
'name' => esc_hashtag( $post_tag->name ),
);
$tags[] = $tag;
}
}
$mentions = $this->get_mentions();
if ( $mentions ) {
foreach ( $mentions as $mention => $url ) {
$tag = array(
'type' => 'Mention',
'href' => \esc_url( $url ),
'name' => \esc_html( $mention ),
);
$tags[] = $tag;
}
}
return $tags;
}
/**
* Returns the content for the ActivityPub Item.
*
* The content will be generated based on the user settings.
*
* @return string The content.
*/
protected function get_content() {
global $post;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$post = $this->wp_post;
$content = $this->get_post_content_template();
// Fill in the shortcodes.
setup_postdata( $post );
$content = do_shortcode( $content );
wp_reset_postdata();
$content = \wp_kses( $content, $this->allowed_tags );
$content = \wpautop( $content );
$content = \preg_replace( '/[\n\r\t]/', '', $content );
$content = \trim( $content );
$content = \apply_filters( 'activitypub_the_content', $content, $post );
$content = \html_entity_decode( $content, \ENT_QUOTES, 'UTF-8' );
return $content;
}
/**
* Gets the template to use to generate the content of the activitypub item.
*
* @return string The Template.
*/
protected function get_post_content_template() {
if ( 'excerpt' === \get_option( 'activitypub_post_content_type', 'content' ) ) {
return "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
}
if ( 'title' === \get_option( 'activitypub_post_content_type', 'content' ) ) {
return "[ap_title]\n\n[ap_permalink type=\"html\"]";
}
if ( 'content' === \get_option( 'activitypub_post_content_type', 'content' ) ) {
return "[ap_content]\n\n[ap_hashtags]\n\n[ap_permalink type=\"html\"]";
}
return \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
}
/**
* Helper function to get the @-Mentions from the post content.
*
* @return array The list of @-Mentions.
*/
protected function get_mentions() {
return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_post->post_content, $this->wp_post );
}
}

View file

@ -3,7 +3,7 @@ namespace Activitypub\Integration;
class Buddypress {
public static function init() {
\add_filter( 'activitypub_json_author_array', array( 'Activitypub\Integration\Buddypress', 'add_user_metadata' ), 11, 2 );
\add_filter( 'activitypub_json_author_array', array( self::class, 'add_user_metadata' ), 11, 2 );
}
public static function add_user_metadata( $object, $author_id ) {

View file

@ -9,12 +9,23 @@
"name": "Matthias Pfefferle",
"web": "https://notiz.blog"
},
"scripts": {
"dev": "wp-scripts start",
"build": "wp-scripts build"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/pfefferle/wordpress-activitypub/issues"
},
"homepage": "https://github.com/pfefferle/wordpress-activitypub#readme",
"devDependencies": {
"@wordpress/blocks": "^12.11.0",
"@wordpress/components": "^25.0.0",
"@wordpress/data": "^9.4.0",
"@wordpress/dom-ready": "^3.36.0",
"@wordpress/element": "^5.11.0",
"@wordpress/scripts": "^26.5.0",
"classnames": "^2.3.2",
"grunt": "^1.1.0",
"grunt-checktextdomain": "^1.0.1",
"grunt-wp-i18n": "^1.0.3",

View file

@ -23,4 +23,50 @@
<rule ref="WordPress-Extra" />
<rule ref="WordPress.WP.I18n"/>
<config name="text_domain" value="activitypub,default"/>
<arg value="ps"/>
<arg name="parallel" value="20"/>
<rule ref="VariableAnalysis"/>
<rule ref="VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable">
<type>error</type>
</rule>
<rule ref="VariableAnalysis.CodeAnalysis.VariableAnalysis">
<properties>
<property name="allowUnusedCaughtExceptions" value="true"/>
</properties>
</rule>
<rule ref="WordPress"/>
<rule ref="WordPress.WP.I18n.NoHtmlWrappedStrings">
<type>error</type>
</rule>
<rule ref="Generic.CodeAnalysis.UnusedFunctionParameter"/>
<rule ref="Generic.Arrays.DisallowShortArraySyntax">
<severity>0</severity>
</rule>
<rule ref="Universal.Arrays.DisallowShortArraySyntax">
<severity>0</severity>
</rule>
<rule ref="Squiz.Commenting">
<severity>0</severity>
</rule>
<rule ref="Generic.Commenting">
<severity>0</severity>
</rule>
<rule ref="WordPress.Files.FileName">
<severity>0</severity>
</rule>
<rule ref="WordPress.DB.PreparedSQL.NotPrepared">
<severity>0</severity>
</rule>
<rule ref="WordPress.WP.CapitalPDangit">
<severity>0</severity>
</rule>
<rule ref="WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound">
<severity>0</severity>
</rule>
<rule ref="WordPress.PHP.YodaConditions.NotYoda">
<type>warning</type>
</rule>
<rule ref="WordPress.Arrays.ArrayDeclarationSpacing">
<exclude-pattern>**/*.asset.php</exclude-pattern>
</rule>
</ruleset>

View file

@ -1,9 +1,9 @@
=== ActivityPub ===
Contributors: pfefferle, mediaformat, akirk, automattic
Contributors: automattic, pfefferle, mediaformat, mattwiebe, akirk, jeherve, nuriapena
Tags: OStatus, fediverse, activitypub, activitystream
Requires at least: 4.7
Tested up to: 6.1
Stable tag: 0.17.0
Tested up to: 6.3
Stable tag: 1.0.0
Requires PHP: 5.6
License: MIT
License URI: http://opensource.org/licenses/MIT
@ -12,22 +12,59 @@ The ActivityPub protocol is a decentralized social networking protocol based upo
== Description ==
This is **BETA** software, see the FAQ to see the current feature set or rather what is still planned.
This is BETA software, see the FAQ to see the current feature set or rather what is still planned.
The plugin implements the ActivityPub protocol for your blog. Your readers will be able to follow your blogposts on Mastodon and other federated platforms that support ActivityPub.
The plugin implements the ActivityPub protocol for your blog, which means that your readers will be able to follow your blog posts on Mastodon and other federated platforms that support ActivityPub. In addition, replies to your posts on Mastodon and related platforms will automatically become comments on your blog post.
The plugin works with the following federated platforms:
The plugin works with the following tested federated platforms, but there may be more that it works with as well:
* [Mastodon](https://joinmastodon.org/)
* [Pleroma](https://pleroma.social/)
* [Friendica](https://friendi.ca/)
* [HubZilla](https://hubzilla.org/)
* [friendica](https://friendi.ca/)
* [Hubzilla](https://hubzilla.org/)
* [Pixelfed](https://pixelfed.org/)
* [SocialHome](https://socialhome.network/)
* [Socialhome](https://socialhome.network/)
* [Misskey](https://join.misskey.page/)
* [Calckey](https://calckey.org/)
Heres what that means and what you can expect.
Once the ActivityPub plugin is installed, each authors page on your WordPress blog will become its own federated instance. In other words, if you have two authors, Jane and Bob, on your website, `example.com`, then your authors would have their own author pages at `example.com/author/jane` and `example.com/author/bob`. Each of those author pages would now be available to Mastodon users (and all other federated platform users) as a profile that can be followed. Lets break that down further. Lets say you have a friend on Mastodon who tells you to follow them and they give you their profile name `@janelivesheresomeofthetime@mastodon.social`. You search for her name, see her profile, and click the follow button, right? From then on, everything Jane posts on her profile shows up in your Home feed. Okay, similarly, now that Jane has installed the ActivityPub plugin on her `example.com` site, her friends can also follow her on Mastodon by searching for `@jane@example.com` and clicking the Follow button on that profile.
From now on, every blog post Jane publishes on example.com will show up on your Home feed because you follow her `@jane@example.com` profile.
Of course, if no one follows your author instance, then no one will ever see the posts - including you! So the easiest way to even know if the plugin is working is to follow your new profile yourself. If you already have a Mastodon profile, just follow your new one from there.
Some things to note:
1. Many single-author blogs have chosen to turn off or redirect their author profile pages, usually via an SEO plugin like Yoast or Rank Math. This is usually done to avoid duplicate content with your blogs home page. If your author page has been deactivated in this way, then ActivityPub wont work for you. Instead, you can turn your author profile page back on, and then use the option in your SEO plugin to noindex the author page. This will enable the page to be live and ActivityPub will now work, but the live page wont cause any duplicate content issues with search engines.
1. Once ActivityPub is installed, only new posts going forward will be available in the fediverse. Likewise, even if youve been using ActivityPub for a while, anyone who follows your site, will only see new posts you publish from that moment on. They will never see previously-published posts in their Home feed. This process is very similar to subscribing to a newsletter. If you subscribe to a newsletter, you will only receive future emails, but not the old archived ones. With ActivityPub, if someone follows your site, they will only receive new blog posts you publish from then on.
So whats the process?
1. Install the ActivityPub plugin.
1. Go to the plugins settings page and adjust the settings to your liking. Click the Save button when ready.
1. Make sure your blogs author profile page is active.
1. Go to Mastodon or any other federated platform, search for your authors new federated profile, and follow it. Your new profile will be in the form of @yourauthorname@yourwebsite.com, so that is what youll search for.
1. On your blog, publish a new post.
1. From Mastodon, check to see if the new post appears in your Home feed.
Please note that it may take up to 15 minutes or so for the new post to show up in your federated feed. This is because the messages are sent to the federated platforms using a delayed cron. This avoids breaking the publishing process for those cases where users might have lots of followers. So please dont assume that just because you didnt see it show up right away that something is broken. Give it some time. In most cases, it will show up within a few minutes, and youll know everything is working as expected.
== Frequently Asked Questions ==
= tl;dr =
This plugin connects your WordPress blog to popular social platforms like Mastodon, making your posts more accessible to a wider audience. Once installed, your blog's author pages can be followed by users on these platforms, allowing them to receive your new posts in their feeds.
Here's how it works:
1. Install the plugin and adjust settings as needed.
1. Ensure your blog's author profile page is active.
1. On Mastodon or other supported platforms, search for and follow your author's new profile (e.g., `@yourauthorname@yourwebsite.com`).
1. Publish a new post on your blog and check if it appears in your Mastodon feed.
Please note that it may take up to 15 minutes for a new post to appear in your feed, as messages are sent on a delay to avoid overwhelming your followers. Be patient and give it some time.
= What is the status of this plugin? =
Implemented:
@ -38,11 +75,10 @@ Implemented:
* follow (accept follows)
* share posts
* receive comments/reactions
* signature verification
To implement:
* signature verification
* better WordPress integration
* better configuration possibilities
* threaded comments support
@ -50,16 +86,6 @@ To implement:
*ActivityPub for WordPress* extends WordPress with some Fediverse features, but it does not compete with platforms like Friendica or Mastodon. If you want to run a **decentralized social network**, please use [Mastodon](https://joinmastodon.org/) or [GNU social](https://gnusocial.network/).
= What are the differences between this plugin and Pterotype? =
**Compatibility**
*ActivityPub for WordPress* is compatible with OStatus and IndieWeb plugin suites. *Pterotype* is incompatible with the standalone [WebFinger plugin](https://wordpress.org/plugins/webfinger/), so it can't be run together with OStatus.
**Custom tables**
*Pterotype* creates/uses a bunch of custom tables, *ActivityPub for WordPress* only uses the native tables and adds as little meta data as possible.
= What if you are running your blog in a subdirectory? =
In order for webfinger to work, it must be mapped to the root directory of the URL on which your blog resides.
@ -68,7 +94,7 @@ In order for webfinger to work, it must be mapped to the root directory of the U
Add the following to the .htaccess file in the root directory:
RedirectMatch "^\/\.well-known(.*)$" "\/blog\/\.well-known$1"
RedirectMatch "^\/\.well-known/(webfinger|nodeinfo|x-nodeinfo2)(.*)$" "\/blog\/\.well-known$1$2"
Where 'blog' is the path to the subdirectory at which your blog resides.
@ -85,7 +111,24 @@ Where 'blog' is the path to the subdirectory at which your blog resides.
== Changelog ==
Project maintained on GitHub at [pfefferle/wordpress-activitypub](https://github.com/pfefferle/wordpress-activitypub).
Project maintained on GitHub at [automattic/wordpress-activitypub](https://github.com/automattic/wordpress-activitypub).
= 1.0.0 =
* Add: blog-wide Account (catchall, like `mydomain.com@mydomain.com`)
* Add: Signature Verification: https://docs.joinmastodon.org/spec/security/ .
* Add: a Followers Block.
* Add: Simple caching
* Add: Collection endpoints for Featured Tags and Featured Posts
* Update: Complete rewrite of the Follower-System based on Custom Post Types.
* Update: Improved linter (PHPCS)
* Compatibility: Add a new conditional, `\Activitypub\is_activitypub_request()`, to allow third-party plugins to detect ActivityPub requests.
* Compatibility: Add hooks to allow modifying images returned in ActivityPub requests.
* Compatibility: Indicate that the plugin is compatible and has been tested with the latest version of WordPress, 6.3.
* Compatibility: Avoid PHP notice on sites using PHP 8.2.
* Fixed: Load the plugin later in the WordPress code lifecycle to avoid errors in some requests.
* Fixed: Updating posts
* Fixed: Hashtag now support CamelCase and UTF-8
= 0.17.0 =
@ -347,6 +390,12 @@ Project maintained on GitHub at [pfefferle/wordpress-activitypub](https://github
* initial
== Upgrade Notice ==
= 1.0.0 =
For version 1.0.0 we have completely rebuilt the followers lists. There is a migration from the old format to the new, but it may take some time until the migration is complete. No data will be lost in the process, please give the migration some time.
== Installation ==
Follow the normal instructions for [installing WordPress plugins](https://wordpress.org/support/article/managing-plugins/).

41
src/followers/block.json Normal file
View file

@ -0,0 +1,41 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "activitypub/followers",
"apiVersion": 3,
"version": "1.0.0",
"title": "Fediverse Followers",
"category": "widgets",
"description": "Display your followers from the Fediverse on your website.",
"textdomain": "activitypub",
"icon": "groups",
"supports": {
"html": false
},
"attributes": {
"title": {
"type": "string",
"default": "Fediverse Followers"
},
"selectedUser": {
"type": "string",
"default": "site"
},
"per_page": {
"type": "number",
"default": 10
},
"order": {
"type": "string",
"default": "desc",
"enum": [ "asc", "desc" ]
}
},
"styles": [
{ "name": "default", "label": "No Lines", "isDefault": true },
{ "name": "with-lines", "label": "Lines" },
{ "name": "compact", "label": "Compact" }
],
"editorScript": "file:./index.js",
"viewScript": "file:./view.js",
"style": ["file:./style-view.css","wp-block-query-pagination"]
}

80
src/followers/edit.js Normal file
View file

@ -0,0 +1,80 @@
import { SelectControl, RangeControl, PanelBody, TextControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { useMemo, useState } from '@wordpress/element';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
import { Followers } from './followers';
const enabled = window._activityPubOptions?.enabled;
function useUserOptions() {
const users = enabled?.users ? useSelect( ( select ) => select( 'core' ).getUsers( { who: 'authors' } ) ) : [];
return useMemo( () => {
if ( ! users ) {
return [];
}
const withBlogUser = enabled?.site ? [ {
label: __( 'Whole Site', 'activitypub' ),
value: 'site'
} ] : [];
return users.reduce( ( acc, user ) => {
acc.push({
label: user.name,
value: user.id
} );
return acc;
}, withBlogUser );
}, [ users ] );
}
export default function Edit( { attributes, setAttributes } ) {
const { order, per_page, selectedUser, title } = attributes;
const blockProps = useBlockProps();
const [ page, setPage ] = useState( 1 );
const orderOptions = [
{ label: __( 'New to old', 'activitypub' ), value: 'desc' },
{ label: __( 'Old to new', 'activitypub' ), value: 'asc' },
];
const usersOptions = useUserOptions();
const setAttributestAndResetPage = ( key ) => {
return ( value ) => {
setPage( 1 );
setAttributes( { [ key ]: value } );
};
}
return (
<div { ...blockProps }>
<InspectorControls key="setting">
<PanelBody title={ __( 'Followers Options', 'activitypub' ) }>
<TextControl
label={ __( 'Title', 'activitypub' ) }
help={ __( 'Title to display above the list of followers. Blank for none.', 'activitypub' ) }
value={ title }
onChange={ value => setAttributes( { title: value } ) }
/>
<SelectControl
label= { __( 'Select User', 'activitypub' ) }
value={ selectedUser }
options={ usersOptions }
onChange={ setAttributestAndResetPage( 'selectedUser' ) }
/>
<SelectControl
label={ __( 'Sort', 'activitypub' ) }
value={ order }
options={ orderOptions }
onChange={ setAttributestAndResetPage( 'order' ) }
/>
<RangeControl
label={ __( 'Number of Followers', 'activitypub' ) }
value={ per_page }
onChange={ setAttributestAndResetPage( 'per_page' ) }
min={ 1 }
max={ 10 }
/>
</PanelBody>
</InspectorControls>
<Followers { ...attributes } page={ page } setPage={ setPage } followLinks={ false } />
</div>
);
}

110
src/followers/followers.js Normal file
View file

@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
import { createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Pagination } from './pagination';
import { ExternalLink } from '@wordpress/components';
const { namespace } = window._activityPubOptions;
function getPath( userId, per_page, order, page ) {
const path = `/${ namespace }/users/${ userId }/followers`;
const args = {
per_page,
order,
page,
context: 'full'
};
return addQueryArgs( path, args );
}
function usePage() {
const [ page, setPage ] = useState( 1 );
return [ page, setPage ];
}
export function Followers( {
selectedUser,
per_page,
order,
title,
page: passedPage,
setPage: passedSetPage,
className = '',
followLinks = true
} ) {
const userId = selectedUser === 'site' ? 0 : selectedUser;
const [ followers, setFollowers ] = useState( [] );
const [ pages, setPages ] = useState( 0 );
const [ total, setTotal ] = useState( 0 );
const [ localPage, setLocalPage ] = usePage();
const page = passedPage || localPage;
const setPage = passedSetPage || setLocalPage;
const prevLabel = createInterpolateElement(
/* translators: arrow for previous followers link */
__( '<span>←</span> Less', 'activitypub' ),
{
span: <span class="wp-block-query-pagination-previous-arrow is-arrow-arrow" aria-hidden="true" />,
}
);
const nextLabel = createInterpolateElement(
/* translators: arrow for next followers link */
__( 'More <span>→</span>', 'activitypub' ),
{
span: <span class="wp-block-query-pagination-next-arrow is-arrow-arrow" aria-hidden="true" />,
}
);
useEffect( () => {
const path = getPath( userId, per_page, order, page );
apiFetch( { path } )
.then( ( data ) => {
setPages( Math.ceil( data.totalItems / per_page ) );
setTotal( data.totalItems );
setFollowers( data.orderedItems );
} )
.catch( ( error ) => console.error( error ) );
}, [ userId, per_page, order, page ] );
return (
<div className={ "activitypub-follower-block " + className }>
<h3>{ title }</h3>
<ul>
{ followers && followers.map( ( follower ) => (
<li key={ follower.url }>
<Follower { ...follower } followLinks={ followLinks } />
</li>
) ) }
</ul>
{ pages > 1 && (
<Pagination
page={ page }
perPage={ per_page }
total={ total }
pageClick={ setPage }
nextLabel={ nextLabel }
prevLabel={ prevLabel }
compact={ className === 'is-style-compact' }
/>
) }
</div>
);
}
function Follower( { name, icon, url, preferredUsername, followLinks = true } ) {
const handle = `@${ preferredUsername }`;
const extraProps = {};
if ( ! followLinks ) {
extraProps.onClick = event => event.preventDefault();
}
return (
<ExternalLink className="activitypub-link" href={ url } title={ handle } { ...extraProps }>
<img width="40" height="40" src={ icon.url } class="avatar activitypub-avatar" />
<span class="activitypub-actor">
<strong className="activitypub-name">{ name }</strong>
<span class="sep">/</span>
<span class="activitypub-handle">{ handle }</span>
</span>
</ExternalLink>
)
}

5
src/followers/index.js Normal file
View file

@ -0,0 +1,5 @@
import { registerBlockType } from '@wordpress/blocks';
import { people } from '@wordpress/icons';
import edit from './edit';
const save = () => null;
registerBlockType( 'activitypub/followers', { edit, save, icon: people } );

View file

@ -0,0 +1,18 @@
import classNames from 'classnames';
export function PaginationPage( { active, children, page, pageClick, className } ) {
const handleClick = event => {
event.preventDefault();
! active && pageClick( page );
};
const classes = classNames( 'wp-block activitypub-pager', className , {
'current': active,
} );
return (
<a className={ classes }onClick={ handleClick }>
{ children }
</a>
);
}

View file

@ -0,0 +1,82 @@
// Adapted from: https://github.com/Automattic/wp-calypso/tree/trunk/client/components/pagination
// Markup adapted to imitate the core query-pagination component so we can inherit those styles.
import classnames from 'classnames';
import { PaginationPage } from './pagination-page';
const PaginationVariant = {
outlined: 'outlined',
minimal: 'minimal',
};
export function Pagination( { compact, nextLabel, page, pageClick, perPage, prevLabel, total, variant = PaginationVariant.outlined } ) {
const getPageList = ( page, pageCount ) => {
let pageList = [ 1, page - 2, page - 1, page, page + 1, page + 2, pageCount ];
pageList.sort( ( a, b ) => a - b );
// Remove pages less than 1, or greater than total number of pages, and remove duplicates
pageList = pageList.filter( ( pageNumber, index, originalPageList ) => {
return (
pageNumber >= 1 &&
pageNumber <= pageCount &&
originalPageList.lastIndexOf( pageNumber ) === index
);
} );
for ( let i = pageList.length - 2; i >= 0; i-- ) {
if ( pageList[ i ] === pageList[ i + 1 ] ) {
pageList.splice( i + 1, 1 );
}
}
return pageList;
};
const pageList = getPageList( page, Math.ceil( total / perPage ) );
const className = classnames( 'alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex', `is-${ variant }`, {
'is-compact': compact,
} );
return (
<nav className={ className }>
{ prevLabel && (
<PaginationPage
key="prev"
page={ page - 1 }
pageClick={ pageClick }
active={ page === 1 }
aria-label={ prevLabel }
className="wp-block-query-pagination-previous block-editor-block-list__block"
>
{ prevLabel }
</PaginationPage>
) }
{ ! compact && (
<div className="block-editor-block-list__block wp-block wp-block-query-pagination-numbers">
{ pageList.map( pageNumber => (
<PaginationPage
key={ pageNumber }
page={ pageNumber }
pageClick={ pageClick }
active={ pageNumber === page }
className="page-numbers"
>
{ pageNumber }
</PaginationPage>
) ) }
</div>
) }
{ nextLabel && (
<PaginationPage
key="next"
page={ page + 1 }
pageClick={ pageClick }
active={ page === Math.ceil( total / perPage ) }
aria-label={ nextLabel }
className="wp-block-query-pagination-next block-editor-block-list__block"
>
{ nextLabel }
</PaginationPage>
) }
</nav>
);
}

View file

@ -0,0 +1,179 @@
// I like lamp.
$minimal-inactive: #999;
$font-body-small: 12px;
.followers-pagination {
width: 100%;
&.is-compact .pagination__list-item {
.pagination__list-button.button.is-borderless {
padding: 5px 8px;
}
&.pagination__ellipsis span {
padding: 5px 6px;
}
&.pagination__arrow .pagination__list-button.button.is-borderless {
padding: 6px;
}
}
&.is-minimal {
.pagination__list-item,
.pagination__list-item:first-child,
.pagination__list-item:last-child {
.pagination__list-button.button {
padding: 8px 16px;
background-color: transparent;
border: none;
color: $minimal-inactive;
&:hover {
color: var( --wp--preset--color--contrast, #000 );
}
&:disabled {
visibility: hidden;
}
}
&.pagination__ellipsis span {
padding: 8px 16px;
background-color: transparent;
border: none;
color: $minimal-inactive;
}
&.is-selected {
.pagination__list-button.button {
background-color: transparent;
color: var( --wp--preset--color--contrast, #000 );
&:hover {
color: var( --wp--preset--color--contrast, #000 );
}
}
}
&.pagination__arrow {
.pagination__list-button {
color: var( --wp--preset--color--contrast, #000 );
line-height: calc( var( --wp--preset--color--contrast, #000 ) * 1.5 );
}
.gridicon {
width: calc( var( --wp--preset--color--contrast, #000 ) * 1.5 );
height: calc( var( --wp--preset--color--contrast, #000 ) * 1.5 );
}
}
}
}
}
.pagination__list {
display: flex;
margin: 0;
list-style: none;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
// List item styles for all links
.pagination__list-item {
.pagination__list-button.button.is-borderless,
&.pagination__ellipsis span {
padding: 8px 12px;
background-color: var(--color-surface);
border: solid 1px var(--color-neutral-10);
border-right: none;
font-size: $font-body-small;
line-height: 18px;
color: var(--color-text-subtle);
text-align: center;
border-radius: 0;
display: flex;
flex-wrap: nowrap;
.gridicon {
top: 1px;
.pagination.is-compact & {
top: 0;
}
}
}
&:first-child .pagination__list-button.button.is-borderless {
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
}
&:last-child .pagination__list-button.button.is-borderless {
border-right: solid 1px var(--color-neutral-10);
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
}
}
// Left/right navigation arrows
.pagination__list-item.pagination__arrow {
display: flex;
align-items: center;
&.is-left {
padding-left: 14px;
float: left;
.gridicon {
margin-right: 2px;
}
}
&.is-right {
padding-right: 14px;
float: right;
.gridicon {
margin-left: 2px;
}
}
.gridicon {
vertical-align: middle;
width: 18px;
height: 18px;
}
}
// // Hover/focus states
.pagination__list-item .pagination__list-button.button.is-borderless:not([disabled]):hover,
.pagination__list-item .pagination__list-button.button.is-borderless:focus {
color: var(--color-neutral-70);
outline: none;
}
// Selected state
.pagination__list-item.is-selected .pagination__list-button.button.is-borderless {
border-color: var(--color-primary);
background-color: var(--color-primary);
color: var(--color-text-inverted);
}
.pagination__list-item.is-selected .pagination__list-button.button.is-borderless:hover {
color: var(--color-text-inverted);
}
.pagination__list-item.is-selected + .pagination__list-item
.pagination__list-button.button.is-borderless {
border-left: 0;
}
// Abridgement indication
.pagination__ellipsis,
.pagination__ellipsis:hover {
color: var(--color-neutral-light);
}

82
src/followers/style.scss Normal file
View file

@ -0,0 +1,82 @@
.activitypub-follower-block {
&.is-style-compact {
.activitypub-handle, .sep {
display: none;
}
}
&.is-style-with-lines {
ul li {
border-bottom: .5px solid;
padding-bottom: .5rem;
margin-bottom: .5rem;
&:last-child {
border-bottom: none;
}
}
.activitypub-name, .activitypub-handle {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
ul {
margin: 0 !important;
padding: 0 !important;
}
li {
display: flex;
margin-bottom: 1rem;
}
img {
border-radius: 50%;
width: 40px;
height: 40px;
margin-right: var( --wp--preset--spacing--20, .5rem );
}
.activitypub-link {
display: flex;
flex-flow: row nowrap;
align-items: center;
max-width: 100%;
color: inherit !important;
text-decoration: none !important; // overrides core styles for external links
}
.activitypub-name, .activitypub-handle {
text-decoration: underline;
text-decoration-thickness: .8px;
text-underline-position: under;
&:hover {
text-decoration: none;
}
}
.activitypub-name {
font-size: var( --wp--preset--font-size--normal, 16px );
}
.activitypub-actor {
font-size: var( --wp--preset--font-size--small, 13px );
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sep {
padding: 0 .2rem;
}
.wp-block-query-pagination {
margin-top: 1.5rem;
}
.activitypub-pager {
cursor: default;
&.current {
opacity: .33;
}
}
.page-numbers {
padding: 0 .2rem;
&.current {
opacity: 1;
font-weight: bold;
}
}
}

12
src/followers/view.js Normal file
View file

@ -0,0 +1,12 @@
import './style.scss';
import { Followers } from './followers';
import { render } from '@wordpress/element';
import domReady from '@wordpress/dom-ready';
domReady( () => {
// iterate over a nodelist
[].forEach.call( document.querySelectorAll( '.activitypub-follower-block' ), ( element ) => {
const attrs = JSON.parse( element.dataset.attrs );
render( <Followers { ...attrs } />, element );
} );
} );

View file

@ -1,3 +1,6 @@
<?php
// phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
?>
<div class="activitypub-settings-header">
<div class="activitypub-settings-title-section">
<h1><?php \esc_html_e( 'ActivityPub', 'activitypub' ); ?></h1>
@ -11,6 +14,14 @@
<a href="<?php echo \esc_url_raw( admin_url( 'options-general.php?page=activitypub&tab=settings' ) ); ?>" class="activitypub-settings-tab <?php echo \esc_attr( $args['settings'] ); ?>">
<?php \esc_html_e( 'Settings', 'activitypub' ); ?>
</a>
<?php if ( ! \Activitypub\is_user_disabled( \Activitypub\Collection\Users::BLOG_USER_ID ) ) : ?>
<a href="<?php echo \esc_url_raw( admin_url( 'options-general.php?page=activitypub&tab=followers' ) ); ?>" class="activitypub-settings-tab <?php echo \esc_attr( $args['followers'] ); ?>">
<?php \esc_html_e( 'Followers', 'activitypub' ); ?>
</a>
<?php endif; ?>
</nav>
</div>
<hr class="wp-header-end">

View file

@ -1,92 +1,14 @@
<?php
$author_id = \get_the_author_meta( 'ID' );
$user = \Activitypub\Collection\Users::get_by_id( \get_the_author_meta( 'ID' ) );
$json = new \stdClass();
$json->{'@context'} = \Activitypub\get_context();
$json->id = \get_author_posts_url( $author_id );
$json->type = 'Person';
$json->name = \get_the_author_meta( 'display_name', $author_id );
$json->summary = \html_entity_decode(
\get_the_author_meta( 'description', $author_id ),
\ENT_QUOTES,
'UTF-8'
$user->set_context(
\Activitypub\Activity\Activity::CONTEXT
);
$json->preferredUsername = \get_the_author_meta( 'login', $author_id ); // phpcs:ignore
$json->url = \get_author_posts_url( $author_id );
$json->icon = array(
'type' => 'Image',
'url' => \get_avatar_url( $author_id, array( 'size' => 120 ) ),
);
$json->published = \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( \get_the_author_meta( 'registered', $author_id ) ) );
if ( \has_header_image() ) {
$json->image = array(
'type' => 'Image',
'url' => \get_header_image(),
);
}
$json->inbox = \get_rest_url( null, "/activitypub/1.0/users/$author_id/inbox" );
$json->outbox = \get_rest_url( null, "/activitypub/1.0/users/$author_id/outbox" );
$json->followers = \get_rest_url( null, "/activitypub/1.0/users/$author_id/followers" );
$json->following = \get_rest_url( null, "/activitypub/1.0/users/$author_id/following" );
$json->manuallyApprovesFollowers = \apply_filters( 'activitypub_json_manually_approves_followers', \__return_false() ); // phpcs:ignore
// phpcs:ignore
$json->publicKey = array(
'id' => \get_author_posts_url( $author_id ) . '#main-key',
'owner' => \get_author_posts_url( $author_id ),
'publicKeyPem' => \trim( \Activitypub\Signature::get_public_key( $author_id ) ),
);
$json->tag = array();
$json->attachment = array();
$json->attachment['blog_url'] = array(
'type' => 'PropertyValue',
'name' => \__( 'Blog', 'activitypub' ),
'value' => \html_entity_decode(
'<a rel="me" title="' . \esc_attr( \home_url( '/' ) ) . '" target="_blank" href="' . \home_url( '/' ) . '">' . \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) . '</a>',
\ENT_QUOTES,
'UTF-8'
),
);
$json->attachment['profile_url'] = array(
'type' => 'PropertyValue',
'name' => \__( 'Profile', 'activitypub' ),
'value' => \html_entity_decode(
'<a rel="me" title="' . \esc_attr( \get_author_posts_url( $author_id ) ) . '" target="_blank" href="' . \get_author_posts_url( $author_id ) . '">' . \wp_parse_url( \get_author_posts_url( $author_id ), \PHP_URL_HOST ) . '</a>',
\ENT_QUOTES,
'UTF-8'
),
);
if ( \get_the_author_meta( 'user_url', $author_id ) ) {
$json->attachment['user_url'] = array(
'type' => 'PropertyValue',
'name' => \__( 'Website', 'activitypub' ),
'value' => \html_entity_decode(
'<a rel="me" title="' . \esc_attr( \get_the_author_meta( 'user_url', $author_id ) ) . '" target="_blank" href="' . \get_the_author_meta( 'user_url', $author_id ) . '">' . \wp_parse_url( \get_the_author_meta( 'user_url', $author_id ), \PHP_URL_HOST ) . '</a>',
\ENT_QUOTES,
'UTF-8'
),
);
}
// filter output
$json = \apply_filters( 'activitypub_json_author_array', $json, $author_id );
// migrate to ActivityPub standard
$json->attachment = array_values( $json->attachment );
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_json_author_pre', $author_id );
\do_action( 'activitypub_json_author_pre', $user->get__id() );
$options = 0;
// JSON_PRETTY_PRINT added in PHP 5.4
@ -101,12 +23,12 @@ $options |= \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT;
*
* @param int $options The current options flags
*/
$options = \apply_filters( 'activitypub_json_author_options', $options, $author_id );
$options = \apply_filters( 'activitypub_json_author_options', $options, $user->get__id() );
\header( 'Content-Type: application/activity+json' );
echo \wp_json_encode( $json, $options );
echo \wp_json_encode( $user->to_array(), $options );
/*
* Action triggerd after the ActivityPub profile has been created and sent to the client
*/
\do_action( 'activitypub_json_author_post', $author_id );
\do_action( 'activitypub_json_author_post', $user->get__id() );

View file

@ -1,66 +1,14 @@
<?php
$json = new \stdClass();
$user = new \Activitypub\Model\Blog_User();
$json->{'@context'} = \Activitypub\get_context();
$json->id = \get_home_url( '/' );
$json->type = 'Organization';
$json->name = \get_bloginfo( 'name' );
$json->summary = \html_entity_decode(
\get_bloginfo( 'description' ),
\ENT_QUOTES,
'UTF-8'
$user->set_context(
\Activitypub\Activity\Activity::CONTEXT
);
$json->preferredUsername = \get_bloginfo( 'name' ); // phpcs:ignore
$json->url = \get_home_url( '/' );
if ( \has_site_icon() ) {
$json->icon = array(
'type' => 'Image',
'url' => \get_site_icon_url( 120 ),
);
}
if ( \has_header_image() ) {
$json->image = array(
'type' => 'Image',
'url' => \get_header_image(),
);
}
$json->inbox = \get_rest_url( null, '/activitypub/1.0/blog/inbox' );
$json->outbox = \get_rest_url( null, '/activitypub/1.0/blog/outbox' );
$json->followers = \get_rest_url( null, '/activitypub/1.0/blog/followers' );
$json->following = \get_rest_url( null, '/activitypub/1.0/blog/following' );
$json->manuallyApprovesFollowers = \apply_filters( 'activitypub_json_manually_approves_followers', \__return_false() ); // phpcs:ignore
// phpcs:ignore
$json->publicKey = array(
'id' => \get_home_url( '/' ) . '#main-key',
'owner' => \get_home_url( '/' ),
'publicKeyPem' => '',
);
$json->tag = array();
$json->attachment = array();
$json->attachment[] = array(
'type' => 'PropertyValue',
'name' => \__( 'Blog', 'activitypub' ),
'value' => \html_entity_decode(
'<a rel="me" title="' . \esc_attr( \home_url( '/' ) ) . '" target="_blank" href="' . \home_url( '/' ) . '">' . \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) . '</a>',
\ENT_QUOTES,
'UTF-8'
),
);
// filter output
$json = \apply_filters( 'activitypub_json_blog_array', $json );
/*
* Action triggerd prior to the ActivityPub profile being created and sent to the client
*/
\do_action( 'activitypub_json_blog_pre' );
\do_action( 'activitypub_json_author_pre', $user->get__id() );
$options = 0;
// JSON_PRETTY_PRINT added in PHP 5.4
@ -75,12 +23,12 @@ $options |= \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT;
*
* @param int $options The current options flags
*/
$options = \apply_filters( 'activitypub_json_blog_options', $options );
$options = \apply_filters( 'activitypub_json_author_options', $options, $user->get__id() );
\header( 'Content-Type: application/activity+json' );
echo \wp_json_encode( $json, $options );
echo \wp_json_encode( $user->to_array(), $options );
/*
* Action triggerd after the ActivityPub profile has been created and sent to the client
*/
\do_action( 'activitypub_json_blog_post' );
\do_action( 'activitypub_json_author_post', $user->get__id() );

View file

@ -0,0 +1,30 @@
<?php
\load_template(
__DIR__ . '/admin-header.php',
true,
array(
'settings' => '',
'welcome' => '',
'followers' => 'active',
)
);
?>
<div class="wrap activitypub-followers-page">
<h1><?php \esc_html_e( 'Followers', 'activitypub' ); ?></h1>
<?php $table = new \Activitypub\Table\Followers(); ?>
<?php // translators: The follower count. ?>
<p><?php \printf( \esc_html__( 'You currently have %s followers.', 'activitypub' ), \esc_attr( $table->get_user_count() ) ); ?></p>
<form method="get">
<input type="hidden" name="page" value="activitypub" />
<input type="hidden" name="tab" value="followers" />
<?php
$table->prepare_items();
$table->display();
?>
<?php wp_nonce_field( 'activitypub-followers-list', '_apnonce' ); ?>
</form>
</div>

View file

@ -1,16 +0,0 @@
<div class="wrap">
<h1><?php \esc_html_e( 'Followers (Fediverse)', 'activitypub' ); ?></h1>
<?php // translators: ?>
<p><?php \printf( \esc_html__( 'You currently have %s followers.', 'activitypub' ), \esc_attr( \Activitypub\Peer\Followers::count_followers( \get_current_user_id() ) ) ); ?></p>
<?php $token_table = new \Activitypub\Table\Followers_List(); ?>
<form method="get">
<input type="hidden" name="page" value="indieauth_user_token" />
<?php
$token_table->prepare_items();
$token_table->display();
?>
</form>
</div>

View file

@ -2,8 +2,8 @@
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$post = \get_post();
$activitypub_post = new \Activitypub\Model\Post( $post );
$json = \array_merge( array( '@context' => \Activitypub\get_context() ), $activitypub_post->to_array() );
$object = new \Activitypub\Transformer\Post( $post );
$json = \array_merge( array( '@context' => \Activitypub\get_context() ), $object->to_object()->to_array() );
// filter output
$json = \apply_filters( 'activitypub_json_post_array', $json );

View file

@ -1,16 +1,18 @@
<?php
\load_template(
\dirname( __FILE__ ) . '/admin-header.php',
__DIR__ . '/admin-header.php',
true,
array(
'settings' => 'active',
'welcome' => '',
'settings' => 'active',
'welcome' => '',
'followers' => '',
)
);
?>
<div class="privacy-settings-body hide-if-no-js">
<div class="notice notice-info">
<div class="activitypub-settings activitypub-settings-page hide-if-no-js">
<div class="box">
<h3><?php \esc_html_e( 'Troubleshooting', 'activitypub' ); ?></h3>
<p>
<?php
echo \wp_kses(
@ -25,156 +27,257 @@
</p>
</div>
<p><?php \esc_html_e( 'Customize your ActivityPub settings to suit your needs.', 'activitypub' ); ?></p>
<form method="post" action="options.php">
<?php \settings_fields( 'activitypub' ); ?>
<h3><?php \esc_html_e( 'Activities', 'activitypub' ); ?></h3>
<div class="box">
<h3><?php \esc_html_e( 'Users', 'activitypub' ); ?></h3>
<p><?php \esc_html_e( 'All activity related settings.', 'activitypub' ); ?></p>
<p><?php \esc_html_e( 'All user related settings.', 'activitypub' ); ?></p>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<?php \esc_html_e( 'Post-Content', 'activitypub' ); ?>
</th>
<td>
<p>
<label><input type="radio" name="activitypub_post_content_type" id="activitypub_post_content_type_title_link" value="title" <?php echo \checked( 'title', \get_option( 'activitypub_post_content_type', 'content' ) ); ?> /> <?php \esc_html_e( 'Title and link', 'activitypub' ); ?></label> - <span class="description"><?php \esc_html_e( 'Only the title and a link.', 'activitypub' ); ?></span>
</p>
<p>
<label><input type="radio" name="activitypub_post_content_type" id="activitypub_post_content_type_excerpt" value="excerpt" <?php echo \checked( 'excerpt', \get_option( 'activitypub_post_content_type', 'content' ) ); ?> /> <?php \esc_html_e( 'Excerpt', 'activitypub' ); ?></label> - <span class="description"><?php \esc_html_e( 'A content summary, shortened to 400 characters and without markup.', 'activitypub' ); ?></span>
</p>
<p>
<label><input type="radio" name="activitypub_post_content_type" id="activitypub_post_content_type_content" value="content" <?php echo \checked( 'content', \get_option( 'activitypub_post_content_type', 'content' ) ); ?> /> <?php \esc_html_e( 'Content (default)', 'activitypub' ); ?></label> - <span class="description"><?php \esc_html_e( 'The full content.', 'activitypub' ); ?></span>
</p>
<p>
<label><input type="radio" name="activitypub_post_content_type" id="activitypub_post_content_type_custom" value="custom" <?php echo \checked( 'custom', \get_option( 'activitypub_post_content_type', 'content' ) ); ?> /> <?php \esc_html_e( 'Custom', 'activitypub' ); ?></label> - <span class="description"><?php \esc_html_e( 'Use the text-area below, to customize your activities.', 'activitypub' ); ?></span>
</p>
<p>
<textarea name="activitypub_custom_post_content" id="activitypub_custom_post_content" rows="10" cols="50" class="large-text" placeholder="<?php echo wp_kses( ACTIVITYPUB_CUSTOM_POST_CONTENT, 'post' ); ?>"><?php echo wp_kses( \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT ), 'post' ); ?></textarea>
<details>
<summary><?php esc_html_e( 'See a list of ActivityPub Template Tags.', 'activitypub' ); ?></summary>
<div class="description">
<ul>
<li><code>[ap_title]</code> - <?php \esc_html_e( 'The post\'s title.', 'activitypub' ); ?></li>
<li><code>[ap_content]</code> - <?php \esc_html_e( 'The post\'s content.', 'activitypub' ); ?></li>
<li><code>[ap_excerpt]</code> - <?php \esc_html_e( 'The post\'s excerpt (default 400 chars).', 'activitypub' ); ?></li>
<li><code>[ap_permalink]</code> - <?php \esc_html_e( 'The post\'s permalink.', 'activitypub' ); ?></li>
<li><code>[ap_shortlink]</code> - <?php echo \wp_kses( \__( 'The post\'s shortlink. I can recommend <a href="https://wordpress.org/plugins/hum/" target="_blank">Hum</a>.', 'activitypub' ), 'default' ); ?></li>
<li><code>[ap_hashtags]</code> - <?php \esc_html_e( 'The post\'s tags as hashtags.', 'activitypub' ); ?></li>
<li><code>[ap_hashcats]</code> - <?php \esc_html_e( 'The post\'s categories as hashtags.', 'activitypub' ); ?></li>
<li><code>[ap_image]</code> - <?php \esc_html_e( 'The URL for the post\'s featured image.', 'activitypub' ); ?></li>
</ul>
<p><?php \esc_html_e( 'You can find the full list with all possible attributes in the help section on the top-right of the screen.', 'activitypub' ); ?></p>
</div>
</details>
</p>
</td>
</tr>
<tr>
<th scope="row">
<?php \esc_html_e( 'Number of images', 'activitypub' ); ?>
</th>
<td>
<input value="<?php echo esc_attr( \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ); ?>" name="activitypub_max_image_attachments" id="activitypub_max_image_attachments" type="number" min="0" />
<p class="description">
<?php
echo \wp_kses(
\sprintf(
// translators:
\__( 'The number of images to attach to posts. Default: <code>%s</code>', 'activitypub' ),
\esc_html( ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS )
),
'default'
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<?php \esc_html_e( 'Activity-Object-Type', 'activitypub' ); ?>
</th>
<td>
<p>
<label><input type="radio" name="activitypub_object_type" id="activitypub_object_type_note" value="note" <?php echo \checked( 'note', \get_option( 'activitypub_object_type', 'note' ) ); ?> /> <?php \esc_html_e( 'Note (default)', 'activitypub' ); ?></label> - <span class="description"><?php \esc_html_e( 'Should work with most platforms.', 'activitypub' ); ?></span>
</p>
<p>
<label><input type="radio" name="activitypub_object_type" id="activitypub_object_type_article" value="article" <?php echo \checked( 'article', \get_option( 'activitypub_object_type', 'note' ) ); ?> /> <?php \esc_html_e( 'Article', 'activitypub' ); ?></label> - <span class="description"><?php \esc_html_e( 'The presentation of the "Article" might change on different platforms. Mastodon for example shows the "Article" type as a simple link.', 'activitypub' ); ?></span>
</p>
<p>
<label><input type="radio" name="activitypub_object_type" id="activitypub_object_type" value="wordpress-post-format" <?php echo \checked( 'wordpress-post-format', \get_option( 'activitypub_object_type', 'note' ) ); ?> /> <?php \esc_html_e( 'WordPress Post-Format', 'activitypub' ); ?></label> - <span class="description"><?php \esc_html_e( 'Maps the WordPress Post-Format to the ActivityPub Object Type.', 'activitypub' ); ?></span>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php \esc_html_e( 'Supported post types', 'activitypub' ); ?></th>
<td>
<fieldset>
<?php \esc_html_e( 'Enable ActivityPub support for the following post types:', 'activitypub' ); ?>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<?php \esc_html_e( 'Enable/disable Users by Type', 'activitypub' ); ?>
</th>
<td>
<p>
<label>
<input type="checkbox" name="activitypub_enable_users" id="activitypub_enable_users" value="1" <?php echo \checked( '1', \get_option( 'activitypub_enable_users', '1' ) ); ?> />
<?php \esc_html_e( 'Enable Authors', 'activitypub' ); ?>
</label>
</p>
<p class="description">
<?php echo \wp_kses( \__( 'Every Author on this Blog (with the <code>publish_posts</code> capability) gets his own ActivityPub enabled Profile.', 'activitypub' ), array( 'code' => array() ) ); ?>
</p>
<p>
<label>
<input type="checkbox" name="activitypub_enable_blog_user" id="activitypub_enable_blog_user" value="1" <?php echo \checked( '1', \get_option( 'activitypub_enable_blog_user', '0' ) ); ?> />
<?php \esc_html_e( 'Enable Blog-User', 'activitypub' ); ?>
</label>
</p>
<p class="description">
<?php \esc_html_e( 'Your Blog becomes an ActivityPub compatible Profile.', 'activitypub' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<?php \esc_html_e( 'Change Blog-User Identifier', 'activitypub' ); ?>
</th>
<td>
<label for="activitypub_blog_user_identifier">
<input class="blog-user-identifier" name="activitypub_blog_user_identifier" id="activitypub_blog_user_identifier" type="text" value="<?php echo esc_attr( \get_option( 'activitypub_blog_user_identifier', \Activitypub\Model\Blog_User::get_default_username() ) ); ?>" />
@<?php echo esc_html( \wp_parse_url( \home_url(), PHP_URL_HOST ) ); ?>
</label>
<p class="description">
<?php \esc_html_e( 'This Blog-User will federate all posts written on your Blog, regardless of the User who posted it.', 'activitypub' ); ?>
</p>
</td>
</tr>
</tbody>
</table>
<?php $post_types = \get_post_types( array( 'public' => true ), 'objects' ); ?>
<?php $support_post_types = \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ) ? \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ) : array(); ?>
<ul>
<?php // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited ?>
<?php foreach ( $post_types as $post_type ) { ?>
<li>
<input type="checkbox" id="activitypub_support_post_types" name="activitypub_support_post_types[]" value="<?php echo \esc_attr( $post_type->name ); ?>" <?php echo \checked( true, \in_array( $post_type->name, $support_post_types, true ) ); ?> />
<label for="<?php echo \esc_attr( $post_type->name ); ?>"><?php echo \esc_html( $post_type->label ); ?></label>
</li>
<?php } ?>
</ul>
</fieldset>
</td>
</tr>
<tr>
<th scope="row">
<?php \esc_html_e( 'Hashtags (beta)', 'activitypub' ); ?>
</th>
<td>
<p>
<label><input type="checkbox" name="activitypub_use_hashtags" id="activitypub_use_hashtags" value="1" <?php echo \checked( '1', \get_option( 'activitypub_use_hashtags', '1' ) ); ?> /> <?php echo wp_kses( \__( 'Add hashtags in the content as native tags and replace the <code>#tag</code> with the tag-link. <strong>This feature is experimental! Please disable it, if you find any HTML or CSS errors.</strong>', 'activitypub' ), 'default' ); ?></label>
</p>
</td>
</tr>
</tbody>
</table>
<?php \do_settings_fields( 'activitypub', 'user' ); ?>
</div>
<?php \do_settings_fields( 'activitypub', 'activity' ); ?>
<div class="box">
<h3><?php \esc_html_e( 'Activities', 'activitypub' ); ?></h3>
<h3><?php \esc_html_e( 'Server', 'activitypub' ); ?></h3>
<p><?php \esc_html_e( 'All activity related settings.', 'activitypub' ); ?></p>
<p><?php \esc_html_e( 'Server related settings.', 'activitypub' ); ?></p>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<?php \esc_html_e( 'Post-Content', 'activitypub' ); ?>
</th>
<td>
<p>
<label for="activitypub_post_content_type_title_link">
<input type="radio" name="activitypub_post_content_type" id="activitypub_post_content_type_title_link" value="title" <?php echo \checked( 'title', \get_option( 'activitypub_post_content_type', 'content' ) ); ?> />
<?php \esc_html_e( 'Title and link', 'activitypub' ); ?>
-
<span class="description">
<?php \esc_html_e( 'Only the title and a link.', 'activitypub' ); ?>
</span>
</label>
</p>
<p>
<label for="activitypub_post_content_type_excerpt">
<input type="radio" name="activitypub_post_content_type" id="activitypub_post_content_type_excerpt" value="excerpt" <?php echo \checked( 'excerpt', \get_option( 'activitypub_post_content_type', 'content' ) ); ?> />
<?php \esc_html_e( 'Excerpt', 'activitypub' ); ?>
-
<span class="description">
<?php \esc_html_e( 'A content summary, shortened to 400 characters and without markup.', 'activitypub' ); ?>
</span>
</label>
</p>
<p>
<label for="activitypub_post_content_type_content">
<input type="radio" name="activitypub_post_content_type" id="activitypub_post_content_type_content" value="content" <?php echo \checked( 'content', \get_option( 'activitypub_post_content_type', 'content' ) ); ?> />
<?php \esc_html_e( 'Content (default)', 'activitypub' ); ?>
-
<span class="description">
<?php \esc_html_e( 'The full content.', 'activitypub' ); ?>
</span>
</label>
</p>
<p>
<label for="activitypub_post_content_type_custom">
<input type="radio" name="activitypub_post_content_type" id="activitypub_post_content_type_custom" value="custom" <?php echo \checked( 'custom', \get_option( 'activitypub_post_content_type', 'content' ) ); ?> />
<?php \esc_html_e( 'Custom', 'activitypub' ); ?>
-
<span class="description">
<?php \esc_html_e( 'Use the text-area below, to customize your activities.', 'activitypub' ); ?>
</span>
</label>
</p>
<p>
<textarea name="activitypub_custom_post_content" id="activitypub_custom_post_content" rows="10" cols="50" class="large-text" placeholder="<?php echo wp_kses( ACTIVITYPUB_CUSTOM_POST_CONTENT, 'post' ); ?>"><?php echo wp_kses( \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT ), 'post' ); ?></textarea>
<details>
<summary><?php esc_html_e( 'See a list of ActivityPub Template Tags.', 'activitypub' ); ?></summary>
<div class="description">
<ul>
<li><code>[ap_title]</code> - <?php \esc_html_e( 'The post\'s title.', 'activitypub' ); ?></li>
<li><code>[ap_content]</code> - <?php \esc_html_e( 'The post\'s content.', 'activitypub' ); ?></li>
<li><code>[ap_excerpt]</code> - <?php \esc_html_e( 'The post\'s excerpt (default 400 chars).', 'activitypub' ); ?></li>
<li><code>[ap_permalink]</code> - <?php \esc_html_e( 'The post\'s permalink.', 'activitypub' ); ?></li>
<li><code>[ap_shortlink]</code> - <?php echo \wp_kses( \__( 'The post\'s shortlink. I can recommend <a href="https://wordpress.org/plugins/hum/" target="_blank">Hum</a>.', 'activitypub' ), 'default' ); ?></li>
<li><code>[ap_hashtags]</code> - <?php \esc_html_e( 'The post\'s tags as hashtags.', 'activitypub' ); ?></li>
</ul>
<p><?php \esc_html_e( 'You can find the full list with all possible attributes in the help section on the top-right of the screen.', 'activitypub' ); ?></p>
</div>
</details>
</p>
</td>
</tr>
<tr>
<th scope="row">
<?php \esc_html_e( 'Number of images', 'activitypub' ); ?>
</th>
<td>
<input value="<?php echo esc_attr( \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ); ?>" name="activitypub_max_image_attachments" id="activitypub_max_image_attachments" type="number" min="0" />
<p class="description">
<?php
echo \wp_kses(
\sprintf(
// translators:
\__( 'The number of images to attach to posts. Default: <code>%s</code>', 'activitypub' ),
\esc_html( ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS )
),
'default'
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<?php \esc_html_e( 'Activity-Object-Type', 'activitypub' ); ?>
</th>
<td>
<p>
<label for="activitypub_object_type_note">
<input type="radio" name="activitypub_object_type" id="activitypub_object_type_note" value="note" <?php echo \checked( 'note', \get_option( 'activitypub_object_type', 'note' ) ); ?> />
<?php \esc_html_e( 'Note (default)', 'activitypub' ); ?>
-
<span class="description">
<?php \esc_html_e( 'Should work with most platforms.', 'activitypub' ); ?>
</span>
</label>
</p>
<p><strong><?php \esc_html_e( 'Please note that the following "Activity-Object-Type" options may cause your texts to be displayed differently on each platform and/or parts may be completely ignored. Mastodon, for example, displays all content that is not of the "Note" type as links only.', 'activitypub' ); ?></strong></p>
<p>
<label for="activitypub_object_type_article">
<input type="radio" name="activitypub_object_type" id="activitypub_object_type_article" value="article" <?php echo \checked( 'article', \get_option( 'activitypub_object_type', 'note' ) ); ?> />
<?php \esc_html_e( 'Article', 'activitypub' ); ?>
-
<span class="description">
<?php \esc_html_e( 'The presentation of the "Article" might change on different platforms.', 'activitypub' ); ?>
</span>
</label>
</p>
<p>
<label>
<input type="radio" name="activitypub_object_type" id="activitypub_object_type" value="wordpress-post-format" <?php echo \checked( 'wordpress-post-format', \get_option( 'activitypub_object_type', 'note' ) ); ?> />
<?php \esc_html_e( 'WordPress Post-Format', 'activitypub' ); ?>
-
<span class="description">
<?php \esc_html_e( 'Maps the WordPress Post-Format to the ActivityPub Object Type.', 'activitypub' ); ?>
</span>
</label>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php \esc_html_e( 'Supported post types', 'activitypub' ); ?></th>
<td>
<fieldset>
<?php \esc_html_e( 'Enable ActivityPub support for the following post types:', 'activitypub' ); ?>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<?php \esc_html_e( 'Blocklist', 'activitypub' ); ?>
</th>
<td>
<p class="description">
<?php
echo \wp_kses(
\sprintf(
// translators: %s is a URL.
\__( 'To block servers, add the host of the server to the "<a href="%s">Disallowed Comment Keys</a>" list.', 'activitypub' ),
\esc_attr( \admin_url( 'options-discussion.php#disallowed_keys' ) )
),
'default'
);
?>
</p>
</td>
</tr>
</tbody>
</table>
<?php $post_types = \get_post_types( array( 'public' => true ), 'objects' ); ?>
<?php $support_post_types = \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ) ? \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ) : array(); ?>
<ul>
<?php // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited ?>
<?php foreach ( $post_types as $post_type ) { ?>
<li>
<input type="checkbox" id="activitypub_support_post_type_<?php echo \esc_attr( $post_type->name ); ?>" name="activitypub_support_post_types[]" value="<?php echo \esc_attr( $post_type->name ); ?>" <?php echo \checked( \in_array( $post_type->name, $support_post_types, true ) ); ?> />
<label for="activitypub_support_post_type_<?php echo \esc_attr( $post_type->name ); ?>"><?php echo \esc_html( $post_type->label ); ?></label>
</li>
<?php } ?>
</ul>
</fieldset>
</td>
</tr>
<tr>
<th scope="row">
<?php \esc_html_e( 'Hashtags (beta)', 'activitypub' ); ?>
</th>
<td>
<p>
<label><input type="checkbox" name="activitypub_use_hashtags" id="activitypub_use_hashtags" value="1" <?php echo \checked( '1', \get_option( 'activitypub_use_hashtags', '1' ) ); ?> /> <?php echo wp_kses( \__( 'Add hashtags in the content as native tags and replace the <code>#tag</code> with the tag-link. <strong>This feature is experimental! Please disable it, if you find any HTML or CSS errors.</strong>', 'activitypub' ), 'default' ); ?></label>
</p>
</td>
</tr>
</tbody>
</table>
<?php \do_settings_fields( 'activitypub', 'server' ); ?>
<?php \do_settings_fields( 'activitypub', 'activity' ); ?>
</div>
<div class="box">
<h3><?php \esc_html_e( 'Server', 'activitypub' ); ?></h3>
<p><?php \esc_html_e( 'Server related settings.', 'activitypub' ); ?></p>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<?php \esc_html_e( 'Blocklist', 'activitypub' ); ?>
</th>
<td>
<p class="description">
<?php
echo \wp_kses(
\sprintf(
// translators: %s is a URL.
\__( 'To block servers, add the host of the server to the "<a href="%s">Disallowed Comment Keys</a>" list.', 'activitypub' ),
\esc_attr( \admin_url( 'options-discussion.php#disallowed_keys' ) )
),
'default'
);
?>
</p>
</td>
</tr>
</tbody>
</table>
<?php \do_settings_fields( 'activitypub', 'server' ); ?>
</div>
<?php \do_settings_sections( 'activitypub' ); ?>
<?php \submit_button(); ?>

View file

@ -0,0 +1,16 @@
<div class="wrap">
<h1><?php \esc_html_e( 'Followers', 'activitypub' ); ?></h1>
<?php // translators: The follower count. ?>
<p><?php \printf( \esc_html__( 'You currently have %s followers.', 'activitypub' ), \esc_attr( \Activitypub\Collection\Followers::count_followers( \get_current_user_id() ) ) ); ?></p>
<?php $table = new \Activitypub\Table\Followers(); ?>
<form method="get">
<input type="hidden" name="page" value="activitypub-followers-list" />
<?php
$table->prepare_items();
$table->display();
?>
<?php wp_nonce_field( 'activitypub-followers-list', '_apnonce' ); ?>
</form>
</div>

View file

@ -0,0 +1,32 @@
<?php
// phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
$user = \Activitypub\Collection\Users::get_by_id( \get_current_user_id() ); ?>
<h2 id="activitypub"><?php \esc_html_e( 'ActivityPub', 'activitypub' ); ?></h2>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<label><?php \esc_html_e( 'Profile identifier', 'activitypub' ); ?></label>
</th>
<td>
<p>
<code><?php echo \esc_html( $user->get_resource() ); ?></code> or
<code><?php echo \esc_url( $user->get_url() ); ?></code>
</p>
<?php // translators: the webfinger resource ?>
<p class="description"><?php \printf( \esc_html__( 'Try to follow "@%s" by searching for it on Mastodon,Friendica & Co.', 'activitypub' ), \esc_html( $user->get_resource() ) ); ?></p>
</td>
</tr>
<tr class="activitypub-user-description-wrap">
<th>
<label for="activitypub-user-description"><?php \esc_html_e( 'Biography', 'activitypub' ); ?></label>
</th>
<td>
<textarea name="activitypub-user-description" id="activitypub-user-description" rows="5" cols="30" placeholder="<?php echo \esc_html( get_user_meta( \get_current_user_id(), 'description', true ) ); ?>"><?php echo \esc_html( $args['description'] ); ?></textarea>
<p class="description"><?php \esc_html_e( 'If you wish to use different biographical info for the fediverse, enter your alternate bio here.', 'activitypub' ); ?></p>
</td>
<?php wp_nonce_field( 'activitypub-user-description', '_apnonce' ); ?>
</tr>
</tbody>
</table>

View file

@ -1,55 +1,113 @@
<?php
\load_template(
\dirname( __FILE__ ) . '/admin-header.php',
__DIR__ . '/admin-header.php',
true,
array(
'settings' => '',
'welcome' => 'active',
'settings' => '',
'welcome' => 'active',
'followers' => '',
)
);
?>
<div class="privacy-settings-body hide-if-no-js">
<h2><?php \esc_html_e( 'Welcome', 'activitypub' ); ?></h2>
<div class="activitypub-settings activitypub-welcome-page hide-if-no-js">
<div class="box">
<h2><?php \esc_html_e( 'Welcome', 'activitypub' ); ?></h2>
<p><?php \esc_html_e( 'With ActivityPub your blog becomes part of a federated social network. This means you can share and talk to everyone using the ActivityPub protocol, including users of Friendica, Pleroma and Mastodon.', 'activitypub' ); ?></p>
<p>
<?php
echo wp_kses(
\sprintf(
// translators:
\__(
'People can follow you by using the username <code>%1$s</code> or the URL <code>%2$s</code>. Users who can not access this settings page will find their username on the <a href="%3$s">Edit Profile</a> page.',
'activitypub'
<p><?php echo wp_kses( \__( 'With ActivityPub your blog becomes part of a federated social network. This means you can share and talk to everyone using the <strong>ActivityPub</strong> protocol, including users of <strong>Friendica</strong>, <strong>Pleroma</strong>, <strong>Pixelfed</strong> and <strong>Mastodon</strong>.', 'activitypub' ), array( 'strong' => array() ) ); ?></p>
</div>
<?php
if ( ! \Activitypub\is_user_disabled( \Activitypub\Collection\Users::BLOG_USER_ID ) ) :
$blog_user = new \Activitypub\Model\Blog_User();
?>
<div class="box">
<h3><?php \esc_html_e( 'Blog Account', 'activitypub' ); ?></h3>
<p>
<?php \esc_html_e( 'People can follow your Blog by using:', 'activitypub' ); ?>
</p>
<p>
<label for="activitypub-blog-identifier"><?php \esc_html_e( 'Username', 'activitypub' ); ?></label>
</p>
<p>
<input type="text" class="regular-text" id="activitypub-blog-identifier" value="<?php echo \esc_attr( $blog_user->get_resource() ); ?>" readonly />
</p>
<p>
<label for="activitypub-blog-url"><?php \esc_html_e( 'Profile-URL', 'activitypub' ); ?></label>
</p>
<p>
<input type="text" class="regular-text" id="activitypub-blog-url" value="<?php echo \esc_attr( $blog_user->get_url() ); ?>" readonly />
</p>
<p>
<?php \esc_html_e( 'This Blog-User will federate all posts written on your Blog, regardless of the User who posted it.', 'activitypub' ); ?>
<p>
<p>
<a href="<?php echo \esc_url_raw( \admin_url( '/options-general.php?page=activitypub&tab=settings' ) ); ?>">
<?php \esc_html_e( 'Customize Blog-User on Settings page.', 'activitypub' ); ?>
</a>
</p>
</div>
<?php endif; ?>
<?php
if ( ! \Activitypub\is_user_disabled( get_current_user_id() ) ) :
$user = \Activitypub\Collection\Users::get_by_id( wp_get_current_user()->ID );
?>
<div class="box">
<h3><?php \esc_html_e( 'Personal Account', 'activitypub' ); ?></h3>
<p>
<?php \esc_html_e( 'People can follow you by using your Username:', 'activitypub' ); ?>
</p>
<p>
<label for="activitypub-user-identifier"><?php \esc_html_e( 'Username', 'activitypub' ); ?></label>
</p>
<p>
<input type="text" class="regular-text" id="activitypub-user-identifier" value="<?php echo \esc_attr( $user->get_resource() ); ?>" readonly />
</p>
<p>
<label for="activitypub-user-url"><?php \esc_html_e( 'Profile-URL', 'activitypub' ); ?></label>
</p>
<p>
<input type="text" class="regular-text" id="activitypub-user-url" value="<?php echo \esc_attr( $user->get_url() ); ?>" readonly />
</p>
<p>
<?php \esc_html_e( 'Users who can not access this settings page will find their username on the "Edit Profile" page.', 'activitypub' ); ?>
<p>
<p>
<a href="<?php echo \esc_url_raw( \admin_url( '/profile.php#activitypub' ) ); ?>">
<?php \esc_html_e( 'Customize Username on "Edit Profile" page.', 'activitypub' ); ?>
</a>
</p>
</div>
<?php endif; ?>
<div class="box">
<h3><?php \esc_html_e( 'Troubleshooting', 'activitypub' ); ?></h3>
<p>
<?php
echo wp_kses(
\sprintf(
// translators:
\__(
'If you have problems using this plugin, please check the <a href="%s">Site Health</a> to ensure that your site is compatible and/or use the "Help" tab (in the top right of the settings pages).',
'activitypub'
),
\esc_url_raw( admin_url( 'site-health.php' ) )
),
\esc_attr( \Activitypub\get_webfinger_resource( wp_get_current_user()->ID ) ),
\esc_url_raw( \get_author_posts_url( wp_get_current_user()->ID ) ),
\esc_url_raw( \admin_url( 'profile.php#activitypub' ) )
),
'default'
);
?>
</p>
<p>
<?php
echo wp_kses(
\sprintf(
// translators:
\__( 'If you have problems using this plugin, please check the <a href="%s">Site Health</a> to ensure that your site is compatible and/or use the "Help" tab (in the top right of the settings pages).', 'activitypub' ),
\esc_url_raw( admin_url( 'site-health.php' ) )
),
'default'
);
?>
</p>
'default'
);
?>
</p>
</div>
<hr />
<h3><?php \esc_html_e( 'Recommended Plugins', 'activitypub' ); ?></h3>
<p><?php \esc_html_e( 'ActivityPub works as is and there is no need for you to install additional plugins, nevertheless there are some plugins that extends the functionality of ActivityPub.', 'activitypub' ); ?></p>
<?php if ( ACTIVITYPUB_SHOW_PLUGIN_RECOMMENDATIONS ) : ?>
<div class="box plugin-recommendations">
<h3><?php \esc_html_e( 'Recommended Plugins', 'activitypub' ); ?></h3>
<p><?php \esc_html_e( 'ActivityPub works as is and there is no need for you to install additional plugins, nevertheless there are some plugins that extends the functionality of ActivityPub.', 'activitypub' ); ?></p>
</div>
<div class="activitypub-settings-accordion">
<?php if ( ! \defined( 'FRIENDS_VERSION' ) ) : ?>
<h4 class="activitypub-settings-accordion-heading">
<button aria-expanded="true" class="activitypub-settings-accordion-trigger" aria-controls="activitypub-settings-accordion-block-friends-plugin" type="button">
<span class="title"><?php \esc_html_e( 'Following Others', 'activitypub' ); ?></span>
@ -58,8 +116,10 @@
</h4>
<div id="activitypub-settings-accordion-block-friends-plugin" class="activitypub-settings-accordion-panel plugin-card-friends">
<p><?php \esc_html_e( 'To follow people on Mastodon or similar platforms using your own WordPress, you can use the Friends Plugin for WordPress which uses this plugin to receive posts and display them on your own WordPress, thus making your own WordPress a Fediverse instance of its own.', 'activitypub' ); ?></p>
<p><a href="<?php echo \esc_url_raw( \admin_url( 'plugin-install.php?tab=plugin-information&plugin=friends&TB_iframe=true' ) ); ?>" class="thickbox open-plugin-details-modal button install-now" target="_blank"><?php \esc_html_e( 'Install the Friends Plugin for WordPress', 'activitypub' ); ?></a></p>
<p><a href="<?php echo \esc_url_raw( \admin_url( 'plugin-install.php?tab=plugin-information&plugin=friends&TB_iframe=true' ) ); ?>" class="thickbox open-plugin-details-modal button install-now" target="_blank"><?php \esc_html_e( 'Install the Friends Plugin', 'activitypub' ); ?></a></p>
</div>
<?php endif; ?>
<?php if ( ! \class_exists( 'Hum' ) ) : ?>
<h4 class="activitypub-settings-accordion-heading">
<button aria-expanded="false" class="activitypub-settings-accordion-trigger" aria-controls="activitypub-settings-accordion-block-activitypub-hum-plugin" type="button">
<span class="title"><?php \esc_html_e( 'Add a URL Shortener', 'activitypub' ); ?></span>
@ -68,8 +128,10 @@
</h4>
<div id="activitypub-settings-accordion-block-activitypub-hum-plugin" class="activitypub-settings-accordion-panel plugin-card-hum" hidden="hidden">
<p><?php \esc_html_e( 'Hum is a personal URL shortener for WordPress, designed to provide short URLs to your personal content, both hosted on WordPress and elsewhere.', 'activitypub' ); ?></p>
<p><a href="<?php echo \esc_url_raw( \admin_url( 'plugin-install.php?tab=plugin-information&plugin=hum&TB_iframe=true' ) ); ?>" class="thickbox open-plugin-details-modal button install-now" target="_blank"><?php \esc_html_e( 'Install Hum Plugin for WordPress', 'activitypub' ); ?></a></p>
<p><a href="<?php echo \esc_url_raw( \admin_url( 'plugin-install.php?tab=plugin-information&plugin=hum&TB_iframe=true' ) ); ?>" class="thickbox open-plugin-details-modal button install-now" target="_blank"><?php \esc_html_e( 'Install the Hum Plugin', 'activitypub' ); ?></a></p>
</div>
<?php endif; ?>
<?php if ( ! \class_exists( 'Webfinger' ) ) : ?>
<h4 class="activitypub-settings-accordion-heading">
<button aria-expanded="false" class="activitypub-settings-accordion-trigger" aria-controls="activitypub-settings-accordion-block-activitypub-webfinger-plugin" type="button">
<span class="title"><?php \esc_html_e( 'Advanced WebFinger Support', 'activitypub' ); ?></span>
@ -79,8 +141,10 @@
<div id="activitypub-settings-accordion-block-activitypub-webfinger-plugin" class="activitypub-settings-accordion-panel plugin-card-webfinger" hidden="hidden">
<p><?php \esc_html_e( 'WebFinger is a protocol that allows for discovery of information about people and things identified by a URI. Information about a person might be discovered via an "acct:" URI, for example, which is a URI that looks like an email address.', 'activitypub' ); ?></p>
<p><?php \esc_html_e( 'The ActivityPub plugin comes with basic WebFinger support, if you need more configuration options and compatibility with other Fediverse/IndieWeb plugins, please install the WebFinger plugin.', 'activitypub' ); ?></p>
<p><a href="<?php echo \esc_url_raw( \admin_url( 'plugin-install.php?tab=plugin-information&plugin=webfinger&TB_iframe=true' ) ); ?>" class="thickbox open-plugin-details-modal button install-now" target="_blank"><?php \esc_html_e( 'Install WebFinger Plugin for WordPress', 'activitypub' ); ?></a></p>
<p><a href="<?php echo \esc_url_raw( \admin_url( 'plugin-install.php?tab=plugin-information&plugin=webfinger&TB_iframe=true' ) ); ?>" class="thickbox open-plugin-details-modal button install-now" target="_blank"><?php \esc_html_e( 'Install the WebFinger Plugin', 'activitypub' ); ?></a></p>
</div>
<?php endif; ?>
<?php if ( ! \function_exists( 'nodeinfo_init' ) ) : ?>
<h4 class="activitypub-settings-accordion-heading">
<button aria-expanded="false" class="activitypub-settings-accordion-trigger" aria-controls="activitypub-settings-accordion-block-activitypub-nodeinfo-plugin" type="button">
<span class="title"><?php \esc_html_e( 'Provide Enhanced Information about Your Blog', 'activitypub' ); ?></span>
@ -90,7 +154,9 @@
<div id="activitypub-settings-accordion-block-activitypub-nodeinfo-plugin" class="activitypub-settings-accordion-panel plugin-card-nodeinfo" hidden="hidden">
<p><?php \esc_html_e( 'NodeInfo is an effort to create a standardized way of exposing metadata about a server running one of the distributed social networks. The two key goals are being able to get better insights into the user base of distributed social networking and the ability to build tools that allow users to choose the best fitting software and server for their needs.', 'activitypub' ); ?></p>
<p><?php \esc_html_e( 'The ActivityPub plugin comes with a simple NodeInfo endpoint. If you need more configuration options and compatibility with other Fediverse plugins, please install the NodeInfo plugin.', 'activitypub' ); ?></p>
<p><a href="<?php echo \esc_url_raw( \admin_url( 'plugin-install.php?tab=plugin-information&plugin=nodeinfo&TB_iframe=true' ) ); ?>" class="thickbox open-plugin-details-modal button install-now" target="_blank"><?php \esc_html_e( 'Install NodeInfo Plugin for WordPress', 'activitypub' ); ?></a></p>
<p><a href="<?php echo \esc_url_raw( \admin_url( 'plugin-install.php?tab=plugin-information&plugin=nodeinfo&TB_iframe=true' ) ); ?>" class="thickbox open-plugin-details-modal button install-now" target="_blank"><?php \esc_html_e( 'Install the NodeInfo Plugin', 'activitypub' ); ?></a></p>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>

View file

@ -2,20 +2,27 @@
class Test_Activitypub_Activity_Dispatcher extends ActivityPub_TestCase_Cache_HTTP {
public static $users = array(
'username@example.org' => array(
'id' => 'https://example.org/users/username',
'url' => 'https://example.org/users/username',
'inbox' => 'https://example.org/users/username/inbox',
'name' => 'username',
'prefferedUsername' => 'username',
),
'jon@example.com' => array(
'id' => 'https://example.com/author/jon',
'url' => 'https://example.com/author/jon',
'inbox' => 'https://example.com/author/jon/inbox',
'name' => 'jon',
'prefferedUsername' => 'jon',
),
);
public function test_dispatch_activity() {
$followers = array( 'https://example.com/author/jon', 'https://example.org/users/username' );
\update_user_meta( 1, 'activitypub_followers', $followers );
foreach ( $followers as $follower ) {
\Activitypub\Collection\Followers::add_follower( 1, $follower );
}
$post = \wp_insert_post(
array(
@ -27,21 +34,25 @@ class Test_Activitypub_Activity_Dispatcher extends ActivityPub_TestCase_Cache_HT
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
$activitypub_post = new \Activitypub\Model\Post( $post );
\Activitypub\Activity_Dispatcher::send_post_activity( $activitypub_post );
$this->assertNotEmpty( $activitypub_post->get_content() );
\Activitypub\Activity_Dispatcher::send_activity( get_post( $post ), 'Create' );
$this->assertSame( 2, $pre_http_request->get_call_count() );
$all_args = $pre_http_request->get_args();
$first_call_args = array_shift( $all_args );
$this->assertEquals( 'https://example.com/author/jon/inbox', $first_call_args[2] );
$second_call_args = array_shift( $all_args );
$this->assertEquals( 'https://example.org/users/username/inbox', $second_call_args[2] );
$json = json_decode( $second_call_args[1]['body'] );
$this->assertEquals( 'Create', $json->type );
$this->assertEquals( 'http://example.org/?author=1', $json->actor );
$this->assertEquals( 'http://example.org/?author=1', $json->object->attributedTo );
remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 );
}
public function test_dispatch_mentions() {
$post = \wp_insert_post(
array(
@ -51,6 +62,7 @@ class Test_Activitypub_Activity_Dispatcher extends ActivityPub_TestCase_Cache_HT
);
self::$users['https://example.com/alex'] = array(
'id' => 'https://example.com/alex',
'url' => 'https://example.com/alex',
'inbox' => 'https://example.com/alex/inbox',
'name' => 'alex',
@ -68,10 +80,7 @@ class Test_Activitypub_Activity_Dispatcher extends ActivityPub_TestCase_Cache_HT
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
$activitypub_post = new \Activitypub\Model\Post( $post );
\Activitypub\Activity_Dispatcher::send_post_activity( $activitypub_post );
$this->assertNotEmpty( $activitypub_post->get_content() );
\Activitypub\Activity_Dispatcher::send_activity( get_post( $post ), 'Create' );
$this->assertSame( 1, $pre_http_request->get_call_count() );
$all_args = $pre_http_request->get_args();
@ -85,6 +94,90 @@ class Test_Activitypub_Activity_Dispatcher extends ActivityPub_TestCase_Cache_HT
remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 );
}
public function test_dispatch_announce() {
add_filter( 'activitypub_is_user_type_disabled', '__return_false' );
$followers = array( 'https://example.com/author/jon' );
foreach ( $followers as $follower ) {
\Activitypub\Collection\Followers::add_follower( \Activitypub\Collection\Users::BLOG_USER_ID, $follower );
}
$post = \wp_insert_post(
array(
'post_author' => 1,
'post_content' => 'hello',
)
);
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
\Activitypub\Activity_Dispatcher::send_activity_or_announce( get_post( $post ), 'Create' );
$all_args = $pre_http_request->get_args();
$first_call_args = $all_args[0];
$this->assertSame( 1, $pre_http_request->get_call_count() );
$user = new \Activitypub\Model\Blog_User();
$json = json_decode( $first_call_args[1]['body'] );
$this->assertEquals( 'Announce', $json->type );
$this->assertEquals( $user->get_url(), $json->actor );
remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 );
}
public function test_dispatch_blog_activity() {
$followers = array( 'https://example.com/author/jon' );
add_filter(
'activitypub_is_user_type_disabled',
function( $value, $type ) {
if ( 'blog' === $type ) {
return false;
} else {
return true;
}
},
10,
2
);
$this->assertTrue( \Activitypub\is_single_user() );
foreach ( $followers as $follower ) {
\Activitypub\Collection\Followers::add_follower( \Activitypub\Collection\Users::BLOG_USER_ID, $follower );
}
$post = \wp_insert_post(
array(
'post_author' => 1,
'post_content' => 'hello',
)
);
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
\Activitypub\Activity_Dispatcher::send_activity_or_announce( get_post( $post ), 'Create' );
$all_args = $pre_http_request->get_args();
$first_call_args = $all_args[0];
$this->assertSame( 1, $pre_http_request->get_call_count() );
$user = new \Activitypub\Model\Blog_User();
$json = json_decode( $first_call_args[1]['body'] );
$this->assertEquals( 'Create', $json->type );
$this->assertEquals( $user->get_url(), $json->actor );
$this->assertEquals( $user->get_url(), $json->object->attributedTo );
remove_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10 );
}
public function set_up() {
parent::set_up();
add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ), 10, 2 );

View file

@ -17,15 +17,29 @@ class Test_Activitypub_Activity extends WP_UnitTestCase {
10
);
$activitypub_post = new \Activitypub\Model\Post( $post );
$activitypub_post = \Activitypub\Transformer\Post::transform( get_post( $post ) )->to_object();
$activitypub_activity = new \Activitypub\Model\Activity( 'Create', \Activitypub\Model\Activity::TYPE_FULL );
$activitypub_activity->from_post( $activitypub_post );
$activitypub_activity = new \Activitypub\Activity\Activity();
$activitypub_activity->set_type( 'Create' );
$activitypub_activity->set_object( $activitypub_post );
$this->assertContains( \get_rest_url( null, '/activitypub/1.0/users/1/followers' ), $activitypub_activity->get_cc() );
$this->assertContains( \Activitypub\get_rest_url_by_path( 'users/1/followers' ), $activitypub_activity->get_to() );
$this->assertContains( 'https://example.com/alex', $activitypub_activity->get_cc() );
remove_all_filters( 'activitypub_extract_mentions' );
\wp_trash_post( $post );
}
public function test_object_transformation() {
$test_array = array(
'id' => 'https://example.com/post/123',
'type' => 'Note',
'content' => 'Hello world!',
);
$object = \Activitypub\Activity\Base_Object::init_from_array( $test_array );
$this->assertEquals( 'Hello world!', $object->get_content() );
$this->assertEquals( $test_array, $object->to_array() );
}
}

View file

@ -2,6 +2,7 @@
class Test_Activitypub_Mention extends ActivityPub_TestCase_Cache_HTTP {
public static $users = array(
'username@example.org' => array(
'id' => 'https://example.org/users/username',
'url' => 'https://example.org/users/username',
'name' => 'username',
),
@ -31,6 +32,7 @@ ENDPRE;
array( 'hallo <a rel="mention" class="u-url mention" href="https://notiz.blog/author/matthias-pfefferle/">@<span>pfefferle</span>@notiz.blog</a> test', 'hallo <a rel="mention" class="u-url mention" href="https://notiz.blog/author/matthias-pfefferle/">@<span>pfefferle</span>@notiz.blog</a> test' ),
array( 'hallo <a rel="mention" class="u-url mention" href="https://notiz.blog/author/matthias-pfefferle/">@pfefferle@notiz.blog</a> test', 'hallo <a rel="mention" class="u-url mention" href="https://notiz.blog/author/matthias-pfefferle/">@pfefferle@notiz.blog</a> test' ),
array( 'hallo <a rel="mention" class="u-url mention" href="https://notiz.blog/@pfefferle/">@pfefferle@notiz.blog</a> test', 'hallo <a rel="mention" class="u-url mention" href="https://notiz.blog/@pfefferle/">@pfefferle@notiz.blog</a> test' ),
array( 'hallo <img src="abc" alt="https://notiz.blog/@pfefferle/" title="@pfefferle@notiz.blog"/> test', 'hallo <img src="abc" alt="https://notiz.blog/@pfefferle/" title="@pfefferle@notiz.blog"/> test' ),
array( $code, $code ),
array( $pre, $pre ),
);

View file

@ -10,13 +10,13 @@ class Test_Activitypub_Post extends WP_UnitTestCase {
$permalink = \get_permalink( $post );
$activitypub_post = new \Activitypub\Model\Post( $post );
$activitypub_post = \Activitypub\Transformer\Post::transform( get_post( $post ) )->to_object();
$this->assertEquals( $permalink, $activitypub_post->get_id() );
\wp_trash_post( $post );
$activitypub_post = new \Activitypub\Model\Post( $post );
$activitypub_post = \Activitypub\Transformer\Post::transform( get_post( $post ) )->to_object();
$this->assertEquals( $permalink, $activitypub_post->get_id() );
}

View file

@ -0,0 +1,116 @@
<?php
class Test_Activitypub_Signature_Verification extends WP_UnitTestCase {
public function test_activity_signature() {
// Activity for generate_digest
$post = \wp_insert_post(
array(
'post_author' => 1,
'post_content' => 'hello world',
)
);
$remote_actor = \get_author_posts_url( 2 );
$activitypub_post = \Activitypub\Transformer\Post::transform( get_post( $post ) )->to_object();
$activitypub_activity = new Activitypub\Activity\Activity( 'Create' );
$activitypub_activity->set_type( 'Create' );
$activitypub_activity->set_object( $activitypub_post );
$activitypub_activity->add_cc( $remote_actor );
$activity = $activitypub_activity->to_json();
// generate_digest & generate_signature
$digest = Activitypub\Signature::generate_digest( $activity );
$date = gmdate( 'D, d M Y H:i:s T' );
$signature = Activitypub\Signature::generate_signature( 1, 'POST', $remote_actor, $date, $digest );
$this->assertRegExp( '/keyId="http:\/\/example\.org\/\?author=1#main-key",algorithm="rsa-sha256",headers="\(request-target\) host date digest",signature="[^"]*"/', $signature );
// Signed headers
$url_parts = wp_parse_url( $remote_actor );
$route = $url_parts['path'] . '?' . $url_parts['query'];
$host = $url_parts['host'];
$headers = array(
'digest' => array( $digest ),
'signature' => array( $signature ),
'date' => array( $date ),
'host' => array( $host ),
'(request-target)' => array( 'post ' . $route ),
);
// Start verification
// parse_signature_header, get_signed_data, get_public_key
$signature_block = Activitypub\Signature::parse_signature_header( $headers['signature'] );
$signed_headers = $signature_block['headers'];
$signed_data = Activitypub\Signature::get_signed_data( $signed_headers, $signature_block, $headers );
$user = Activitypub\Collection\Users::get_by_id( 1 );
$public_key = $user->get__public_key();
// signature_verification
$verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, 'rsa-sha256' ) > 0;
$this->assertTrue( $verified );
}
public function test_rest_activity_signature() {
add_filter(
'pre_get_remote_metadata_by_actor',
function( $json, $actor ) {
$user = Activitypub\Collection\Users::get_by_id( 1 );
$public_key = $user->get__public_key();
// return ActivityPub Profile with signature
return array(
'id' => $actor,
'type' => 'Person',
'publicKey' => array(
'id' => $actor . '#main-key',
'owner' => $actor,
'publicKeyPem' => $public_key,
),
);
},
10,
2
);
// Activity Object
$post = \wp_insert_post(
array(
'post_author' => 1,
'post_content' => 'hello world',
)
);
$remote_actor = \get_author_posts_url( 2 );
$remote_actor_inbox = Activitypub\get_rest_url_by_path( '/inbox' );
$activitypub_post = \Activitypub\Transformer\Post::transform( \get_post( $post ) )->to_object();
$activitypub_activity = new Activitypub\Activity\Activity();
$activitypub_activity->set_type( 'Create' );
$activitypub_activity->set_object( $activitypub_post );
$activitypub_activity->add_cc( $remote_actor_inbox );
$activity = $activitypub_activity->to_json();
// generate_digest & generate_signature
$digest = Activitypub\Signature::generate_digest( $activity );
$date = gmdate( 'D, d M Y H:i:s T' );
$signature = Activitypub\Signature::generate_signature( 1, 'POST', $remote_actor_inbox, $date, $digest );
// Signed headers
$url_parts = wp_parse_url( $remote_actor_inbox );
$route = $url_parts['path'] . '?' . $url_parts['query'];
$host = $url_parts['host'];
$request = new WP_REST_Request( 'POST', $route );
$request->set_header( 'content-type', 'application/activity+json' );
$request->set_header( 'digest', $digest );
$request->set_header( 'signature', $signature );
$request->set_header( 'date', $date );
$request->set_header( 'host', $host );
$request->set_body( $activity );
// Start verification
$verified = \Activitypub\Signature::verify_http_signature( $request );
$this->assertTrue( $verified );
remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_key' ), 10, 2 );
}
}

View file

@ -39,13 +39,12 @@ class Test_Activitypub_Shortcodes extends WP_UnitTestCase {
$post->post_date_gmt = current_time( 'mysql', 1 );
$post->post_title = 'Some title or other';
$post->post_content = '<script>test</script>hallo<script type="javascript">{"asdf": "qwerty"}</script><style></style>';
$post->post_status = 'publish';
$post->comment_status = 'closed';
$post->ping_status = 'closed';
$post->post_name = 'fake-page-' . rand( 1, 99999 ); // append random number to avoid clash
$post->post_type = 'page';
$post->post_password = 'abc';
$post->filter = 'raw'; // important!
$post->post_password = 'abc';
$content = '[ap_content]';

View file

@ -1,27 +1,338 @@
<?php
class Test_Db_Activitypub_Followers extends WP_UnitTestCase {
public function test_get_followers() {
$followers = array( 'https://example.com/author/jon', 'https://example.org/author/doe' );
$followers[] = array(
'type' => 'Person',
public static $users = array(
'username@example.org' => array(
'id' => 'https://example.org/users/username',
'url' => 'https://example.org/users/username',
'inbox' => 'https://example.org/users/username/inbox',
'name' => 'username',
'preferredUsername' => 'username',
),
'jon@example.com' => array(
'id' => 'https://example.com/author/jon',
'url' => 'https://example.com/author/jon',
'inbox' => 'https://example.com/author/jon/inbox',
'name' => 'jon',
'preferredUsername' => 'jon',
),
'doe@example.org' => array(
'id' => 'https://example.org/author/doe',
'url' => 'https://example.org/author/doe',
'inbox' => 'https://example.org/author/doe/inbox',
'name' => 'doe',
'preferredUsername' => 'doe',
),
'sally@example.org' => array(
'id' => 'http://sally.example.org',
'name' => 'Sally Smith',
);
\update_user_meta( 1, 'activitypub_followers', $followers );
'url' => 'http://sally.example.org',
'inbox' => 'http://sally.example.org/inbox',
'name' => 'jon',
'preferredUsername' => 'jon',
),
'12345@example.com' => array(
'id' => 'https://12345.example.com',
'url' => 'https://12345.example.com',
'inbox' => 'https://12345.example.com/inbox',
'name' => '12345',
'preferredUsername' => '12345',
),
'user2@example.com' => array(
'id' => 'https://user2.example.com',
'url' => 'https://user2.example.com',
'inbox' => 'https://user2.example.com/inbox',
'name' => 'úser2',
'preferredUsername' => 'user2',
),
);
$db_followers = \Activitypub\Peer\Followers::get_followers( 1 );
public function set_up() {
parent::set_up();
add_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ), 10, 2 );
_delete_all_posts();
}
public function tear_down() {
remove_filter( 'pre_get_remote_metadata_by_actor', array( get_called_class(), 'pre_get_remote_metadata_by_actor' ) );
parent::tear_down();
}
public function test_get_followers() {
$followers = array( 'https://example.com/author/jon', 'https://example.org/author/doe', 'http://sally.example.org' );
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
foreach ( $followers as $follower ) {
$response = \Activitypub\Collection\Followers::add_follower( 1, $follower );
}
$db_followers = \Activitypub\Collection\Followers::get_followers( 1 );
$this->assertEquals( 3, \count( $db_followers ) );
$this->assertSame( array( 'https://example.com/author/jon', 'https://example.org/author/doe', 'http://sally.example.org' ), $db_followers );
$db_followers = array_map(
function( $item ) {
return $item->get_url();
},
$db_followers
);
$this->assertEquals( array( 'http://sally.example.org', 'https://example.org/author/doe', 'https://example.com/author/jon' ), $db_followers );
}
public function test_add_follower() {
$follower = 'https://example.com/author/' . \time();
\Activitypub\Peer\Followers::add_follower( $follower, 1 );
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
$db_followers = \Activitypub\Peer\Followers::get_followers( 1 );
$follower = 'https://12345.example.com';
$follower2 = 'https://user2.example.com';
\Activitypub\Collection\Followers::add_follower( 1, $follower );
\Activitypub\Collection\Followers::add_follower( 2, $follower );
\Activitypub\Collection\Followers::add_follower( 2, $follower2 );
$db_followers = \Activitypub\Collection\Followers::get_followers( 1 );
$db_followers2 = \Activitypub\Collection\Followers::get_followers( 2 );
$this->assertContains( $follower, $db_followers );
$this->assertContains( $follower2, $db_followers2 );
}
public function test_get_follower() {
$followers = array( 'https://example.com/author/jon' );
$followers2 = array( 'https://user2.example.com' );
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
foreach ( $followers as $follower ) {
\Activitypub\Collection\Followers::add_follower( 1, $follower );
}
foreach ( $followers2 as $follower ) {
\Activitypub\Collection\Followers::add_follower( 2, $follower );
}
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'https://example.com/author/jon' );
$this->assertEquals( 'https://example.com/author/jon', $follower->get_url() );
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'http://sally.example.org' );
$this->assertNull( $follower );
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'https://user2.example.com' );
$this->assertNull( $follower );
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'https://example.com/author/jon' );
$this->assertEquals( 'https://example.com/author/jon', $follower->get_url() );
$follower2 = \Activitypub\Collection\Followers::get_follower( 2, 'https://user2.example.com' );
$this->assertEquals( 'https://user2.example.com', $follower2->get_url() );
$this->assertEquals( 'úser2', $follower2->get_name() );
}
public function test_delete_follower() {
$followers = array(
'https://example.com/author/jon',
'https://example.org/author/doe',
);
$followers2 = array( 'https://user2.example.com' );
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
foreach ( $followers as $follower ) {
\Activitypub\Collection\Followers::add_follower( 1, $follower );
\Activitypub\Collection\Followers::add_follower( 1, $follower );
\Activitypub\Collection\Followers::add_follower( 1, $follower );
\Activitypub\Collection\Followers::add_follower( 2, $follower );
}
foreach ( $followers2 as $follower2 ) {
\Activitypub\Collection\Followers::add_follower( 2, $follower2 );
}
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'https://example.com/author/jon' );
$this->assertEquals( 'https://example.com/author/jon', $follower->get_url() );
$followers = \Activitypub\Collection\Followers::get_followers( 1 );
$this->assertEquals( 2, count( $followers ) );
$follower2 = \Activitypub\Collection\Followers::get_follower( 2, 'https://example.com/author/jon' );
$this->assertEquals( 'https://example.com/author/jon', $follower2->get_url() );
\Activitypub\Collection\Followers::remove_follower( 1, 'https://example.com/author/jon' );
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'https://example.com/author/jon' );
$this->assertNull( $follower );
$follower2 = \Activitypub\Collection\Followers::get_follower( 2, 'https://example.com/author/jon' );
$this->assertEquals( 'https://example.com/author/jon', $follower2->get_url() );
$followers = \Activitypub\Collection\Followers::get_followers( 1 );
$this->assertEquals( 1, count( $followers ) );
}
public function test_get_outdated_followers() {
$followers = array( 'https://example.com/author/jon', 'https://example.org/author/doe', 'http://sally.example.org' );
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
foreach ( $followers as $follower ) {
\Activitypub\Collection\Followers::add_follower( 1, $follower );
}
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'https://example.com/author/jon' );
global $wpdb;
//eg. time one year ago..
$time = time() - 804800;
$mysql_time_format = 'Y-m-d H:i:s';
$post_modified = gmdate( $mysql_time_format, $time );
$post_modified_gmt = gmdate( $mysql_time_format, ( $time + get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ) );
$post_id = $follower->get__id();
$wpdb->query(
$wpdb->prepare(
"UPDATE $wpdb->posts SET post_modified = %s, post_modified_gmt = %s WHERE ID = %s",
array(
$post_modified,
$post_modified_gmt,
$post_id,
)
)
);
clean_post_cache( $post_id );
$followers = \Activitypub\Collection\Followers::get_outdated_followers();
$this->assertEquals( 1, count( $followers ) );
$this->assertEquals( 'https://example.com/author/jon', $followers[0] );
}
public function test_get_faulty_followers() {
$followers = array( 'https://example.com/author/jon', 'https://example.org/author/doe', 'http://sally.example.org' );
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
foreach ( $followers as $follower ) {
\Activitypub\Collection\Followers::add_follower( 1, $follower );
}
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'http://sally.example.org' );
for ( $i = 1; $i <= 15; $i++ ) {
add_post_meta( $follower->get__id(), 'activitypub_errors', 'error ' . $i );
}
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'http://sally.example.org' );
$count = $follower->count_errors();
$followers = \Activitypub\Collection\Followers::get_faulty_followers();
$this->assertEquals( 1, count( $followers ) );
$this->assertEquals( 'http://sally.example.org', $followers[0] );
$follower->reset_errors();
$follower = \Activitypub\Collection\Followers::get_follower( 1, 'http://sally.example.org' );
$count = $follower->count_errors();
$followers = \Activitypub\Collection\Followers::get_faulty_followers();
$this->assertEquals( 0, count( $followers ) );
}
public function test_add_duplicate_follower() {
$pre_http_request = new MockAction();
add_filter( 'pre_http_request', array( $pre_http_request, 'filter' ), 10, 3 );
$follower = 'https://12345.example.com';
\Activitypub\Collection\Followers::add_follower( 1, $follower );
\Activitypub\Collection\Followers::add_follower( 1, $follower );
\Activitypub\Collection\Followers::add_follower( 1, $follower );
\Activitypub\Collection\Followers::add_follower( 1, $follower );
\Activitypub\Collection\Followers::add_follower( 1, $follower );
\Activitypub\Collection\Followers::add_follower( 1, $follower );
$db_followers = \Activitypub\Collection\Followers::get_followers( 1 );
$this->assertContains( $follower, $db_followers );
$follower = current( $db_followers );
$meta = get_post_meta( $follower->get__id(), 'activitypub_user_id' );
$this->assertCount( 1, $meta );
}
/**
* @dataProvider extract_name_from_uri_content_provider
*/
public function test_extract_name_from_uri( $uri, $name ) {
$follower = new \Activitypub\Model\Follower();
$follower->set_id( $uri );
$this->assertEquals( $name, $follower->get_name() );
}
public static function http_request_host_is_external( $in, $host ) {
if ( in_array( $host, array( 'example.com', 'example.org' ), true ) ) {
return true;
}
return $in;
}
public static function http_request_args( $args, $url ) {
if ( in_array( wp_parse_url( $url, PHP_URL_HOST ), array( 'example.com', 'example.org' ), true ) ) {
$args['reject_unsafe_urls'] = false;
}
return $args;
}
public static function pre_http_request( $preempt, $request, $url ) {
return array(
'headers' => array(
'content-type' => 'text/json',
),
'body' => '',
'response' => array(
'code' => 202,
),
);
}
public static function http_response( $response, $args, $url ) {
return $response;
}
public static function pre_get_remote_metadata_by_actor( $pre, $actor ) {
if ( isset( self::$users[ $actor ] ) ) {
return self::$users[ $actor ];
}
foreach ( self::$users as $username => $data ) {
if ( $data['url'] === $actor ) {
return $data;
}
}
return $pre;
}
public function extract_name_from_uri_content_provider() {
return array(
array( 'https://example.com/@user', 'user' ),
array( 'https://example.com/@user/', 'user' ),
array( 'https://example.com/users/user', 'user' ),
array( 'https://example.com/users/user/', 'user' ),
array( 'https://example.com/@user?as=asasas', 'user' ),
array( 'https://example.com/@user#asass', 'user' ),
array( '@user@example.com', 'user' ),
array( 'acct:user@example.com', 'user' ),
array( 'user@example.com', 'user' ),
array( 'https://example.com', 'https://example.com' ),
);
}
}