Compare commits

..

65 Commits

Author SHA1 Message Date
e4b9b8235b modified file upgrade-temp-backup 2025-05-05 12:51:00 +00:00
f3c623d403 updated plugin SMTP Mailer version 1.1.18 2025-05-05 12:50:58 +00:00
37e74c1bea deleted plugin Infinite Uploads version 2.0.8 2025-05-02 12:05:07 +00:00
8fefb19ab4 installed plugin Infinite Uploads version 2.0.8 2025-05-02 12:03:22 +00:00
7ca941b591 deleted file discussion-meta.php 2025-04-29 21:32:09 +00:00
cf022e2628 installed plugin Event Bridge for ActivityPub version 1.1.0 2025-04-29 21:32:06 +00:00
fc3d7ab181 modified file themes 2025-04-29 21:21:24 +00:00
81e02d9aea deleted file user-edit.css 2025-04-29 21:21:24 +00:00
6b573f08f6 modified file bootstrap.php 2025-04-29 21:21:24 +00:00
51f6d193dd updated theme Twenty Nineteen version 3.1 2025-04-29 21:21:21 +00:00
5dc2981470 modified file upgrade-temp-backup 2025-04-29 21:20:07 +00:00
0bc27333c2 modified plugin Cloudron SSO version 1.0.0 2025-04-29 21:20:07 +00:00
a212704ec2 updated plugin Two Factor version 0.13.0 2025-04-29 21:20:04 +00:00
c950632407 updated plugin Simple Local Avatars version 2.8.3 2025-04-29 21:20:02 +00:00
fd76ba0cbe updated plugin Menu Icons version 0.13.17 2025-04-29 21:20:00 +00:00
ebd40ef928 updated plugin Jetpack Protect version 4.0.0 2025-04-29 21:19:56 +00:00
eb9181b250 updated plugin GP Premium version 2.5.2 2025-04-29 21:19:14 +00:00
c53f9e0e50 updated plugin Gitium version 1.2.1 2025-04-29 21:19:11 +00:00
d652fac5a4 updated plugin AudioIgniter version 2.0.1 2025-04-29 21:19:09 +00:00
fdfbf76539 updated plugin ActivityPub version 5.8.0 2025-04-29 21:19:06 +00:00
19dfd317cc deleted file style.css 2024-12-16 13:52:01 +00:00
e3858f0710 deleted plugin Companion Auto Update version 3.9.2 2024-12-16 13:52:01 +00:00
9cbc2cb832 modified file themes 2024-10-09 12:47:37 +00:00
db85936315 modified file jetpack-waf 2024-10-09 12:47:37 +00:00
dd95c943cb deleted file wwa-version.php 2024-10-09 12:47:36 +00:00
7dcace54d3 updated theme GeneratePress version 3.5.1 2024-10-09 12:47:31 +00:00
e13bab0b76 modified file plugins 2024-10-09 12:44:46 +00:00
cd379e1d95 modified plugin OpenID Connect Generic version 3.10.0 2024-10-09 12:44:45 +00:00
65c751c1d9 modified plugin Cloudron SSO version 1.0.0 2024-10-09 12:44:44 +00:00
db5f4b72eb deleted file discussion-meta.php 2024-10-09 12:44:44 +00:00
ef209dc569 deleted plugin info.php 2024-10-09 12:44:44 +00:00
e73c3de31d updated plugin WP-WebAuthn version 1.3.4 2024-10-09 12:44:39 +00:00
f970470c59 updated plugin Jetpack Protect version 3.0.2 2024-10-09 12:44:33 +00:00
a35dc419bc updated plugin GP Premium version 2.5.0 2024-10-09 12:44:25 +00:00
627ec103fe updated plugin Gitium version 1.0.7 2024-10-09 12:44:22 +00:00
c54fa007bd updated plugin ActivityPub version 3.3.3 2024-10-09 12:44:18 +00:00
fb4b27bbc6 modified file themes 2024-07-19 19:46:44 +00:00
b964c1846c deleted file simple-local-avatars.php 2024-07-19 19:46:44 +00:00
44c2f9f9a2 updated theme Twenty Nineteen version 2.9 2024-07-19 19:46:40 +00:00
51937c2f57 modified file enshrined 2024-07-19 19:46:18 +00:00
39ec06fbc1 updated plugin Simple Local Avatars version 2.7.11 2024-07-19 19:46:13 +00:00
311bc308f5 updated plugin Menu Icons version 0.13.15 2024-07-19 19:46:11 +00:00
3b4e169a1e updated plugin ActivityPub version 2.6.1 2024-07-19 19:46:07 +00:00
19e351ef3b modified file smtp-mailer 2024-06-27 12:11:17 +00:00
91db4aebe1 updated plugin SMTP Mailer version 1.1.15 2024-06-27 12:11:11 +00:00
03c1118952 updated plugin Simple Local Avatars version 2.7.10 2024-06-27 12:11:07 +00:00
877a737c75 updated plugin Menu Icons version 0.13.14 2024-06-27 12:11:03 +00:00
938cef2946 updated plugin Jetpack Protect version 2.2.0 2024-06-27 12:10:58 +00:00
ec9d8a5834 updated plugin GP Premium version 2.4.1 2024-06-27 12:10:52 +00:00
5c4b728efa updated plugin Companion Auto Update version 3.9.2 2024-06-27 12:10:47 +00:00
65d26d4d83 updated plugin AuthLDAP version 2.6.2 2024-06-27 12:10:43 +00:00
4e493c268e updated plugin ActivityPub version 2.4.0 2024-06-27 12:10:38 +00:00
eeef5ad6e0 modified file plugins 2024-05-09 15:27:04 +00:00
9179edb708 deleted file discussion-meta.php 2024-05-09 15:27:04 +00:00
baa5aa7ed5 updated plugin Two Factor version 0.9.1 2024-05-09 15:27:00 +00:00
62f3186aef updated plugin Simple Local Avatars version 2.7.8 2024-05-09 15:26:56 +00:00
496ccfac3d modified file upgrade-temp-backup 2024-04-19 11:01:27 +00:00
7b83df998e updated theme Twenty Nineteen version 2.8 2024-04-19 11:01:23 +00:00
7b5aaceef5 deleted file object-cache.php 2024-04-19 11:00:09 +00:00
31de6df412 deleted plugin W3 Total Cache version 2.7.1 2024-04-19 11:00:08 +00:00
5de19fe451 modified file upgrade-temp-backup 2024-04-19 10:59:53 +00:00
1a790bdd29 updated plugin W3 Total Cache version 2.7.1 2024-04-19 10:59:46 +00:00
9420356fcf modified file master.php (after deactivation of W3 Total Cache version 2.7.0) 2024-04-19 10:50:39 +00:00
fd49653431 deleted file object-cache.php (after deactivation of W3 Total Cache version 2.7.0) 2024-04-19 10:50:38 +00:00
8e79281642 modified file htaccess (after deactivation of W3 Total Cache version 2.7.0) 2024-04-19 10:50:38 +00:00
7019 changed files with 81346 additions and 791101 deletions

158
htaccess
View File

@ -1,161 +1,3 @@
# BEGIN W3TC Browser Cache
<IfModule mod_mime.c>
AddType text/css .css
AddType text/x-component .htc
AddType application/x-javascript .js
AddType application/javascript .js2
AddType text/javascript .js3
AddType text/x-js .js4
AddType video/asf .asf .asx .wax .wmv .wmx
AddType video/avi .avi
AddType image/avif .avif
AddType image/avif-sequence .avifs
AddType image/bmp .bmp
AddType application/java .class
AddType video/divx .divx
AddType application/msword .doc .docx
AddType application/vnd.ms-fontobject .eot
AddType application/x-msdownload .exe
AddType image/gif .gif
AddType application/x-gzip .gz .gzip
AddType image/x-icon .ico
AddType image/jpeg .jpg .jpeg .jpe
AddType image/webp .webp
AddType application/json .json
AddType application/vnd.ms-access .mdb
AddType audio/midi .mid .midi
AddType video/quicktime .mov .qt
AddType audio/mpeg .mp3 .m4a
AddType video/mp4 .mp4 .m4v
AddType video/mpeg .mpeg .mpg .mpe
AddType video/webm .webm
AddType application/vnd.ms-project .mpp
AddType application/x-font-otf .otf
AddType application/vnd.ms-opentype ._otf
AddType application/vnd.oasis.opendocument.database .odb
AddType application/vnd.oasis.opendocument.chart .odc
AddType application/vnd.oasis.opendocument.formula .odf
AddType application/vnd.oasis.opendocument.graphics .odg
AddType application/vnd.oasis.opendocument.presentation .odp
AddType application/vnd.oasis.opendocument.spreadsheet .ods
AddType application/vnd.oasis.opendocument.text .odt
AddType audio/ogg .ogg
AddType video/ogg .ogv
AddType application/pdf .pdf
AddType image/png .png
AddType application/vnd.ms-powerpoint .pot .pps .ppt .pptx
AddType audio/x-realaudio .ra .ram
AddType image/svg+xml .svg .svgz
AddType application/x-shockwave-flash .swf
AddType application/x-tar .tar
AddType image/tiff .tif .tiff
AddType application/x-font-ttf .ttf .ttc
AddType application/vnd.ms-opentype ._ttf
AddType audio/wav .wav
AddType audio/wma .wma
AddType application/vnd.ms-write .wri
AddType application/font-woff .woff
AddType application/font-woff2 .woff2
AddType application/vnd.ms-excel .xla .xls .xlsx .xlt .xlw
AddType application/zip .zip
</IfModule>
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css A31536000
ExpiresByType text/x-component A31536000
ExpiresByType application/x-javascript A31536000
ExpiresByType application/javascript A31536000
ExpiresByType text/javascript A31536000
ExpiresByType text/x-js A31536000
ExpiresByType video/asf A31536000
ExpiresByType video/avi A31536000
ExpiresByType image/avif A31536000
ExpiresByType image/avif-sequence A31536000
ExpiresByType image/bmp A31536000
ExpiresByType application/java A31536000
ExpiresByType video/divx A31536000
ExpiresByType application/msword A31536000
ExpiresByType application/vnd.ms-fontobject A31536000
ExpiresByType application/x-msdownload A31536000
ExpiresByType image/gif A31536000
ExpiresByType application/x-gzip A31536000
ExpiresByType image/x-icon A31536000
ExpiresByType image/jpeg A31536000
ExpiresByType image/webp A31536000
ExpiresByType application/json A31536000
ExpiresByType application/vnd.ms-access A31536000
ExpiresByType audio/midi A31536000
ExpiresByType video/quicktime A31536000
ExpiresByType audio/mpeg A31536000
ExpiresByType video/mp4 A31536000
ExpiresByType video/mpeg A31536000
ExpiresByType video/webm A31536000
ExpiresByType application/vnd.ms-project A31536000
ExpiresByType application/x-font-otf A31536000
ExpiresByType application/vnd.ms-opentype A31536000
ExpiresByType application/vnd.oasis.opendocument.database A31536000
ExpiresByType application/vnd.oasis.opendocument.chart A31536000
ExpiresByType application/vnd.oasis.opendocument.formula A31536000
ExpiresByType application/vnd.oasis.opendocument.graphics A31536000
ExpiresByType application/vnd.oasis.opendocument.presentation A31536000
ExpiresByType application/vnd.oasis.opendocument.spreadsheet A31536000
ExpiresByType application/vnd.oasis.opendocument.text A31536000
ExpiresByType audio/ogg A31536000
ExpiresByType video/ogg A31536000
ExpiresByType application/pdf A31536000
ExpiresByType image/png A31536000
ExpiresByType application/vnd.ms-powerpoint A31536000
ExpiresByType audio/x-realaudio A31536000
ExpiresByType image/svg+xml A31536000
ExpiresByType application/x-shockwave-flash A31536000
ExpiresByType application/x-tar A31536000
ExpiresByType image/tiff A31536000
ExpiresByType application/x-font-ttf A31536000
ExpiresByType application/vnd.ms-opentype A31536000
ExpiresByType audio/wav A31536000
ExpiresByType audio/wma A31536000
ExpiresByType application/vnd.ms-write A31536000
ExpiresByType application/font-woff A31536000
ExpiresByType application/font-woff2 A31536000
ExpiresByType application/vnd.ms-excel A31536000
ExpiresByType application/zip A31536000
</IfModule>
<IfModule mod_deflate.c>
<IfModule mod_filter.c>
AddOutputFilterByType DEFLATE text/css text/x-component application/x-javascript application/javascript text/javascript text/x-js text/html text/richtext text/plain text/xsd text/xsl text/xml image/bmp application/java application/msword application/vnd.ms-fontobject application/x-msdownload image/x-icon application/json application/vnd.ms-access video/webm application/vnd.ms-project application/x-font-otf application/vnd.ms-opentype application/vnd.oasis.opendocument.database application/vnd.oasis.opendocument.chart application/vnd.oasis.opendocument.formula application/vnd.oasis.opendocument.graphics application/vnd.oasis.opendocument.presentation application/vnd.oasis.opendocument.spreadsheet application/vnd.oasis.opendocument.text audio/ogg application/pdf application/vnd.ms-powerpoint image/svg+xml application/x-shockwave-flash image/tiff application/x-font-ttf application/vnd.ms-opentype audio/wav application/vnd.ms-write application/font-woff application/font-woff2 application/vnd.ms-excel
<IfModule mod_mime.c>
# DEFLATE by extension
AddOutputFilter DEFLATE js css htm html xml
</IfModule>
</IfModule>
</IfModule>
<FilesMatch "\.(css|htc|less|js|js2|js3|js4|CSS|HTC|LESS|JS|JS2|JS3|JS4)$">
FileETag MTime Size
<IfModule mod_headers.c>
Header set Pragma "public"
Header append Cache-Control "public"
Header unset Set-Cookie
</IfModule>
</FilesMatch>
<FilesMatch "\.(html|htm|rtf|rtx|txt|xsd|xsl|xml|HTML|HTM|RTF|RTX|TXT|XSD|XSL|XML)$">
FileETag MTime Size
<IfModule mod_headers.c>
Header set Pragma "public"
Header set Cache-Control "max-age=3600, public"
</IfModule>
</FilesMatch>
<FilesMatch "\.(asf|asx|wax|wmv|wmx|avi|avif|avifs|bmp|class|divx|doc|docx|eot|exe|gif|gz|gzip|ico|jpg|jpeg|jpe|webp|json|mdb|mid|midi|mov|qt|mp3|m4a|mp4|m4v|mpeg|mpg|mpe|webm|mpp|otf|_otf|odb|odc|odf|odg|odp|ods|odt|ogg|ogv|pdf|png|pot|pps|ppt|pptx|ra|ram|svg|svgz|swf|tar|tif|tiff|ttf|ttc|_ttf|wav|wma|wri|woff|woff2|xla|xls|xlsx|xlt|xlw|zip|ASF|ASX|WAX|WMV|WMX|AVI|AVIF|AVIFS|BMP|CLASS|DIVX|DOC|DOCX|EOT|EXE|GIF|GZ|GZIP|ICO|JPG|JPEG|JPE|WEBP|JSON|MDB|MID|MIDI|MOV|QT|MP3|M4A|MP4|M4V|MPEG|MPG|MPE|WEBM|MPP|OTF|_OTF|ODB|ODC|ODF|ODG|ODP|ODS|ODT|OGG|OGV|PDF|PNG|POT|PPS|PPT|PPTX|RA|RAM|SVG|SVGZ|SWF|TAR|TIF|TIFF|TTF|TTC|_TTF|WAV|WMA|WRI|WOFF|WOFF2|XLA|XLS|XLSX|XLT|XLW|ZIP)$">
FileETag MTime Size
<IfModule mod_headers.c>
Header set Pragma "public"
Header append Cache-Control "public"
Header unset Set-Cookie
</IfModule>
</FilesMatch>
<IfModule mod_headers.c>
Header set Referrer-Policy "no-referrer-when-downgrade"
</IfModule>
# END W3TC Browser Cache
# BEGIN WordPress
# The directives (lines) between "BEGIN WordPress" and "END WordPress" are
# dynamically generated, and should only be modified via WordPress filters.

View File

@ -0,0 +1,11 @@
<?php
define( 'DISABLE_JETPACK_WAF', false );
if ( defined( 'DISABLE_JETPACK_WAF' ) && DISABLE_JETPACK_WAF ) return;
define( 'JETPACK_WAF_MODE', 'silent' );
define( 'JETPACK_WAF_SHARE_DATA', false );
define( 'JETPACK_WAF_SHARE_DEBUG_DATA', false );
define( 'JETPACK_WAF_DIR', '/app/data/wp-content/jetpack-waf' );
define( 'JETPACK_WAF_WPCONFIG', '/app/data/wp-content/../wp-config.php' );
define( 'JETPACK_WAF_ENTRYPOINT', 'rules/rules.php' );
require_once '/app/data/wp-content/plugins/jetpack-protect/vendor/autoload.php';
Automattic\Jetpack\Waf\Waf_Runner::initialize();

View File

@ -0,0 +1,4 @@
<?php
$waf_allow_list = array (
);
return $waf->is_ip_in_array( $waf_allow_list );

View File

@ -0,0 +1 @@
<?php

View File

@ -0,0 +1,4 @@
<?php
$waf_block_list = array (
);
return $waf->is_ip_in_array( $waf_block_list );

View File

@ -0,0 +1 @@
<?php

View File

@ -1,42 +0,0 @@
.DS_Store
.editorconfig
.git
.gitignore
.github
.travis.yml
.codeclimate.yml
.data
.svnignore
.wordpress-org
.php_cs
Gruntfile.js
LINGUAS
Makefile
README.md
readme.md
CHANGELOG.md
CODE_OF_CONDUCT.md
FEDERATION.md
SECURITY.md
LICENSE.md
_site
_config.yml
bin
composer.json
composer.lock
docker-compose.yml
docker-compose-test.yml
Dockerfile
gulpfile.js
package.json
node_modules
npm-debug.log
phpcs.xml
package.json
package-lock.json
phpunit.xml
phpunit.xml.dist
tests
node_modules
vendor
src

View File

@ -1,68 +1,64 @@
<?php
/**
* Plugin Name: ActivityPub
* Plugin URI: https://github.com/pfefferle/wordpress-activitypub/
* Plugin URI: https://github.com/Automattic/wordpress-activitypub
* Description: The ActivityPub protocol is a decentralized social networking protocol based upon the ActivityStreams 2.0 data format.
* Version: 2.3.0
* Version: 5.8.0
* Author: Matthias Pfefferle & Automattic
* Author URI: https://automattic.com/
* License: MIT
* License URI: http://opensource.org/licenses/MIT
* Requires PHP: 5.6
* Requires PHP: 7.2
* Text Domain: activitypub
* Domain Path: /languages
*
* @package Activitypub
*/
namespace Activitypub;
use function Activitypub\is_blog_public;
use function Activitypub\site_supports_blocks;
use WP_CLI;
require_once __DIR__ . '/includes/compat.php';
require_once __DIR__ . '/includes/functions.php';
\define( 'ACTIVITYPUB_PLUGIN_VERSION', '2.3.0' );
/**
* Initialize the plugin constants.
*/
\defined( 'ACTIVITYPUB_REST_NAMESPACE' ) || \define( 'ACTIVITYPUB_REST_NAMESPACE', 'activitypub/1.0' );
\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_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "<h2>[ap_title]</h2>\n\n[ap_content]\n\n[ap_hashtags]\n\n[ap_shortlink]" );
\defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) || \define( 'ACTIVITYPUB_AUTHORIZED_FETCH', false );
\defined( 'ACTIVITYPUB_DISABLE_REWRITES' ) || \define( 'ACTIVITYPUB_DISABLE_REWRITES', false );
\defined( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_SHARED_INBOX_FEATURE' ) || \define( 'ACTIVITYPUB_SHARED_INBOX_FEATURE', false );
\defined( 'ACTIVITYPUB_SEND_VARY_HEADER' ) || \define( 'ACTIVITYPUB_SEND_VARY_HEADER', false );
\defined( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE' ) || \define( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE', 'note' );
\define( 'ACTIVITYPUB_PLUGIN_VERSION', '5.8.0' );
// Plugin related constants.
\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_FILE', ACTIVITYPUB_PLUGIN_DIR . basename( __FILE__ ) );
\define( 'ACTIVITYPUB_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
require_once __DIR__ . '/includes/class-autoloader.php';
require_once __DIR__ . '/includes/compat.php';
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/constants.php';
require_once __DIR__ . '/integration/load.php';
Autoloader::register_path( __NAMESPACE__, __DIR__ . '/includes' );
/**
* Initialize REST routes.
*/
function rest_init() {
Rest\Users::init();
Rest\Outbox::init();
Rest\Inbox::init();
Rest\Followers::init();
Rest\Following::init();
Rest\Webfinger::init();
Rest\Comment::init();
Rest\Server::init();
Rest\Collection::init();
Rest\Post::init();
( new Rest\Actors_Controller() )->register_routes();
( new Rest\Actors_Inbox_Controller() )->register_routes();
( new Rest\Application_Controller() )->register_routes();
( new Rest\Collections_Controller() )->register_routes();
( new Rest\Comments_Controller() )->register_routes();
( new Rest\Followers_Controller() )->register_routes();
( new Rest\Following_Controller() )->register_routes();
( new Rest\Inbox_Controller() )->register_routes();
( new Rest\Interaction_Controller() )->register_routes();
( new Rest\Moderators_Controller() )->register_routes();
( new Rest\Outbox_Controller() )->register_routes();
( new Rest\Replies_Controller() )->register_routes();
( new Rest\URL_Validator_Controller() )->register_routes();
( new Rest\Webfinger_Controller() )->register_routes();
// load NodeInfo endpoints only if blog is public
// Load NodeInfo endpoints only if blog is public.
if ( is_blog_public() ) {
Rest\NodeInfo::init();
( new Rest\Nodeinfo_Controller() )->register_routes();
}
}
\add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' );
@ -71,16 +67,18 @@ function rest_init() {
* Initialize plugin.
*/
function plugin_init() {
\add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Activity_Dispatcher', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Handler', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Admin', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Health_Check', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Comment', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Dispatcher', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Handler', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Hashtag', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Link', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Mailer', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ), 1 );
\add_action( 'init', array( __NAMESPACE__ . '\Move', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Options', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) );
if ( site_supports_blocks() ) {
\add_action( 'init', array( __NAMESPACE__ . '\Blocks', 'init' ) );
@ -91,69 +89,30 @@ function plugin_init() {
require_once $debug_file;
Debug::init();
}
require_once __DIR__ . '/integration/class-webfinger.php';
Integration\Webfinger::init();
require_once __DIR__ . '/integration/class-nodeinfo.php';
Integration\Nodeinfo::init();
require_once __DIR__ . '/integration/class-enable-mastodon-apps.php';
Integration\Enable_Mastodon_Apps::init();
}
\add_action( 'plugins_loaded', __NAMESPACE__ . '\plugin_init' );
/**
* Class Autoloader
* Initialize plugin admin.
*/
\spl_autoload_register(
function ( $full_class ) {
$base_dir = __DIR__ . '/includes/';
$base = 'Activitypub\\';
function plugin_admin_init() {
// Menus are registered before `admin_init`, because of course they are.
\add_action( 'admin_menu', array( __NAMESPACE__ . '\WP_Admin\Menu', 'admin_menu' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Admin', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Health_Check', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings_Fields', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Welcome_Fields', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Advanced_Settings_Fields', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Blog_Settings_Fields', 'init' ) );
\add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\User_Settings_Fields', 'init' ) );
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 = strtr( 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 ) ) );
}
}
if ( defined( 'WP_LOAD_IMPORTERS' ) && WP_LOAD_IMPORTERS ) {
require_once __DIR__ . '/includes/wp-admin/import/load.php';
\add_action( 'admin_init', __NAMESPACE__ . '\WP_Admin\Import\load' );
}
);
/**
* 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 ),
\__( 'Settings', 'activitypub' )
);
return \array_merge( $settings_link, $actions );
}
\add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), __NAMESPACE__ . '\plugin_settings_link' );
\add_action( 'plugins_loaded', __NAMESPACE__ . '\plugin_admin_init' );
\register_activation_hook(
__FILE__,
@ -163,6 +122,19 @@ function plugin_settings_link( $actions ) {
)
);
/**
* Redirect to the welcome page after plugin activation.
*
* @param string $plugin The plugin basename.
*/
function activation_redirect( $plugin ) {
if ( ACTIVITYPUB_PLUGIN_BASENAME === $plugin ) {
\wp_safe_redirect( \admin_url( 'options-general.php?page=activitypub' ) );
exit;
}
}
\add_action( 'activated_plugin', __NAMESPACE__ . '\activation_redirect' );
\register_deactivation_hook(
__FILE__,
array(
@ -179,24 +151,18 @@ function plugin_settings_link( $actions ) {
)
);
/**
* Only load code that needs BuddyPress to run once BP is loaded and initialized.
*/
add_action(
'bp_include',
function () {
require_once __DIR__ . '/integration/class-buddypress.php';
Integration\Buddypress::init();
},
0
);
/**
* `get_plugin_data` wrapper
* `get_plugin_data` wrapper.
*
* @return array The plugin metadata array
* @deprecated 4.2.0 Use `get_plugin_data` instead.
*
* @param array $default_headers Optional. The default plugin headers. Default empty array.
* @return array The plugin metadata array.
*/
function get_plugin_meta( $default_headers = array() ) {
_deprecated_function( __FUNCTION__, '4.2.0', 'get_plugin_data' );
if ( ! $default_headers ) {
$default_headers = array(
'Name' => 'Plugin Name',
@ -219,13 +185,22 @@ function get_plugin_meta( $default_headers = array() ) {
/**
* Plugin Version Number used for caching.
*
* @deprecated 4.2.0 Use constant ACTIVITYPUB_PLUGIN_VERSION directly.
*/
function get_plugin_version() {
if ( \defined( 'ACTIVITYPUB_PLUGIN_VERSION' ) ) {
return ACTIVITYPUB_PLUGIN_VERSION;
}
_deprecated_function( __FUNCTION__, '4.2.0', 'ACTIVITYPUB_PLUGIN_VERSION' );
$meta = get_plugin_meta( array( 'Version' => 'Version' ) );
return $meta['Version'];
return ACTIVITYPUB_PLUGIN_VERSION;
}
// Check for CLI env, to add the CLI commands.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command(
'activitypub',
'\Activitypub\Cli',
array(
'shortdesc' => 'ActivityPub related commands to manage plugin functionality and the federation of posts and comments.',
)
);
}

View File

@ -1,12 +1,16 @@
.activitypub-settings {
max-width: 800px;
margin: 0 auto;
position: relative;
}
.settings_page_activitypub .notice {
max-width: 800px;
margin: auto;
margin: 0px auto 30px;
margin: 0 auto 30px;
}
.settings_page_activitypub .update-nag {
margin: 25px 20px 15px 22px;
}
.settings_page_activitypub .wrap {
@ -20,6 +24,15 @@
border-bottom: 1px solid #dcdcde;
}
.activitypub-settings-header h1 {
display: inline-block;
font-weight: 600;
margin: 0 0.8rem 1rem;
font-size: 23px;
padding: 9px 0 4px;
line-height: 1.3;
}
.activitypub-settings-title-section {
display: flex;
align-items: center;
@ -33,11 +46,10 @@
}
.activitypub-settings-tabs-wrapper {
display: -ms-inline-grid;
-ms-grid-columns: auto auto auto;
display: inline-flex;
vertical-align: top;
display: inline-grid;
grid-template-columns: auto auto auto;
flex-wrap: nowrap;
gap: 0;
}
.activitypub-settings-tab.active {
@ -54,6 +66,20 @@
transition: box-shadow .5s ease-in-out;
}
.activitypub-settings .row {
margin-bottom: 16px;
}
.activitypub-settings .row > div {
max-width: calc(100% - 24px);
display: inline-flex;
flex-direction: column;
}
.activitypub-settings .row .description {
margin-top: 0;
}
.wp-header-end {
visibility: hidden;
margin: -2px 0 0;
@ -168,8 +194,7 @@ input.blog-user-identifier {
background-size: cover;
}
.activitypub-settings
.logo {
.activitypub-settings .logo {
height: 80px;
width: 80px;
position: relative;
@ -177,23 +202,71 @@ input.blog-user-identifier {
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;
}
#dashboard_right_now li a.activitypub-followers::before {
content: "\f307";
font-family: dashicons;
}
.repost .dashboard-comment-wrap,
.like .dashboard-comment-wrap {
padding-inline-start: 63px;
}
.repost .dashboard-comment-wrap .comment-author,
.like .dashboard-comment-wrap .comment-author {
margin-block: 0;
}
.activitypub-settings .welcome-tab-close {
position: absolute;
top: 0px;
right: 0px;
font-size: 13px;
padding: 0 5px 0 20px;
text-decoration: none;
z-index: 1;
}
.activitypub-settings .welcome-tab-close::before {
position: absolute;
top: 0px;
left: 0;
transition: all .1s ease-in-out;
font: normal 16px/20px dashicons;
content: '\f335';
font-size: 20px;
}
.activitypub-notice .count {
display: inline-block;
vertical-align: top;
box-sizing: border-box;
margin: 1px 0 -1px 2px;
padding: 0 5px;
min-width: 18px;
height: 18px;
border-radius: 9px;
background-color: #dba617;
color: #fff;
font-size: 11px;
line-height: 1.6;
text-align: center;
z-index: 26;
}
.activitypub-notice .dashicons-warning {
color: #dba617;
}
.extra-fields-nav a + a {
margin-left: 8px;
}
.rtl .extra-fields-nav a + a {
margin-left: auto;
margin-right: 8px;
}

View File

@ -0,0 +1,115 @@
/**
* ActivityPub embed styles.
*/
.activitypub-embed {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 0;
max-width: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.activitypub-reply-block .activitypub-embed {
margin: 1em 0;
}
.activitypub-embed-header {
padding: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.activitypub-embed-header img {
width: 48px;
height: 48px;
border-radius: 50%;
}
.activitypub-embed-header-text {
flex-grow: 1;
}
.activitypub-embed-header-text h2 {
color: #000;
font-size: 15px;
font-weight: 600;
margin: 0;
padding: 0;
}
.activitypub-embed-header-text .ap-account {
color: #687684;
font-size: 14px;
text-decoration: none;
}
.activitypub-embed-content {
padding: 0 15px 15px;
}
.activitypub-embed-content .ap-title {
font-size: 23px;
font-weight: 600;
margin: 0 0 10px;
padding: 0;
color: #000;
}
.activitypub-embed-content .ap-subtitle {
font-size: 15px;
color: #000;
margin: 0 0 15px;
}
.activitypub-embed-content .ap-preview {
border: 1px solid #e6e6e6;
border-radius: 8px;
overflow: hidden;
}
.activitypub-embed-content .ap-preview img {
width: 100%;
height: auto;
display: block;
}
.activitypub-embed-content .ap-preview-text {
padding: 15px;
}
.activitypub-embed-meta {
padding: 15px;
border-top: 1px solid #e6e6e6;
color: #687684;
font-size: 13px;
display: flex;
gap: 15px;
}
.activitypub-embed-meta .ap-stat {
display: flex;
align-items: center;
gap: 5px;
}
@media only screen and (max-width: 399px) {
.activitypub-embed-meta span.ap-stat {
display: none !important;
}
}
.activitypub-embed-meta a.ap-stat {
color: inherit;
text-decoration: none;
}
.activitypub-embed-meta strong {
font-weight: 600;
color: #000;
}
.activitypub-embed-meta .ap-stat-label {
color: #687684;
}

View File

@ -0,0 +1,271 @@
/**
* Handle the header image setting in
*
* This is based on site-icon.js
*
* @see wp-admin/js/site-icon.js
*/
/* global jQuery, wp */
( function ( $ ) {
var $chooseButton = $( '#activitypub-choose-from-library-button' ),
$headerImagePreviewWrapper = $( '#activitypub-header-image-preview-wrapper' ),
$headerImagePreview = $( '#activitypub-header-image-preview' ),
$hiddenDataField = $( '#activitypub_header_image' ),
$removeButton = $( '#activitypub-remove-header-image' ),
frame,
ImageCropperNoCustomizer;
/**
* We register our own handler because the Core one invokes the Customizer, which fails the request unnecessarily
* for users who don't have the 'customize' capability.
* See https://github.com/Automattic/wordpress-activitypub/issues/846
*/
ImageCropperNoCustomizer = wp.media.controller.CustomizeImageCropper.extend( {
doCrop: function( attachment ) {
var cropDetails = attachment.get( 'cropDetails' ),
control = this.get( 'control' ),
ratio = cropDetails.width / cropDetails.height;
// Use crop measurements when flexible in both directions.
if ( control.params.flex_width && control.params.flex_height ) {
cropDetails.dst_width = cropDetails.width;
cropDetails.dst_height = cropDetails.height;
// Constrain flexible side based on image ratio and size of the fixed side.
} else {
cropDetails.dst_width = control.params.flex_width ? control.params.height * ratio : control.params.width;
cropDetails.dst_height = control.params.flex_height ? control.params.width / ratio : control.params.height;
}
return wp.ajax.post( 'crop-image', {
// where wp_customize: 'on' would be in Core, for no good reason I understand.
nonce: attachment.get( 'nonces' ).edit,
id: attachment.get( 'id' ),
context: control.id,
cropDetails: cropDetails
} );
}
} );
/**
* Calculate image selection options based on the attachment dimensions.
*
* @since 6.5.0
*
* @param {Object} attachment The attachment object representing the image.
* @return {Object} The image selection options.
*/
function calculateImageSelectOptions( attachment ) {
var realWidth = attachment.get( 'width' ),
realHeight = attachment.get( 'height' ),
xInit = 1500,
yInit = 500,
ratio = xInit / yInit,
xImg = xInit,
yImg = yInit,
x1,
y1,
imgSelectOptions;
if ( realWidth / realHeight > ratio ) {
yInit = realHeight;
xInit = yInit * ratio;
} else {
xInit = realWidth;
yInit = xInit / ratio;
}
x1 = ( realWidth - xInit ) / 2;
y1 = ( realHeight - yInit ) / 2;
imgSelectOptions = {
aspectRatio: xInit + ':' + yInit,
handles: true,
keys: true,
instance: true,
persistent: true,
imageWidth: realWidth,
imageHeight: realHeight,
minWidth: xImg > xInit ? xInit : xImg,
minHeight: yImg > yInit ? yInit : yImg,
x1: x1,
y1: y1,
x2: xInit + x1,
y2: yInit + y1,
};
return imgSelectOptions;
}
/**
* Initializes the media frame for selecting or cropping an image.
*
* @since 6.5.0
*/
$chooseButton.on( 'click', function () {
var $el = $( this );
var userId = $el.data( 'userId' );
var mediaQuery = { type: 'image' };
if ( userId ) {
mediaQuery.author = userId;
}
// Create the media frame.
frame = wp.media( {
button: {
// Set the text of the button.
text: $el.data( 'update' ),
// Don't close, we might need to crop.
close: false,
},
states: [
new wp.media.controller.Library( {
title: $el.data( 'choose-text' ),
library: wp.media.query( mediaQuery ),
date: false,
suggestedWidth: $el.data( 'width' ),
suggestedHeight: $el.data( 'height' ),
} ),
new ImageCropperNoCustomizer( {
control: {
id: 'activitypub-header-image',
params: {
width: $el.data( 'width' ),
height: $el.data( 'height' ),
},
},
imgSelectOptions: calculateImageSelectOptions,
} ),
],
} );
frame.on( 'cropped', function ( attachment ) {
$hiddenDataField.val( attachment.id );
switchToUpdate( attachment );
frame.close();
// Start over with a frame that is so fresh and so clean clean.
frame = null;
} );
// When an image is selected, run a callback.
frame.on( 'select', function () {
// Grab the selected attachment.
var attachment = frame.state().get( 'selection' ).first(),
targetRatio = $el.data( 'width' ) / $el.data( 'height' ),
currentRatio = attachment.attributes.width / attachment.attributes.height,
alreadyCropped = false;
// Check if the image already has the correct aspect ratio (with a small tolerance).
if ( Math.abs( currentRatio - targetRatio ) < 0.01 ) {
// Check if this is the same image that was already selected.
if ( attachment.id !== parseInt( $hiddenDataField.val(), 10 ) ) {
// This is a new image with the correct aspect ratio.
$hiddenDataField.val( attachment.id );
}
alreadyCropped = true;
}
if ( alreadyCropped ) {
// Skip cropping for already cropped images.
switchToUpdate( attachment.attributes );
frame.close();
} else {
frame.setState( 'cropper' );
}
} );
frame.open();
} );
/**
* Update the UI when a header is selected.
*
* @since 6.5.0
*
* @param {array} attributes The attributes for the attachment.
*/
function switchToUpdate( attributes ) {
var i18nAppAlternativeString, i18nBrowserAlternativeString;
if ( attributes.alt ) {
i18nBrowserAlternativeString = wp.i18n.sprintf(
/* translators: %s: The selected image alt text. */
wp.i18n.__( 'Header Image preview: Current image: %s' ),
attributes.alt
);
} else {
i18nAppAlternativeString = wp.i18n.sprintf(
/* translators: %s: The selected image filename. */
wp.i18n.__(
'Header Image preview: The current image has no alternative text. The file name is: %s'
),
attributes.filename
);
i18nBrowserAlternativeString = wp.i18n.sprintf(
/* translators: %s: The selected image filename. */
wp.i18n.__(
'Header Image preview: The current image has no alternative text. The file name is: %s'
),
attributes.filename
);
}
// Set activitypub-header-image-preview src.
$headerImagePreview.attr( {
src: attributes.url,
alt: i18nAppAlternativeString,
} );
// Remove hidden class from header image preview div and remove button.
$headerImagePreviewWrapper.removeClass( 'hidden' );
$removeButton.removeClass( 'hidden' );
// If the choose button is not in the update state, swap the classes.
if ( $chooseButton.attr( 'data-state' ) !== '1' ) {
$chooseButton.attr( {
class: $chooseButton.attr( 'data-alt-classes' ),
'data-alt-classes': $chooseButton.attr( 'class' ),
'data-state': '1',
} );
}
// Swap the text of the choose button.
$chooseButton.text( $chooseButton.attr( 'data-update-text' ) );
}
/**
* Handles the click event of the remove button.
*
* @since 6.5.0
*/
$removeButton.on( 'click', function () {
$hiddenDataField.val( 'false' );
$( this ).toggleClass( 'hidden' );
$headerImagePreviewWrapper.toggleClass( 'hidden' );
$headerImagePreview.attr( {
src: '',
alt: '',
} );
/**
* Resets state to the button, for correct visual style and state.
* Updates the text of the button.
* Sets focus state to the button.
*/
$chooseButton
.attr( {
class: $chooseButton.attr( 'data-alt-classes' ),
'data-alt-classes': $chooseButton.attr( 'class' ),
'data-state': '',
} )
.text( $chooseButton.attr( 'data-choose-text' ) )
.trigger( 'focus' );
} );
} )( jQuery );

View File

@ -0,0 +1,8 @@
{
"name": "editor-plugin",
"title": "Editor Plugin: not a block, but block.json is very useful.",
"category": "widgets",
"icon": "admin-comments",
"keywords": [],
"editorScript": "file:./plugin.js"
}

View File

@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-primitives', 'wp-url'), 'version' => '293b8e75ac7a589c5096');

File diff suppressed because one or more lines are too long

View File

@ -36,8 +36,29 @@
"selectedUser": {
"type": "string",
"default": "site"
},
"buttonOnly": {
"type": "boolean",
"default": false
},
"buttonText": {
"type": "string",
"default": "Follow"
},
"buttonSize": {
"type": "string",
"default": "default",
"enum": [
"small",
"default",
"compact"
]
}
},
"usesContext": [
"postType",
"postId"
],
"editorScript": "file:./index.js",
"viewScript": "file:./view.js",
"style": [

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '0708145714d72862bff0');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '8f1a6f7e5f76d58a3204');

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--color-white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-left:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:0 50px 50px 0;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:50px 0 0 50px;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-follow-me-block-wrapper{width:100%}.activitypub-follow-me-block-wrapper.has-background .activitypub-profile,.activitypub-follow-me-block-wrapper.has-border-color .activitypub-profile{padding-right:1rem;padding-left:1rem}.activitypub-follow-me-block-wrapper .activitypub-profile{align-items:center;display:flex;padding:1rem 0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__avatar{border-radius:50%;height:75px;margin-left:1rem;width:75px}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__content{flex:1;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__handle,.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{line-height:1.2;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{font-size:1.25em}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow{align-self:center;background-color:var(--wp--preset--color--black);color:var(--wp--preset--color--white)}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow:not(:only-child){margin-right:1rem}

View File

@ -1 +1 @@
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--wp--preset--color--white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-right:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border:1px solid var(--wp--preset--color--black);border-radius:inherit 0;color:var(--wp--preset--color--black);flex:1;padding:6px 12px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);color:var(--wp--preset--color--white);margin-left:0;text-decoration:none}.activitypub-follow-me-block-wrapper{width:100%}.activitypub-follow-me-block-wrapper.has-background .activitypub-profile,.activitypub-follow-me-block-wrapper.has-border-color .activitypub-profile{padding-left:1rem;padding-right:1rem}.activitypub-follow-me-block-wrapper .activitypub-profile{align-items:center;display:flex;padding:1rem 0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__avatar{border-radius:50%;height:75px;margin-right:1rem;width:75px}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__content{flex:1;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__handle,.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{line-height:1.2;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{font-size:1.25em}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow{align-self:center;background-color:var(--wp--preset--color--black);color:var(--wp--preset--color--white);margin-left:1rem}
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--color-white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-right:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:50px 0 0 50px;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:0 50px 50px 0;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-follow-me-block-wrapper{width:100%}.activitypub-follow-me-block-wrapper.has-background .activitypub-profile,.activitypub-follow-me-block-wrapper.has-border-color .activitypub-profile{padding-left:1rem;padding-right:1rem}.activitypub-follow-me-block-wrapper .activitypub-profile{align-items:center;display:flex;padding:1rem 0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__avatar{border-radius:50%;height:75px;margin-right:1rem;width:75px}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__content{flex:1;min-width:0}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__handle,.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{line-height:1.2;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__name{font-size:1.25em}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow{align-self:center;background-color:var(--wp--preset--color--black);color:var(--wp--preset--color--white)}.activitypub-follow-me-block-wrapper .activitypub-profile .activitypub-profile__follow:not(:only-child){margin-left:1rem}

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => 'cbc379fca374f5f88e22');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '635ed3e6db3230ae865f');

File diff suppressed because one or more lines are too long

View File

@ -33,6 +33,10 @@
]
}
},
"usesContext": [
"postType",
"postId"
],
"styles": [
{
"name": "default",

View File

@ -1 +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' => '536d43b3eaab93a5c9ef');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => 'e98a40c18060cbb88187');

View File

@ -1,3 +1,4 @@
(()=>{var e={942:(e,t)=>{var a;!function(){"use strict";var n={}.hasOwnProperty;function r(){for(var e="",t=0;t<arguments.length;t++){var a=arguments[t];a&&(e=o(e,l(a)))}return e}function l(e){if("string"==typeof e||"number"==typeof e)return e;if("object"!=typeof e)return"";if(Array.isArray(e))return r.apply(null,e);if(e.toString!==Object.prototype.toString&&!e.toString.toString().includes("[native code]"))return e.toString();var t="";for(var a in e)n.call(e,a)&&e[a]&&(t=o(t,a));return t}function o(e,t){return t?e?e+" "+t:e+t:e}e.exports?(r.default=r,e.exports=r):void 0===(a=function(){return r}.apply(t,[]))||(e.exports=a)}()}},t={};function a(n){var r=t[n];if(void 0!==r)return r.exports;var l=t[n]={exports:{}};return e[n](l,l.exports,a),l.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.React,n=window.wp.primitives,r=(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"})),l=window.wp.components,o=window.wp.element,i=window.wp.blockEditor,c=window.wp.i18n,s=window.wp.apiFetch;var p=a.n(s);const u=window.wp.url;var v=a(942),m=a.n(v);function w({active:e,children:a,page:n,pageClick:r,className:l}){const o=m()("wp-block activitypub-pager",l,{current:e});return(0,t.createElement)("a",{className:o,onClick:t=>{t.preventDefault(),!e&&r(n)}},a)}const b={outlined:"outlined",minimal:"minimal"};function d({compact:e,nextLabel:a,page:n,pageClick:r,perPage:l,prevLabel:o,total:i,variant:c=b.outlined}){const s=((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})(n,Math.ceil(i/l)),p=m()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${c}`,{"is-compact":e});return(0,t.createElement)("nav",{className:p},o&&(0,t.createElement)(w,{key:"prev",page:n-1,pageClick:r,active:1===n,"aria-label":o,className:"wp-block-query-pagination-previous block-editor-block-list__block"},o),!e&&(0,t.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},s.map((e=>(0,t.createElement)(w,{key:e,page:e,pageClick:r,active:e===n,className:"page-numbers"},e)))),a&&(0,t.createElement)(w,{key:"next",page:n+1,pageClick:r,active:n===Math.ceil(i/l),"aria-label":a,className:"wp-block-query-pagination-next block-editor-block-list__block"},a))}const{namespace:f}=window._activityPubOptions;function g({selectedUser:e,per_page:a,order:n,title:r,page:l,setPage:i,className:s="",followLinks:v=!0,followerData:m=!1}){const w="site"===e?0:e,[b,g]=(0,t.useState)([]),[k,h]=(0,t.useState)(0),[E,_]=(0,t.useState)(0),[x,C]=function(){const[e,a]=(0,t.useState)(1);return[e,a]}(),S=l||x,N=i||C,P=(0,o.createInterpolateElement)(/* translators: arrow for previous followers link */ /* translators: arrow for previous followers link */
(0,c.__)("<span>←</span> Less","activitypub"),{span:(0,t.createElement)("span",{class:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),L=(0,o.createInterpolateElement)(/* translators: arrow for next followers link */ /* translators: arrow for next followers link */
(0,c.__)("More <span>→</span>","activitypub"),{span:(0,t.createElement)("span",{class:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})}),O=(e,t)=>{g(e),_(t),h(Math.ceil(t/a))};return(0,t.useEffect)((()=>{if(m&&1===S)return O(m.followers,m.total);const e=function(e,t,a,n){const r=`/${f}/users/${e}/followers`,l={per_page:t,order:a,page:n,context:"full"};return(0,u.addQueryArgs)(r,l)}(w,a,n,S);p()({path:e}).then((e=>O(e.orderedItems,e.totalItems))).catch((()=>{}))}),[w,a,n,S,m]),(0,t.createElement)("div",{className:"activitypub-follower-block "+s},(0,t.createElement)("h3",null,r),(0,t.createElement)("ul",null,b&&b.map((e=>(0,t.createElement)("li",{key:e.url},(0,t.createElement)(y,{...e,followLinks:v}))))),k>1&&(0,t.createElement)(d,{page:S,perPage:a,total:E,pageClick:N,nextLabel:L,prevLabel:P,compact:"is-style-compact"===s}))}function y({name:e,icon:a,url:n,preferredUsername:r,followLinks:o=!0}){const i=`@${r}`,c={};return o||(c.onClick=e=>e.preventDefault()),(0,t.createElement)(l.ExternalLink,{className:"activitypub-link",href:n,title:i,...c},(0,t.createElement)("img",{width:"40",height:"40",src:a.url,class:"avatar activitypub-avatar",alt:e}),(0,t.createElement)("span",{class:"activitypub-actor"},(0,t.createElement)("strong",{className:"activitypub-name"},e),(0,t.createElement)("span",{class:"sep"},"/"),(0,t.createElement)("span",{class:"activitypub-handle"},i)))}const k=window.wp.data,h=window._activityPubOptions?.enabled;(0,e.registerBlockType)("activitypub/followers",{edit:function({attributes:e,setAttributes:a}){const{order:n,per_page:r,selectedUser:s,title:p}=e,u=(0,i.useBlockProps)(),[v,m]=(0,o.useState)(1),w=[{label:(0,c.__)("New to old","activitypub"),value:"desc"},{label:(0,c.__)("Old to new","activitypub"),value:"asc"}],b=function(){const e=h?.users?(0,k.useSelect)((e=>e("core").getUsers({who:"authors"}))):[];return(0,o.useMemo)((()=>{if(!e)return[];const t=h?.site?[{label:(0,c.__)("Whole Site","activitypub"),value:"site"}]:[];return e.reduce(((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e)),t)}),[e])}(),d=e=>t=>{m(1),a({[e]:t})};return(0,o.useEffect)((()=>{b.length&&(b.find((({value:e})=>e===s))||a({selectedUser:b[0].value}))}),[s,b]),(0,t.createElement)("div",{...u},(0,t.createElement)(i.InspectorControls,{key:"setting"},(0,t.createElement)(l.PanelBody,{title:(0,c.__)("Followers Options","activitypub")},(0,t.createElement)(l.TextControl,{label:(0,c.__)("Title","activitypub"),help:(0,c.__)("Title to display above the list of followers. Blank for none.","activitypub"),value:p,onChange:e=>a({title:e})}),b.length>1&&(0,t.createElement)(l.SelectControl,{label:(0,c.__)("Select User","activitypub"),value:s,options:b,onChange:d("selectedUser")}),(0,t.createElement)(l.SelectControl,{label:(0,c.__)("Sort","activitypub"),value:n,options:w,onChange:d("order")}),(0,t.createElement)(l.RangeControl,{label:(0,c.__)("Number of Followers","activitypub"),value:r,onChange:d("per_page"),min:1,max:10}))),(0,t.createElement)(g,{...e,page:v,setPage:m,followLinks:!1}))},save:()=>null,icon:r})})()})();
(()=>{var e={20:(e,t,a)=>{"use strict";var r=a(609),n=Symbol.for("react.element"),l=(Symbol.for("react.fragment"),Object.prototype.hasOwnProperty),o=r.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,i={key:!0,ref:!0,__self:!0,__source:!0};t.jsx=function(e,t,a){var r,c={},s=null,p=null;for(r in void 0!==a&&(s=""+a),void 0!==t.key&&(s=""+t.key),void 0!==t.ref&&(p=t.ref),t)l.call(t,r)&&!i.hasOwnProperty(r)&&(c[r]=t[r]);if(e&&e.defaultProps)for(r in t=e.defaultProps)void 0===c[r]&&(c[r]=t[r]);return{$$typeof:n,type:e,key:s,ref:p,props:c,_owner:o.current}}},848:(e,t,a)=>{"use strict";e.exports=a(20)},609:e=>{"use strict";e.exports=window.React},942:(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];a&&(e=o(e,l(a)))}return e}function l(e){if("string"==typeof e||"number"==typeof e)return e;if("object"!=typeof e)return"";if(Array.isArray(e))return n.apply(null,e);if(e.toString!==Object.prototype.toString&&!e.toString.toString().includes("[native code]"))return e.toString();var t="";for(var a in e)r.call(e,a)&&e[a]&&(t=o(t,a));return t}function o(e,t){return t?e?e+" "+t:e+t:e}e.exports?(n.default=n,e.exports=n):void 0===(a=function(){return n}.apply(t,[]))||(e.exports=a)}()}},t={};function a(r){var n=t[r];if(void 0!==n)return n.exports;var l=t[r]={exports:{}};return e[r](l,l.exports,a),l.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 r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{"use strict";const e=window.wp.blocks,t=window.wp.primitives;var r=a(848);const n=(0,r.jsx)(t.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,r.jsx)(t.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"})});var l=a(609);const o=window.wp.components,i=window.wp.element,c=window.wp.blockEditor,s=window.wp.data,p=window.wp.coreData,u=window.wp.i18n,m=window.wp.apiFetch;var v=a.n(m);const d=window.wp.url;var w=a(942),b=a.n(w);function f({active:e,children:t,page:a,pageClick:r,className:n}){const o=b()("wp-block activitypub-pager",n,{current:e});return(0,l.createElement)("a",{className:o,onClick:t=>{t.preventDefault(),!e&&r(a)}},t)}function y({compact:e,nextLabel:t,page:a,pageClick:r,perPage:n,prevLabel:o,total:i,variant:c="outlined"}){const s=((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})(a,Math.ceil(i/n)),p=b()("alignwide wp-block-query-pagination is-content-justification-space-between is-layout-flex wp-block-query-pagination-is-layout-flex",`is-${c}`,{"is-compact":e});return(0,l.createElement)("nav",{className:p},o&&(0,l.createElement)(f,{key:"prev",page:a-1,pageClick:r,active:1===a,"aria-label":o,className:"wp-block-query-pagination-previous block-editor-block-list__block"},o),!e&&(0,l.createElement)("div",{className:"block-editor-block-list__block wp-block wp-block-query-pagination-numbers"},s.map((e=>(0,l.createElement)(f,{key:e,page:e,pageClick:r,active:e===a,className:"page-numbers"},e)))),t&&(0,l.createElement)(f,{key:"next",page:a+1,pageClick:r,active:a===Math.ceil(i/n),"aria-label":t,className:"wp-block-query-pagination-next block-editor-block-list__block"},t))}function g(){return window._activityPubOptions||{}}function h({selectedUser:e,per_page:t,order:a,title:r,page:n,setPage:o,className:c="",followLinks:s=!0,followerData:p=!1}){const m="site"===e?0:e,[w,b]=(0,l.useState)([]),[f,h]=(0,l.useState)(0),[k,E]=(0,l.useState)(0),[x,S]=function(){const[e,t]=(0,l.useState)(1);return[e,t]}(),N=n||x,C=o||S,O=(0,i.createInterpolateElement)(/* translators: arrow for previous followers link */ /* translators: arrow for previous followers link */
(0,u.__)("<span>←</span> Less","activitypub"),{span:(0,l.createElement)("span",{className:"wp-block-query-pagination-previous-arrow is-arrow-arrow","aria-hidden":"true"})}),P=(0,i.createInterpolateElement)(/* translators: arrow for next followers link */ /* translators: arrow for next followers link */
(0,u.__)("More <span>→</span>","activitypub"),{span:(0,l.createElement)("span",{className:"wp-block-query-pagination-next-arrow is-arrow-arrow","aria-hidden":"true"})}),I=(e,a)=>{b(e),E(a),h(Math.ceil(a/t))};return(0,l.useEffect)((()=>{if(p&&1===N)return I(p.followers,p.total);const e=function(e,t,a,r){const{namespace:n}=g(),l=`/${n}/actors/${e}/followers`,o={per_page:t,order:a,page:r,context:"full"};return(0,d.addQueryArgs)(l,o)}(m,t,a,N);v()({path:e}).then((e=>I(e.orderedItems,e.totalItems))).catch((()=>{}))}),[m,t,a,N,p]),(0,l.createElement)("div",{className:"activitypub-follower-block "+c},(0,l.createElement)("h3",null,r),(0,l.createElement)("ul",null,w&&w.map((e=>(0,l.createElement)("li",{key:e.url},(0,l.createElement)(_,{...e,followLinks:s}))))),f>1&&(0,l.createElement)(y,{page:N,perPage:t,total:k,pageClick:C,nextLabel:P,prevLabel:O,compact:"is-style-compact"===c}))}function _({name:e,icon:t,url:a,preferredUsername:r,followLinks:n=!0}){const i=`@${r}`,c={};return n||(c.onClick=e=>e.preventDefault()),(0,l.createElement)(o.ExternalLink,{className:"activitypub-link",href:a,title:i,...c},(0,l.createElement)("img",{width:"40",height:"40",src:t.url,className:"avatar activitypub-avatar",alt:e}),(0,l.createElement)("span",{className:"activitypub-actor"},(0,l.createElement)("strong",{className:"activitypub-name"},e),(0,l.createElement)("span",{className:"sep"},"/"),(0,l.createElement)("span",{className:"activitypub-handle"},i)))}function k({name:e}){const{enabled:t}=g(),a=t?.site?"":(0,u.__)("It will be empty in other non-author contexts.","activitypub"),r=(0,u.sprintf)(/* translators: %1$s: block name, %2$s: extra information for non-author context */ /* translators: %1$s: block name, %2$s: extra information for non-author context */
(0,u.__)("This <strong>%1$s</strong> block will adapt to the page it is on, displaying the user profile associated with a post author (in a loop) or a user archive. %2$s","activitypub"),e,a).trim();return(0,l.createElement)(o.Card,null,(0,l.createElement)(o.CardBody,null,(0,i.createInterpolateElement)(r,{strong:(0,l.createElement)("strong",null)})))}(0,e.registerBlockType)("activitypub/followers",{edit:function({attributes:e,setAttributes:t,context:{postType:a,postId:r}}){const{order:n,per_page:m,selectedUser:v,title:d}=e,w=(0,c.useBlockProps)(),[b,f]=(0,i.useState)(1),y=[{label:(0,u.__)("New to old","activitypub"),value:"desc"},{label:(0,u.__)("Old to new","activitypub"),value:"asc"}],_=function({withInherit:e=!1}){const{enabled:t}=g(),a=t?.users?(0,s.useSelect)((e=>e("core").getUsers({who:"authors"}))):[];return(0,i.useMemo)((()=>{if(!a)return[];const r=[];return t?.site&&r.push({label:(0,u.__)("Site","activitypub"),value:"site"}),e&&t?.users&&r.push({label:(0,u.__)("Dynamic User","activitypub"),value:"inherit"}),a.reduce(((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e)),r)}),[a])}({withInherit:!0}),E=e=>a=>{f(1),t({[e]:a})},x=(0,s.useSelect)((e=>{const{getEditedEntityRecord:t}=e(p.store),n=t("postType",a,r)?.author;return null!=n?n:null}),[a,r]);return(0,i.useEffect)((()=>{_.length&&(_.find((({value:e})=>e===v))||t({selectedUser:_[0].value}))}),[v,_]),(0,l.createElement)("div",{...w},(0,l.createElement)(c.InspectorControls,{key:"setting"},(0,l.createElement)(o.PanelBody,{title:(0,u.__)("Followers Options","activitypub")},(0,l.createElement)(o.TextControl,{label:(0,u.__)("Title","activitypub"),help:(0,u.__)("Title to display above the list of followers. Blank for none.","activitypub"),value:d,onChange:e=>t({title:e})}),_.length>1&&(0,l.createElement)(o.SelectControl,{label:(0,u.__)("Select User","activitypub"),value:v,options:_,onChange:E("selectedUser")}),(0,l.createElement)(o.SelectControl,{label:(0,u.__)("Sort","activitypub"),value:n,options:y,onChange:E("order")}),(0,l.createElement)(o.RangeControl,{label:(0,u.__)("Number of Followers","activitypub"),value:m,onChange:E("per_page"),min:1,max:10}))),"inherit"===v?x?(0,l.createElement)(h,{...e,page:b,setPage:f,followLinks:!1,selectedUser:x}):(0,l.createElement)(k,{name:(0,u.__)("Followers","activitypub")}):(0,l.createElement)(h,{...e,page:b,setPage:f,followLinks:!1}))},save:()=>null,icon:n})})()})();

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-left: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

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

View File

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

View File

@ -0,0 +1,37 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "activitypub/reactions",
"apiVersion": 2,
"version": "1.0.0",
"title": "Fediverse Reactions",
"category": "widgets",
"icon": "heart",
"description": "Display Fediverse likes and reposts",
"supports": {
"html": false,
"align": true,
"layout": {
"default": {
"type": "constrained",
"orientation": "vertical",
"justifyContent": "center"
}
}
},
"attributes": {
"title": {
"type": "string",
"default": "Fediverse reactions"
}
},
"blockHooks": {
"core/post-content": "after"
},
"textdomain": "activitypub",
"editorScript": "file:./index.js",
"style": [
"file:./style-index.css",
"wp-components"
],
"viewScript": "file:./view.js"
}

View File

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

View File

@ -0,0 +1,3 @@
(()=>{"use strict";var e,t={373:(e,t,a)=>{const n=window.wp.blocks,r=window.React,l=window.wp.blockEditor,o=window.wp.element,s=window.wp.i18n,i=window.wp.components,c=window.wp.apiFetch;var u=a.n(c);function m(){return window._activityPubOptions||{}}const p=({reactions:e})=>{const{defaultAvatarUrl:t}=m(),[a,n]=(0,o.useState)(new Set),[l,s]=(0,o.useState)(new Map),i=(0,o.useRef)([]),c=()=>{i.current.forEach((e=>clearTimeout(e))),i.current=[]},u=(t,a)=>{c();const r=100,l=e.length;a&&s((e=>{const a=new Map(e);return a.set(t,"clockwise"),a}));const o=e=>{const o="right"===e,c=o?l-1:0,u=o?1:-1;for(let e=o?t:t-1;o?e<=c:e>=c;e+=u){const l=Math.abs(e-t),o=setTimeout((()=>{n((t=>{const n=new Set(t);return a?n.add(e):n.delete(e),n})),a&&e!==t&&s((t=>{const a=new Map(t),n=e-u,r=a.get(n);return a.set(e,"clockwise"===r?"counter":"clockwise"),a}))}),l*r);i.current.push(o)}};if(o("right"),o("left"),!a){const e=Math.max((l-t)*r,t*r),a=setTimeout((()=>{s(new Map)}),e+r);i.current.push(a)}};return(0,o.useEffect)((()=>()=>c()),[]),(0,r.createElement)("ul",{className:"reaction-avatars"},e.map(((e,n)=>{const o=l.get(n),s=["reaction-avatar",a.has(n)?"wave-active":"",o?`rotate-${o}`:""].filter(Boolean).join(" "),i=e.avatar||t;return(0,r.createElement)("li",{key:n},(0,r.createElement)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",onMouseEnter:()=>u(n,!0),onMouseLeave:()=>u(n,!1)},(0,r.createElement)("img",{src:i,alt:e.name,className:s,width:"32",height:"32"})))})))},f=({reactions:e,type:t})=>(0,r.createElement)("ul",{className:"activitypub-reaction-list"},e.map(((e,t)=>(0,r.createElement)("li",{key:t},(0,r.createElement)("a",{href:e.url,className:"reaction-item",target:"_blank",rel:"noopener noreferrer"},(0,r.createElement)("img",{src:e.avatar,alt:e.name,width:"32",height:"32"}),(0,r.createElement)("span",null,e.name)))))),h=({items:e,label:t})=>{const[a,n]=(0,o.useState)(!1),[l,s]=(0,o.useState)(null),[c,u]=(0,o.useState)(e.length),m=(0,o.useRef)(null);(0,o.useEffect)((()=>{if(!m.current)return;const t=()=>{const t=m.current;if(!t)return;const a=t.offsetWidth-(l?.offsetWidth||0)-12,n=Math.max(1,Math.floor((a-32)/22));u(Math.min(n,e.length))};t();const a=new ResizeObserver(t);return a.observe(m.current),()=>{a.disconnect()}}),[l,e.length]);const h=e.slice(0,c);return(0,r.createElement)("div",{className:"reaction-group",ref:m},(0,r.createElement)(p,{reactions:h}),(0,r.createElement)(i.Button,{ref:s,className:"reaction-label is-link",onClick:()=>n(!a),"aria-expanded":a},t),a&&l&&(0,r.createElement)(i.Popover,{anchor:l,onClose:()=>n(!1)},(0,r.createElement)(f,{reactions:e})))};function d({title:e="",postId:t=null,reactions:a=null,titleComponent:n=null}){const{namespace:l}=m(),[s,i]=(0,o.useState)(a),[c,p]=(0,o.useState)(!a);return(0,o.useEffect)((()=>{if(a)return i(a),void p(!1);t?(p(!0),u()({path:`/${l}/posts/${t}/reactions`}).then((e=>{i(e),p(!1)})).catch((()=>p(!1)))):p(!1)}),[t,a]),c?null:s&&Object.values(s).some((e=>e.items?.length>0))?(0,r.createElement)("div",{className:"activitypub-reactions"},n||e&&(0,r.createElement)("h6",null,e),Object.entries(s).map((([e,t])=>t.items?.length?(0,r.createElement)(h,{key:e,items:t.items,label:t.label}):null))):null}const v=e=>{const t=["#FF6B6B","#4ECDC4","#45B7D1","#96CEB4","#FFEEAD","#D4A5A5","#9B59B6","#3498DB","#E67E22"],a=(()=>{const e=["Bouncy","Cosmic","Dancing","Fluffy","Giggly","Hoppy","Jazzy","Magical","Nifty","Perky","Quirky","Sparkly","Twirly","Wiggly","Zippy"],t=["Badger","Capybara","Dolphin","Echidna","Flamingo","Giraffe","Hedgehog","Iguana","Jellyfish","Koala","Lemur","Manatee","Narwhal","Octopus","Penguin"];return`${e[Math.floor(Math.random()*e.length)]} ${t[Math.floor(Math.random()*t.length)]}`})(),n=t[Math.floor(Math.random()*t.length)],r=a.charAt(0),l=document.createElement("canvas");l.width=64,l.height=64;const o=l.getContext("2d");return o.fillStyle=n,o.beginPath(),o.arc(32,32,32,0,2*Math.PI),o.fill(),o.fillStyle="#FFFFFF",o.font="32px sans-serif",o.textAlign="center",o.textBaseline="middle",o.fillText(r,32,32),{name:a,url:"#",avatar:l.toDataURL()}},g=JSON.parse('{"UU":"activitypub/reactions"}');(0,n.registerBlockType)(g.UU,{edit:function({attributes:e,setAttributes:t,__unstableLayoutClassNames:a}){const n=(0,l.useBlockProps)({className:a}),[i]=(0,o.useState)({likes:{label:(0,s.sprintf)(/* translators: %d: Number of likes */ /* translators: %d: Number of likes */
(0,s._x)("%d likes","number of likes","activitypub"),9),items:Array.from({length:9},((e,t)=>v()))},reposts:{label:(0,s.sprintf)(/* translators: %d: Number of reposts */ /* translators: %d: Number of reposts */
(0,s._x)("%d reposts","number of reposts","activitypub"),6),items:Array.from({length:6},((e,t)=>v()))}}),c=(0,r.createElement)(l.RichText,{tagName:"h6",value:e.title,onChange:e=>t({title:e}),placeholder:(0,s.__)("Fediverse Reactions","activitypub"),disableLineBreaks:!0,allowedFormats:[]});return(0,r.createElement)("div",{...n},(0,r.createElement)(d,{titleComponent:c,reactions:i}))}})}},a={};function n(e){var r=a[e];if(void 0!==r)return r.exports;var l=a[e]={exports:{}};return t[e](l,l.exports,n),l.exports}n.m=t,e=[],n.O=(t,a,r,l)=>{if(!a){var o=1/0;for(u=0;u<e.length;u++){a=e[u][0],r=e[u][1],l=e[u][2];for(var s=!0,i=0;i<a.length;i++)(!1&l||o>=l)&&Object.keys(n.O).every((e=>n.O[e](a[i])))?a.splice(i--,1):(s=!1,l<o&&(o=l));if(s){e.splice(u--,1);var c=r();void 0!==c&&(t=c)}}return t}l=l||0;for(var u=e.length;u>0&&e[u-1][2]>l;u--)e[u]=e[u-1];e[u]=[a,r,l]},n.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{for(var a in t)n.o(t,a)&&!n.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={608:0,104:0};n.O.j=t=>0===e[t];var t=(t,a)=>{var r,l,o=a[0],s=a[1],i=a[2],c=0;if(o.some((t=>0!==e[t]))){for(r in s)n.o(s,r)&&(n.m[r]=s[r]);if(i)var u=i(n)}for(t&&t(a);c<o.length;c++)l=o[c],n.o(e,l)&&e[l]&&e[l][0](),e[l]=0;return n.O(u)},a=self.webpackChunkwordpress_activitypub=self.webpackChunkwordpress_activitypub||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))})();var r=n.O(void 0,[104],(()=>n(373)));r=n.O(r)})();

View File

@ -0,0 +1 @@
.activitypub-reactions h6{border-top:1px solid;border-top-color:var(--wp--preset--color--contrast-2);display:inline-block;padding-top:.5em}.activitypub-reactions .reaction-group{align-items:center;display:flex;gap:.75em;justify-content:flex-start;margin:.5em 0;position:relative;width:100%}@media(max-width:782px){.activitypub-reactions .reaction-group:has(.reaction-avatars:not(:empty)){justify-content:space-between}}.activitypub-reactions .reaction-avatars{align-items:center;display:flex;flex-direction:row;list-style:none;margin:0;padding:0}.activitypub-reactions .reaction-avatars li{margin:0 0 0 -10px;padding:0}.activitypub-reactions .reaction-avatars li:last-child{margin-left:0}.activitypub-reactions .reaction-avatars li a{display:block;text-decoration:none}.activitypub-reactions .reaction-avatars .reaction-avatar{border:.5px solid var(--wp--preset--color--contrast,hsla(0,0%,100%,.8));border-radius:50%;box-shadow:0 0 0 .5px hsla(0,0%,100%,.8),0 1px 3px rgba(0,0,0,.2);height:32px;transition:transform .6s cubic-bezier(.34,1.56,.64,1);width:32px;will-change:transform}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active{transform:translateY(-5px)}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-clockwise{transform:translateY(-5px) rotate(-30deg)}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-counter{transform:translateY(-5px) rotate(30deg)}.activitypub-reactions .reaction-avatars .reaction-avatar:hover{position:relative;z-index:1}.activitypub-reactions .reaction-label.components-button{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#2271b1);flex:0 0 auto;height:auto;padding:0;text-decoration:none;white-space:nowrap}.activitypub-reactions .reaction-label.components-button:hover{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#135e96);text-decoration:underline}.activitypub-reactions .reaction-label.components-button:focus:not(:disabled){box-shadow:none;outline:1px solid var(--wp--preset--color--contrast,#135e96);outline-offset:2px}.activitypub-reaction-list{background-color:var(--wp--preset--color--background,var(--wp--preset--color--custom-background,var(--wp--preset--color--base)));list-style:none;margin:0;max-width:300px;padding:.25em .7em .25em 1.3em;width:-moz-max-content;width:max-content}.activitypub-reaction-list ul{margin:0;padding:0}.activitypub-reaction-list li{font-size:var(--wp--preset--font-size--small);margin:0;padding:0}.activitypub-reaction-list a{align-items:center;color:var(--wp--preset--color--contrast,var(--wp--preset--color--secondary));display:flex;font-size:var(--wp--preset--font-size--small,.75rem);gap:.5em;justify-content:flex-start;padding:.5em;text-decoration:none}.activitypub-reaction-list a:hover{text-decoration:underline}.activitypub-reaction-list a img{border-radius:50%;flex:none;height:24px;width:24px}

View File

@ -0,0 +1 @@
.activitypub-reactions h6{border-top:1px solid;border-top-color:var(--wp--preset--color--contrast-2);display:inline-block;padding-top:.5em}.activitypub-reactions .reaction-group{align-items:center;display:flex;gap:.75em;justify-content:flex-start;margin:.5em 0;position:relative;width:100%}@media(max-width:782px){.activitypub-reactions .reaction-group:has(.reaction-avatars:not(:empty)){justify-content:space-between}}.activitypub-reactions .reaction-avatars{align-items:center;display:flex;flex-direction:row;list-style:none;margin:0;padding:0}.activitypub-reactions .reaction-avatars li{margin:0 -10px 0 0;padding:0}.activitypub-reactions .reaction-avatars li:last-child{margin-right:0}.activitypub-reactions .reaction-avatars li a{display:block;text-decoration:none}.activitypub-reactions .reaction-avatars .reaction-avatar{border:.5px solid var(--wp--preset--color--contrast,hsla(0,0%,100%,.8));border-radius:50%;box-shadow:0 0 0 .5px hsla(0,0%,100%,.8),0 1px 3px rgba(0,0,0,.2);height:32px;transition:transform .6s cubic-bezier(.34,1.56,.64,1);width:32px;will-change:transform}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active{transform:translateY(-5px)}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-clockwise{transform:translateY(-5px) rotate(30deg)}.activitypub-reactions .reaction-avatars .reaction-avatar.wave-active.rotate-counter{transform:translateY(-5px) rotate(-30deg)}.activitypub-reactions .reaction-avatars .reaction-avatar:hover{position:relative;z-index:1}.activitypub-reactions .reaction-label.components-button{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#2271b1);flex:0 0 auto;height:auto;padding:0;text-decoration:none;white-space:nowrap}.activitypub-reactions .reaction-label.components-button:hover{color:var(--wp--preset--color--contrast,--wp--preset--color--secondary,#135e96);text-decoration:underline}.activitypub-reactions .reaction-label.components-button:focus:not(:disabled){box-shadow:none;outline:1px solid var(--wp--preset--color--contrast,#135e96);outline-offset:2px}.activitypub-reaction-list{background-color:var(--wp--preset--color--background,var(--wp--preset--color--custom-background,var(--wp--preset--color--base)));list-style:none;margin:0;max-width:300px;padding:.25em 1.3em .25em .7em;width:-moz-max-content;width:max-content}.activitypub-reaction-list ul{margin:0;padding:0}.activitypub-reaction-list li{font-size:var(--wp--preset--font-size--small);margin:0;padding:0}.activitypub-reaction-list a{align-items:center;color:var(--wp--preset--color--contrast,var(--wp--preset--color--secondary));display:flex;font-size:var(--wp--preset--font-size--small,.75rem);gap:.5em;justify-content:flex-start;padding:.5em;text-decoration:none}.activitypub-reaction-list a:hover{text-decoration:underline}.activitypub-reaction-list a img{border-radius:50%;flex:none;height:24px;width:24px}

View File

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

View File

@ -0,0 +1 @@
(()=>{"use strict";var e={n:t=>{var n=t&&t.__esModule?()=>t.default:()=>t;return e.d(n,{a:n}),n},d:(t,n)=>{for(var a in n)e.o(n,a)&&!e.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:n[a]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.React,n=window.wp.element,a=window.wp.domReady;var r=e.n(a);const c=window.wp.components,o=window.wp.apiFetch;var l=e.n(o);function s(){return window._activityPubOptions||{}}window.wp.i18n;const i=({reactions:e})=>{const{defaultAvatarUrl:a}=s(),[r,c]=(0,n.useState)(new Set),[o,l]=(0,n.useState)(new Map),i=(0,n.useRef)([]),u=()=>{i.current.forEach((e=>clearTimeout(e))),i.current=[]},m=(t,n)=>{u();const a=100,r=e.length;n&&l((e=>{const n=new Map(e);return n.set(t,"clockwise"),n}));const o=e=>{const o="right"===e,s=o?r-1:0,u=o?1:-1;for(let e=o?t:t-1;o?e<=s:e>=s;e+=u){const r=Math.abs(e-t),o=setTimeout((()=>{c((t=>{const a=new Set(t);return n?a.add(e):a.delete(e),a})),n&&e!==t&&l((t=>{const n=new Map(t),a=e-u,r=n.get(a);return n.set(e,"clockwise"===r?"counter":"clockwise"),n}))}),r*a);i.current.push(o)}};if(o("right"),o("left"),!n){const e=Math.max((r-t)*a,t*a),n=setTimeout((()=>{l(new Map)}),e+a);i.current.push(n)}};return(0,n.useEffect)((()=>()=>u()),[]),(0,t.createElement)("ul",{className:"reaction-avatars"},e.map(((e,n)=>{const c=o.get(n),l=["reaction-avatar",r.has(n)?"wave-active":"",c?`rotate-${c}`:""].filter(Boolean).join(" "),s=e.avatar||a;return(0,t.createElement)("li",{key:n},(0,t.createElement)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",onMouseEnter:()=>m(n,!0),onMouseLeave:()=>m(n,!1)},(0,t.createElement)("img",{src:s,alt:e.name,className:l,width:"32",height:"32"})))})))},u=({reactions:e,type:n})=>(0,t.createElement)("ul",{className:"activitypub-reaction-list"},e.map(((e,n)=>(0,t.createElement)("li",{key:n},(0,t.createElement)("a",{href:e.url,className:"reaction-item",target:"_blank",rel:"noopener noreferrer"},(0,t.createElement)("img",{src:e.avatar,alt:e.name,width:"32",height:"32"}),(0,t.createElement)("span",null,e.name)))))),m=({items:e,label:a})=>{const[r,o]=(0,n.useState)(!1),[l,s]=(0,n.useState)(null),[m,p]=(0,n.useState)(e.length),h=(0,n.useRef)(null);(0,n.useEffect)((()=>{if(!h.current)return;const t=()=>{const t=h.current;if(!t)return;const n=t.offsetWidth-(l?.offsetWidth||0)-12,a=Math.max(1,Math.floor((n-32)/22));p(Math.min(a,e.length))};t();const n=new ResizeObserver(t);return n.observe(h.current),()=>{n.disconnect()}}),[l,e.length]);const f=e.slice(0,m);return(0,t.createElement)("div",{className:"reaction-group",ref:h},(0,t.createElement)(i,{reactions:f}),(0,t.createElement)(c.Button,{ref:s,className:"reaction-label is-link",onClick:()=>o(!r),"aria-expanded":r},a),r&&l&&(0,t.createElement)(c.Popover,{anchor:l,onClose:()=>o(!1)},(0,t.createElement)(u,{reactions:e})))};function p({title:e="",postId:a=null,reactions:r=null,titleComponent:c=null}){const{namespace:o}=s(),[i,u]=(0,n.useState)(r),[p,h]=(0,n.useState)(!r);return(0,n.useEffect)((()=>{if(r)return u(r),void h(!1);a?(h(!0),l()({path:`/${o}/posts/${a}/reactions`}).then((e=>{u(e),h(!1)})).catch((()=>h(!1)))):h(!1)}),[a,r]),p?null:i&&Object.values(i).some((e=>e.items?.length>0))?(0,t.createElement)("div",{className:"activitypub-reactions"},c||e&&(0,t.createElement)("h6",null,e),Object.entries(i).map((([e,n])=>n.items?.length?(0,t.createElement)(m,{key:e,items:n.items,label:n.label}):null))):null}r()((()=>{[].forEach.call(document.querySelectorAll(".activitypub-reactions-block"),(e=>{const a=JSON.parse(e.dataset.attrs);(0,n.createRoot)(e).render((0,t.createElement)(p,{...a}))}))}))})();

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '9aee45886ecf2680fbd4');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-dom-ready', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '7160b6399cd924e1c7be');

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--color-white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-left:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:0 50px 50px 0;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:50px 0 0 50px;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-remote-profile-delete{align-self:center;color:inherit;font-size:inherit;height:inherit;padding:0 5px}.activitypub-remote-profile-delete:hover{background:inherit;border:inherit}.activitypub-remote-reply{display:flex}

View File

@ -1 +1 @@
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--wp--preset--color--white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-right:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border:1px solid var(--wp--preset--color--black);border-radius:inherit 0;color:var(--wp--preset--color--black);flex:1;padding:6px 12px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);color:var(--wp--preset--color--white);margin-left:0;text-decoration:none}
.activitypub__modal.components-modal__frame{background-color:#f7f7f7;color:#333}.activitypub__modal.components-modal__frame .components-modal__header-heading,.activitypub__modal.components-modal__frame h4{color:#333;letter-spacing:inherit;word-spacing:inherit}.activitypub__modal.components-modal__frame .components-modal__header .components-button:hover{color:var(--color-white)}.activitypub__dialog{max-width:40em}.activitypub__dialog h4{line-height:1;margin:0}.activitypub__dialog .activitypub-dialog__section{margin-bottom:2em}.activitypub__dialog .activitypub-dialog__remember{margin-top:1em}.activitypub__dialog .activitypub-dialog__description{font-size:var(--wp--preset--font-size--normal,.75rem);margin:.33em 0 1em}.activitypub__dialog .activitypub-dialog__button-group{align-items:flex-end;display:flex;justify-content:flex-end}.activitypub__dialog .activitypub-dialog__button-group svg{height:21px;margin-right:.5em;width:21px}.activitypub__dialog .activitypub-dialog__button-group input{background-color:var(--wp--preset--color--white);border-radius:50px 0 0 50px;border-width:1px;border:1px solid var(--wp--preset--color--black);color:var(--wp--preset--color--black);flex:1;font-size:16px;height:inherit;line-height:1;margin-right:0;padding:15px 23px}.activitypub__dialog .activitypub-dialog__button-group button{align-self:center;background-color:var(--wp--preset--color--black);border-radius:0 50px 50px 0;border-width:1px;color:var(--wp--preset--color--white);font-size:16px;height:inherit;line-height:1;margin-left:0;padding:15px 23px;text-decoration:none}.activitypub__dialog .activitypub-dialog__button-group button:hover{border:inherit}.activitypub-remote-profile-delete{align-self:center;color:inherit;font-size:inherit;height:inherit;padding:0 5px}.activitypub-remote-profile-delete:hover{background:inherit;border:inherit}.activitypub-remote-reply{display:flex}

View File

@ -0,0 +1,12 @@
{
"name": "reply-handler",
"title": "Reply Handler: not a block, but block.json is very useful.",
"category": "widgets",
"icon": "admin-comments",
"keywords": [
"reply",
"handler",
"comments"
],
"editorScript": "file:./plugin.js"
}

View File

@ -0,0 +1 @@
<?php return array('dependencies' => array('wp-block-editor', 'wp-blocks', 'wp-data', 'wp-element', 'wp-plugins'), 'version' => 'f65a7269b5abb57d3e73');

View File

@ -0,0 +1 @@
(()=>{"use strict";const e=window.wp.plugins,t=window.wp.blocks,i=window.wp.data,n=window.wp.blockEditor,o=window.wp.element;let r=!1;(0,e.registerPlugin)("activitypub-reply-intent",{render:()=>((0,o.useEffect)((()=>{if(r)return;const e=new URLSearchParams(window.location.search).get("in_reply_to");e&&!r&&setTimeout((()=>{const o=(0,t.createBlock)("activitypub/reply",{url:e,embedPost:!0}),r=(0,i.dispatch)(n.store);r.insertBlock(o),r.insertAfterBlock(o.clientId)}),200),r=!0})),null)})})();

View File

@ -0,0 +1,29 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "activitypub/reply",
"version": "0.1.0",
"title": "Federated Reply",
"category": "widgets",
"icon": "commentReplyLink",
"description": "Respond to posts, notes, videos, and other content on the fediverse. Ensure the URL originates from a federated social network like Mastodon, as other URLs might not function as expected.",
"supports": {
"html": false,
"inserter": true,
"reusable": false,
"lock": false
},
"textdomain": "activitypub",
"editorScript": "file:./index.js",
"editorStyle": "file:./style-index.css",
"style": "file:./index.css",
"attributes": {
"url": {
"type": "string"
},
"embedPost": {
"type": "boolean",
"default": null
}
}
}

View File

@ -0,0 +1 @@
.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}.activitypub-embed-content .ap-preview img{display:block;height:auto;width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta .ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}.wp-block-activitypub-reply .components-spinner{height:12px;margin-bottom:0;margin-top:0;width:12px}

View File

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

View File

@ -0,0 +1 @@
.activitypub-embed{background:#fff;border:1px solid #e6e6e6;border-radius:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:100%;padding:0}.activitypub-reply-block .activitypub-embed{margin:1em 0}.activitypub-embed-header{align-items:center;display:flex;gap:10px;padding:15px}.activitypub-embed-header img{border-radius:50%;height:48px;width:48px}.activitypub-embed-header-text{flex-grow:1}.activitypub-embed-header-text h2{color:#000;font-size:15px;font-weight:600;margin:0;padding:0}.activitypub-embed-header-text .ap-account{color:#687684;font-size:14px;text-decoration:none}.activitypub-embed-content{padding:0 15px 15px}.activitypub-embed-content .ap-title{color:#000;font-size:23px;font-weight:600;margin:0 0 10px;padding:0}.activitypub-embed-content .ap-subtitle{color:#000;font-size:15px;margin:0 0 15px}.activitypub-embed-content .ap-preview{border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}.activitypub-embed-content .ap-preview img{display:block;height:auto;width:100%}.activitypub-embed-content .ap-preview-text{padding:15px}.activitypub-embed-meta{border-top:1px solid #e6e6e6;color:#687684;display:flex;font-size:13px;gap:15px;padding:15px}.activitypub-embed-meta .ap-stat{align-items:center;display:flex;gap:5px}@media only screen and (max-width:399px){.activitypub-embed-meta .ap-stat{display:none!important}}.activitypub-embed-meta a.ap-stat{color:inherit;text-decoration:none}.activitypub-embed-meta strong{color:#000;font-weight:600}.activitypub-embed-meta .ap-stat-label{color:#687684}.wp-block-activitypub-reply .components-spinner{height:12px;margin-bottom:0;margin-top:0;width:12px}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.activitypub-embed-container{margin-top:1em;min-height:100px;pointer-events:none;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none}.activitypub-embed-loading{align-items:center;display:flex;justify-content:center}.activitypub-embed-container .wp-block-embed{pointer-events:none!important}.activitypub-embed-preview,.activitypub-embed-preview iframe{pointer-events:none}.activitypub-reply-display{margin:1em 0}.activitypub-reply-display p{margin:0}.activitypub-reply-display a{color:#2271b1;text-decoration:none}.activitypub-reply-display a:hover{color:#135e96;text-decoration:underline}

View File

@ -0,0 +1 @@
.activitypub-embed-container{margin-top:1em;min-height:100px;pointer-events:none;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none}.activitypub-embed-loading{align-items:center;display:flex;justify-content:center}.activitypub-embed-container .wp-block-embed{pointer-events:none!important}.activitypub-embed-preview,.activitypub-embed-preview iframe{pointer-events:none}.activitypub-reply-display{margin:1em 0}.activitypub-reply-display p{margin:0}.activitypub-reply-display a{color:#2271b1;text-decoration:none}.activitypub-reply-display a:hover{color:#135e96;text-decoration:underline}

View File

@ -3,11 +3,14 @@
* Inspired by the PHP ActivityPub Library by @Landrok
*
* @link https://github.com/landrok/activitypub
*
* @package Activitypub
*/
namespace Activitypub\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\Activity\Extended_Object\Event;
use Activitypub\Activity\Extended_Object\Place;
/**
* \Activitypub\Activity\Activity implements the common
@ -22,6 +25,45 @@ class Activity extends Base_Object {
);
/**
* The default types for Activities.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
*
* @var array
*/
const TYPES = array(
'Accept',
'Add',
'Announce',
'Arrive',
'Block',
'Create',
'Delete',
'Dislike',
'Follow',
'Flag',
'Ignore',
'Invite',
'Join',
'Leave',
'Like',
'Listen',
'Move',
'Offer',
'Read',
'Reject',
'Remove',
'TentativeAccept',
'TentativeReject',
'Travel',
'Undo',
'Update',
'View',
);
/**
* The type of the object.
*
* @var string
*/
protected $type = 'Activity';
@ -33,10 +75,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object-term
*
* @var string
* | Base_Object
* | Link
* | null
* @var string|Base_Object|null
*/
protected $object;
@ -48,11 +87,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-actor
*
* @var string
* | \ActivityPhp\Type\Extended\AbstractActor
* | array<Actor>
* | array<Link>
* | Link
* @var string|array
*/
protected $actor;
@ -67,11 +102,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-target
*
* @var string
* | ObjectType
* | array<ObjectType>
* | Link
* | array<Link>
* @var string|array
*/
protected $target;
@ -83,13 +114,22 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-result
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|Base_Object
*/
protected $result;
/**
* Identifies a Collection containing objects considered to be responses
* to this object.
* WordPress has a strong core system of approving replies. We only include
* approved replies here.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies
*
* @var array
*/
protected $replies;
/**
* An indirect object of the activity from which the
* activity is directed.
@ -100,10 +140,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-origin
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|array
*/
protected $origin;
@ -113,10 +150,7 @@ class Activity extends Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-instrument
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|array
*/
protected $instrument;
@ -124,29 +158,67 @@ class Activity extends Base_Object {
* 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.
* 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
* @param array|string|Base_Object|Activity|Actor|null $data Activity object.
*/
public function set_object( $object ) {
// convert array to object
if ( is_array( $object ) ) {
$object = self::init_from_array( $object );
public function set_object( $data ) {
$object = $data;
// Convert array to appropriate object type.
if ( is_array( $data ) ) {
$type = $data['type'] ?? null;
if ( in_array( $type, self::TYPES, true ) ) {
$object = self::init_from_array( $data );
} elseif ( in_array( $type, Actor::TYPES, true ) ) {
$object = Actor::init_from_array( $data );
} elseif ( in_array( $type, Base_Object::TYPES, true ) ) {
switch ( $type ) {
case 'Event':
$object = Event::init_from_array( $data );
break;
case 'Place':
$object = Place::init_from_array( $data );
break;
default:
$object = Base_Object::init_from_array( $data );
break;
}
} else {
$object = Generic_Object::init_from_array( $data );
}
}
// set object
$this->set( 'object', $object );
$this->pre_fill_activity_from_object();
}
/**
* Fills the Activity with the specified activity object.
*/
public function pre_fill_activity_from_object() {
$object = $this->get_object();
// Check if `$data` is a URL and use it to generate an ID then.
if ( is_string( $object ) && filter_var( $object, FILTER_VALIDATE_URL ) && ! $this->get_id() ) {
$this->set( 'id', $object . '#activity-' . strtolower( $this->get_type() ) . '-' . time() );
return;
}
// Check if `$data` is an object and copy some properties otherwise do nothing.
if ( ! is_object( $object ) ) {
return;
}
foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
$this->set( $i, $object->get( $i ) );
$value = $object->get( $i );
if ( $value && ! $this->get( $i ) ) {
$this->set( $i, $value );
}
}
if ( $object->get_published() && ! $this->get_published() ) {
@ -161,8 +233,20 @@ class Activity extends Base_Object {
$this->set( 'actor', $object->get_attributed_to() );
}
if ( $this->get_type() !== 'Announce' && $object->get_in_reply_to() && ! $this->get_in_reply_to() ) {
$this->set( 'in_reply_to', $object->get_in_reply_to() );
}
if ( $object->get_id() && ! $this->get_id() ) {
$this->set( 'id', $object->get_id() . '#activity' );
$id = strtok( $object->get_id(), '#' );
if ( $object->get_updated() ) {
$updated = $object->get_updated();
} elseif ( $object->get_published() ) {
$updated = $object->get_published();
} else {
$updated = time();
}
$this->set( 'id', $id . '#activity-' . strtolower( $this->get_type() ) . '-' . $updated );
}
}
@ -175,7 +259,7 @@ class Activity extends Base_Object {
if ( $this->object instanceof Base_Object ) {
$class = get_class( $this->object );
if ( $class && $class::JSON_LD_CONTEXT ) {
// Without php 5.6 support this could be just: 'return $this->object::JSON_LD_CONTEXT;'
// Without php 5.6 support this could be just: 'return $this->object::JSON_LD_CONTEXT;'.
return $class::JSON_LD_CONTEXT;
}
}

View File

@ -3,6 +3,8 @@
* Inspired by the PHP ActivityPub Library by @Landrok
*
* @link https://github.com/landrok/activitypub
*
* @package Activitypub
*/
namespace Activitypub\Activity;
@ -22,34 +24,61 @@ class Actor extends Base_Object {
'https://w3id.org/security/v1',
'https://purl.archive.org/socialweb/webfinger',
array(
'schema' => 'http://schema.org#',
'toot' => 'http://joinmastodon.org/ns#',
'webfinger' => 'https://webfinger.net/#',
'lemmy' => 'https://join-lemmy.org/ns#',
'schema' => 'http://schema.org#',
'toot' => 'http://joinmastodon.org/ns#',
'lemmy' => 'https://join-lemmy.org/ns#',
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
'PropertyValue' => 'schema:PropertyValue',
'value' => 'schema:value',
'Hashtag' => 'as:Hashtag',
'featured' => array(
'@id' => 'toot:featured',
'PropertyValue' => 'schema:PropertyValue',
'value' => 'schema:value',
'Hashtag' => 'as:Hashtag',
'featured' => array(
'@id' => 'toot:featured',
'@type' => '@id',
),
'featuredTags' => array(
'@id' => 'toot:featuredTags',
'featuredTags' => array(
'@id' => 'toot:featuredTags',
'@type' => '@id',
),
'moderators' => array(
'@id' => 'lemmy:moderators',
'moderators' => array(
'@id' => 'lemmy:moderators',
'@type' => '@id',
),
'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods',
'discoverable' => 'toot:discoverable',
'indexable' => 'toot:indexable',
'resource' => 'webfinger:resource',
'alsoKnownAs' => array(
'@id' => 'as:alsoKnownAs',
'@type' => '@id',
),
'movedTo' => array(
'@id' => 'as:movedTo',
'@type' => '@id',
),
'attributionDomains' => array(
'@id' => 'toot:attributionDomains',
'@type' => '@id',
),
'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods',
'discoverable' => 'toot:discoverable',
'indexable' => 'toot:indexable',
),
);
/**
* The default types for Actors.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
*
* @var array
*/
const TYPES = array(
'Application',
'Group',
'Organization',
'Person',
'Service',
);
/**
* The type of the object.
*
* @var string
*/
protected $type;
@ -60,8 +89,7 @@ class Actor extends Base_Object {
*
* @see https://www.w3.org/TR/activitypub/#inbox
*
* @var string
* | null
* @var string|null
*/
protected $inbox;
@ -71,8 +99,7 @@ class Actor extends Base_Object {
*
* @see https://www.w3.org/TR/activitypub/#outbox
*
* @var string
* | null
* @var string|null
*/
protected $outbox;
@ -171,4 +198,28 @@ class Actor extends Base_Object {
* @var boolean
*/
protected $manually_approves_followers = false;
/**
* Domains allowed to use `fediverse:creator` for this actor in
* published articles.
*
* @see https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/
*
* @var array
*/
protected $attribution_domains = null;
/**
* The target of the actor.
*
* @var string|null
*/
protected $moved_to;
/**
* The alsoKnownAs of the actor.
*
* @var array
*/
protected $also_known_as;
}

View File

@ -3,17 +3,12 @@
* Inspired by the PHP ActivityPub Library by @Landrok
*
* @link https://github.com/landrok/activitypub
*
* @package Activitypub
*/
namespace Activitypub\Activity;
use WP_Error;
use ReflectionClass;
use DateTime;
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.
@ -25,25 +20,89 @@ use function Activitypub\snake_to_camel_case;
* 'Base_' for this reason.
*
* @see https://www.w3.org/TR/activitystreams-core/#object
*
* @method string|null get_actor() Gets one or more entities that performed or are expected to perform the activity.
* @method string|null get_attributed_to() Gets the entity attributed as the original author.
* @method array|null get_attachment() Gets the attachment property of the object.
* @method array|null get_cc() Gets the secondary recipients of the object.
* @method string|null get_content() Gets the content property of the object.
* @method array|null get_icon() Gets the icon property of the object.
* @method string|null get_id() Gets the object's unique global identifier.
* @method array|null get_image() Gets the image property of the object.
* @method array|string|null get_in_reply_to() Gets the objects this object is in reply to.
* @method string|null get_name() Gets the natural language name of the object.
* @method Base_Object|string|null get_object() Gets the direct object of the activity.
* @method string|null get_published() Gets the date and time the object was published in ISO 8601 format.
* @method string|null get_summary() Gets the natural language summary of the object.
* @method array|null get_tag() Gets the tag property of the object.
* @method array|string|null get_to() Gets the primary recipients of the object.
* @method string get_type() Gets the type of the object.
* @method string|null get_updated() Gets the date and time the object was updated in ISO 8601 format.
* @method string|null get_url() Gets the URL of the object.
*
* @method string|array add_cc( string|array $cc ) Adds one or more entities to the secondary audience of the object.
* @method string|array add_to( string|array $to ) Adds one or more entities to the primary audience of the object.
*
* @method Base_Object set_actor( string|array $actor ) Sets one or more entities that performed the activity.
* @method Base_Object set_attachment( array $attachment ) Sets the attachment property of the object.
* @method Base_Object set_attributed_to( string $attributed_to ) Sets the entity attributed as the original author.
* @method Base_Object set_cc( array|string $cc ) Sets the secondary recipients of the object.
* @method Base_Object set_content( string $content ) Sets the content property of the object.
* @method Base_Object set_content_map( array $content_map ) Sets the content property of the object.
* @method Base_Object set_icon( array $icon ) Sets the icon property of the object.
* @method Base_Object set_id( string $id ) Sets the object's unique global identifier.
* @method Base_Object set_image( array $image ) Sets the image property of the object.
* @method Base_Object set_name( string $name ) Sets the natural language name of the object.
* @method Base_Object set_origin( string $origin ) Sets the origin property of the object.
* @method Base_Object set_published( string $published ) Sets the date and time the object was published in ISO 8601 format.
* @method Base_Object set_sensitive( bool $sensitive ) Sets the sensitive property of the object.
* @method Base_Object set_summary( string $summary ) Sets the natural language summary of the object.
* @method Base_Object set_summary_map( array|null $summary_map ) Sets the summary property of the object.
* @method Base_Object set_target( string $target ) Sets the target property of the object.
* @method Base_Object set_to( array|string $to ) Sets the primary recipients of the object.
* @method Base_Object set_type( string $type ) Sets the type of the object.
* @method Base_Object set_updated( string $updated ) Sets the date and time the object was updated in ISO 8601 format.
* @method Base_Object set_url( string $url ) Sets the URL of the object.
*/
class Base_Object {
class Base_Object extends Generic_Object {
/**
* The JSON-LD context for the object.
*
* @var array
*/
const JSON_LD_CONTEXT = array(
'https://www.w3.org/ns/activitystreams',
array(
'Hashtag' => 'as:Hashtag',
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
),
);
/**
* The object's unique global identifier
* The default types for Objects.
*
* @see https://www.w3.org/TR/activitypub/#obj-id
* @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
*
* @var string
* @var array
*/
protected $id;
const TYPES = array(
'Article',
'Audio',
'Document',
'Event',
'Image',
'Note',
'Page',
'Place',
'Profile',
'Relationship',
'Tombstone',
'Video',
);
/**
* The type of the object.
*
* @var string
*/
protected $type = 'Object';
@ -56,12 +115,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-attachment
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $attachment;
@ -72,12 +126,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-attributedto
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $attributed_to;
@ -87,12 +136,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audience
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $audience;
@ -122,10 +166,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|null
*/
protected $context;
@ -186,12 +227,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
*
* @var string
* | Image
* | Link
* | array<Image>
* | array<Link>
* | null
* @var string|array|null
*/
protected $icon;
@ -202,12 +238,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image-term
*
* @var string
* | Image
* | Link
* | array<Image>
* | array<Link>
* | null
* @var string|array|null
*/
protected $image;
@ -217,12 +248,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $in_reply_to;
@ -232,12 +258,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-location
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $location;
@ -246,10 +267,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-preview
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|null
*/
protected $preview;
@ -281,10 +299,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary
*
* @var string
* | ObjectType
* | Link
* | null
* @var string|null
*/
protected $summary;
@ -294,7 +309,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary
*
* @var array<string>|null
* @var string[]|null
*/
protected $summary_map;
@ -307,12 +322,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|null
*/
protected $tag;
@ -328,11 +338,7 @@ class Base_Object {
/**
* One or more links to representations of the object.
*
* @var string
* | array<string>
* | Link
* | array<Link>
* | null
* @var string|null
*/
protected $url;
@ -342,12 +348,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|array|null
*/
protected $to;
@ -357,12 +358,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-bto
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|array|null
*/
protected $bto;
@ -372,12 +368,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-cc
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|array|null
*/
protected $cc;
@ -387,12 +378,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-bcc
*
* @var string
* | ObjectType
* | Link
* | array<ObjectType>
* | array<Link>
* | null
* @var string|array|null
*/
protected $bcc;
@ -428,7 +414,7 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitypub/#source-property
*
* @var ObjectType
* @var array
*/
protected $source;
@ -438,58 +424,40 @@ class Base_Object {
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies
*
* @var string
* | Collection
* | Link
* | null
* @var string|array|null
*/
protected $replies;
/**
* Magic function to implement getter and setter
* A Collection containing objects considered to be likes for
* this object.
*
* @param string $method The method name.
* @param string $params The method params.
* @see https://www.w3.org/TR/activitypub/#likes
*
* @return void
* @var array
*/
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', 'activitypub' ), array( 'status' => 404 ) );
}
return $this->$var;
}
if ( \strncasecmp( $method, 'set', 3 ) === 0 ) {
return $this->set( $var, $params[0] );
}
if ( \strncasecmp( $method, 'add', 3 ) === 0 ) {
$this->add( $var, $params[0] );
}
}
protected $likes;
/**
* Magic function, to transform the object to string.
* A Collection containing objects considered to be shares for
* this object.
*
* @return string The object id.
* @see https://www.w3.org/TR/activitypub/#shares
*
* @var array
*/
public function __toString() {
return $this->to_string();
}
protected $shares;
/**
* Function to transform the object to string.
* Used to mark an object as containing sensitive content.
* Mastodon displays a content warning, requiring users to click
* through to view the content.
*
* @return string The object id.
* @see https://docs.joinmastodon.org/spec/activitypub/#sensitive
*
* @var boolean
*/
public function to_string() {
return $this->get_id();
}
protected $sensitive;
/**
* Generic getter.
@ -500,21 +468,10 @@ class Base_Object {
*/
public function get( $key ) {
if ( ! $this->has( $key ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
return new \WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
}
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 );
return parent::get( $key );
}
/**
@ -527,12 +484,10 @@ class Base_Object {
*/
public function set( $key, $value ) {
if ( ! $this->has( $key ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
return new \WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
}
$this->$key = $value;
return $this;
return parent::set( $key, $value );
}
/**
@ -545,170 +500,9 @@ class Base_Object {
*/
public function add( $key, $value ) {
if ( ! $this->has( $key ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
return new \WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) );
}
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 );
if ( ! is_array( $array ) ) {
$array = array();
}
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 ) {
if ( ! is_array( $array ) ) {
return new WP_Error( 'invalid_array', __( 'Invalid array', 'activitypub' ), array( 'status' => 404 ) );
}
$object = new static();
foreach ( $array as $key => $value ) {
$key = camel_to_snake_case( $key );
call_user_func( array( $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 );
call_user_func( array( $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.
*
* @param bool $include_json_ld_context Whether to include the JSON-LD context. Default true.
*
* @return array An array built from the Object.
*/
public function to_array( $include_json_ld_context = true ) {
$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( false );
}
// if value is still empty, ignore it for the array and continue.
if ( isset( $value ) ) {
$array[ snake_to_camel_case( $key ) ] = $value;
}
}
if ( $include_json_ld_context ) {
// Get JsonLD context and move it to '@context' at the top.
$array = array_merge( array( '@context' => $this->get_json_ld_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.
*
* @param bool $include_json_ld_context Whether to include the JSON-LD context. Default true.
*
* @return string The JSON string.
*/
public function to_json( $include_json_ld_context = true ) {
$array = $this->to_array( $include_json_ld_context );
$options = \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT;
/*
* Options to be passed to json_encode()
*
* @param int $options The current options flags
*/
$options = \apply_filters( 'activitypub_json_encode_options', $options );
return \wp_json_encode( $array, $options );
}
/**
* Returns the keys of the object vars.
*
* @return array The keys of the object vars.
*/
public function get_object_var_keys() {
return \array_keys( \get_object_vars( $this ) );
}
/**
* Returns the JSON-LD context of this object.
*
* @return array $context A compacted JSON-LD context for the ActivityPub object.
*/
public function get_json_ld_context() {
return static::JSON_LD_CONTEXT;
return parent::add( $key, $value );
}
}

View File

@ -0,0 +1,325 @@
<?php
/**
* Generic Object.
*
* @package Activitypub
*/
namespace Activitypub\Activity;
use function Activitypub\camel_to_snake_case;
use function Activitypub\snake_to_camel_case;
/**
* Generic Object.
*
* This class is used to create Generic Objects.
* It is used to create objects that might be unknown by the plugin but
* conform to the ActivityStreams vocabulary.
*
* @since 5.3.0
*/
#[\AllowDynamicProperties]
class Generic_Object {
/**
* The JSON-LD context for the object.
*
* @var array
*/
const JSON_LD_CONTEXT = array(
'https://www.w3.org/ns/activitystreams',
);
/**
* The object's unique global identifier
*
* @see https://www.w3.org/TR/activitypub/#obj-id
*
* @var string
*/
protected $id;
/**
* 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();
}
/**
* Magic function to implement getter and setter.
*
* @param string $method The method name.
* @param string $params The method params.
*/
public function __call( $method, $params ) {
$var = \strtolower( \substr( $method, 4 ) );
if ( \strncasecmp( $method, 'get', 3 ) === 0 ) {
if ( ! $this->has( $var ) ) {
return null;
}
return $this->$var;
}
if ( \strncasecmp( $method, 'set', 3 ) === 0 ) {
return $this->set( $var, $params[0] );
}
if ( \strncasecmp( $method, 'add', 3 ) === 0 ) {
return $this->add( $var, $params[0] );
}
}
/**
* Generic getter.
*
* @param string $key The key to get.
*
* @return mixed The value.
*/
public function get( $key ) {
return call_user_func( array( $this, 'get_' . $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 ) {
$this->$key = $value;
return $this;
}
/**
* 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 ( empty( $value ) ) {
return;
}
if ( ! isset( $this->$key ) ) {
$this->$key = array();
}
if ( is_string( $this->$key ) ) {
$this->$key = array( $this->$key );
}
$attributes = $this->$key;
if ( is_array( $value ) ) {
$attributes = array_merge( $attributes, $value );
} else {
$attributes[] = $value;
}
$this->$key = array_unique( $attributes );
return $this->$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 );
}
/**
* Convert JSON input to an array.
*
* @param string $json The JSON string.
*
* @return Generic_Object|\WP_Error An Object built from the JSON string or WP_Error when it's not a JSON string.
*/
public static function init_from_json( $json ) {
$array = \json_decode( $json, true );
if ( ! is_array( $array ) ) {
return new \WP_Error( 'invalid_json', __( 'Invalid JSON', 'activitypub' ), array( 'status' => 400 ) );
}
return self::init_from_array( $array );
}
/**
* Convert input array to a Base_Object.
*
* @param array $data The object array.
*
* @return Generic_Object|\WP_Error An Object built from the input array or WP_Error when it's not an array.
*/
public static function init_from_array( $data ) {
if ( ! is_array( $data ) ) {
return new \WP_Error( 'invalid_array', __( 'Invalid array', 'activitypub' ), array( 'status' => 400 ) );
}
$object = new static();
$object->from_array( $data );
return $object;
}
/**
* Convert JSON input to an array and pre-fill the object.
*
* @param array $data The array.
*/
public function from_array( $data ) {
foreach ( $data as $key => $value ) {
if ( null !== $value ) {
$key = camel_to_snake_case( $key );
call_user_func( array( $this, 'set_' . $key ), $value );
}
}
}
/**
* 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 Object to an array.
*
* It tries to get the object attributes if they exist
* and falls back to the getters. Empty values are ignored.
*
* @param bool $include_json_ld_context Whether to include the JSON-LD context. Default true.
*
* @return array An array built from the Object.
*/
public function to_array( $include_json_ld_context = true ) {
$array = array();
$vars = get_object_vars( $this );
foreach ( $vars as $key => $value ) {
if ( \is_wp_error( $value ) ) {
continue;
}
// Ignore 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( false );
}
// If value is still empty, ignore it for the array and continue.
if ( isset( $value ) ) {
$array[ snake_to_camel_case( $key ) ] = $value;
}
}
if ( $include_json_ld_context ) {
// Get JsonLD context and move it to '@context' at the top.
$array = array_merge( array( '@context' => $this->get_json_ld_context() ), $array );
}
$class = new \ReflectionClass( $this );
$class = strtolower( $class->getShortName() );
/**
* Filter the array of the ActivityPub object.
*
* @param array $array The array of the ActivityPub object.
* @param string $class The class of the ActivityPub object.
* @param string $id The ID of the ActivityPub object.
* @param Generic_Object $object The ActivityPub object.
*
* @return array The filtered array of the ActivityPub object.
*/
$array = \apply_filters( 'activitypub_activity_object_array', $array, $class, $this->id, $this );
/**
* Filter the array of the ActivityPub object by class.
*
* @param array $array The array of the ActivityPub object.
* @param string $id The ID of the ActivityPub object.
* @param Generic_Object $object The ActivityPub object.
*
* @return array The filtered array of the ActivityPub object.
*/
return \apply_filters( "activitypub_activity_{$class}_object_array", $array, $this->id, $this );
}
/**
* Convert Object to JSON.
*
* @param bool $include_json_ld_context Whether to include the JSON-LD context. Default true.
*
* @return string The JSON string.
*/
public function to_json( $include_json_ld_context = true ) {
$array = $this->to_array( $include_json_ld_context );
$options = \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT | \JSON_UNESCAPED_SLASHES;
/**
* Options to be passed to json_encode().
*
* @param int $options The current options flags.
*/
$options = \apply_filters( 'activitypub_json_encode_options', $options );
return \wp_json_encode( $array, $options );
}
/**
* Returns the keys of the object vars.
*
* @return array The keys of the object vars.
*/
public function get_object_var_keys() {
return \array_keys( \get_object_vars( $this ) );
}
/**
* Returns the JSON-LD context of this object.
*
* @return array $context A compacted JSON-LD context for the ActivityPub object.
*/
public function get_json_ld_context() {
return static::JSON_LD_CONTEXT;
}
}

View File

@ -19,8 +19,8 @@ use Activitypub\Activity\Base_Object;
class Event extends Base_Object {
// Human friendly minimal context for full Mobilizon compatible ActivityPub events.
const JSON_LD_CONTEXT = array(
'https://schema.org/', // The base context is schema.org, cause it is used a lot.
'https://www.w3.org/ns/activitystreams', // The ActivityStreams context overrides everyting also defined in schema.org.
'https://schema.org/', // The base context is schema.org, because it is used a lot.
'https://www.w3.org/ns/activitystreams', // The ActivityStreams context overrides everything also defined in schema.org.
array( // The keys here override/extend the context even more.
'pt' => 'https://joinpeertube.org/ns#',
'mz' => 'https://joinmobilizon.org/ns#',
@ -50,7 +50,8 @@ class Event extends Base_Object {
);
/**
* Mobilizon compatible values for repliesModertaionOption.
* Mobilizon compatible values for repliesModerationOption.
*
* @var array
*/
const REPLIES_MODERATION_OPTION_TYPES = array( 'allow_all', 'closed' );
@ -58,10 +59,11 @@ class Event extends Base_Object {
/**
* Mobilizon compatible values for joinModeTypes.
*/
const JOIN_MODE_TYPES = array( 'free', 'restricted', 'external' ); // and 'invite', but not used by mobilizon atm
const JOIN_MODE_TYPES = array( 'free', 'restricted', 'external' ); // and 'invite', but not used by mobilizon atm.
/**
* Allowed values for ical VEVENT STATUS.
*
* @var array
*/
const ICAL_EVENT_STATUS_TYPES = array( 'TENTATIVE', 'CONFIRMED', 'CANCELLED' );
@ -70,6 +72,7 @@ class Event extends Base_Object {
* Default event categories.
*
* These values currently reflect the default set as proposed by Mobilizon to maximize interoperability.
*
* @var array
*/
const DEFAULT_EVENT_CATEGORIES = array(
@ -106,8 +109,7 @@ class Event extends Base_Object {
);
/**
* Event is an implementation of one of the
* Activity Streams
* Event is an implementation of one of the Activity Streams.
*
* @var string
*/
@ -115,11 +117,13 @@ class Event extends Base_Object {
/**
* The Title of the event.
*
* @var string
*/
protected $name;
/**
* The events contacts
* The events contacts.
*
* @context {
* '@id' => 'mz:contacts',
@ -142,12 +146,16 @@ class Event extends Base_Object {
protected $comments_enabled;
/**
* Timezone of the event.
*
* @context https://joinmobilizon.org/ns#timezone
* @var string
*/
protected $timezone;
/**
* Moderation option for replies.
*
* @context https://joinmobilizon.org/ns#repliesModerationOption
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#repliesmoderation
* @var string
@ -155,6 +163,8 @@ class Event extends Base_Object {
protected $replies_moderation_option;
/**
* Whether anonymous participation is enabled.
*
* @context https://joinmobilizon.org/ns#anonymousParticipationEnabled
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#anonymousparticipationenabled
* @var bool
@ -162,26 +172,34 @@ class Event extends Base_Object {
protected $anonymous_participation_enabled;
/**
* The event's category.
*
* @context https://schema.org/category
* @var enum
* @var string
*/
protected $category;
/**
* Language of the event.
*
* @context https://schema.org/inLanguage
* @var
* @var string
*/
protected $in_language;
/**
* Whether the event is online.
*
* @context https://joinmobilizon.org/ns#isOnline
* @var bool
*/
protected $is_online;
/**
* The event's status.
*
* @context https://www.w3.org/2002/12/cal/ical#status
* @var enum
* @var string
*/
protected $status;
@ -196,25 +214,33 @@ class Event extends Base_Object {
protected $actor;
/**
* The external participation URL.
*
* @context https://joinmobilizon.org/ns#externalParticipationUrl
* @var string
*/
protected $external_participation_url;
/**
* Indicator of how new members may be able to join.
*
* @context https://joinmobilizon.org/ns#joinMode
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#joinmode
* @var
* @var string
*/
protected $join_mode;
/**
* The participant count of the event.
*
* @context https://joinmobilizon.org/ns#participantCount
* @var int
*/
protected $participant_count;
/**
* How many places there can be for an event.
*
* @context https://schema.org/maximumAttendeeCapacity
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#maximumattendeecapacity
* @var int
@ -222,6 +248,8 @@ class Event extends Base_Object {
protected $maximum_attendee_capacity;
/**
* The number of attendee places for an event that remain unallocated.
*
* @context https://schema.org/remainingAttendeeCapacity
* @see https://docs.joinmobilizon.org/contribute/activity_pub/#remainignattendeecapacity
* @var int
@ -234,6 +262,7 @@ class Event extends Base_Object {
* The passed timezone is only set when it is a valid one, otherwise the site's timezone is used.
*
* @param string $timezone The timezone string to be set, e.g. 'Europe/Berlin'.
* @return Event
*/
public function set_timezone( $timezone ) {
if ( in_array( $timezone, timezone_identifiers_list(), true ) ) {
@ -246,14 +275,16 @@ class Event extends Base_Object {
}
/**
* Custom setter for repliesModerationOption which also directy sets commentsEnabled accordingly.
* Custom setter for repliesModerationOption which also directly sets commentsEnabled accordingly.
*
* @param string $type
* @param string $type The type of the replies moderation option.
*
* @return Event
*/
public function set_replies_moderation_option( $type ) {
if ( in_array( $type, self::REPLIES_MODERATION_OPTION_TYPES, true ) ) {
$this->replies_moderation_option = $type;
$this->comments_enabled = ( 'allow_all' === $type ) ? true : false;
$this->comments_enabled = ( 'allow_all' === $type ) ? true : false;
} else {
_doing_it_wrong(
__METHOD__,
@ -268,11 +299,13 @@ class Event extends Base_Object {
/**
* Custom setter for commentsEnabled which also directly sets repliesModerationOption accordingly.
*
* @param bool $comments_enabled
* @param bool $comments_enabled Whether comments are enabled.
*
* @return Event
*/
public function set_comments_enabled( $comments_enabled ) {
if ( is_bool( $comments_enabled ) ) {
$this->comments_enabled = $comments_enabled;
$this->comments_enabled = $comments_enabled;
$this->replies_moderation_option = $comments_enabled ? 'allow_all' : 'closed';
} else {
_doing_it_wrong(
@ -288,7 +321,9 @@ class Event extends Base_Object {
/**
* Custom setter for the ical status that checks whether the status is an ical event status.
*
* @param string $status
* @param string $status The status of the event.
*
* @return Event
*/
public function set_status( $status ) {
if ( in_array( $status, self::ICAL_EVENT_STATUS_TYPES, true ) ) {
@ -307,13 +342,15 @@ class Event extends Base_Object {
/**
* Custom setter for the event category.
*
* Falls back to Mobilizons default category.
* Falls back to Mobilizon's default category.
*
* @param string $category
* @param bool $mobilizon_compatibilty Whether the category must be compatibly with Mobilizon.
* @param string $category The category of the event.
* @param bool $mobilizon_compatibility Optional. Whether the category must be compatibly with Mobilizon. Default true.
*
* @return Event
*/
public function set_category( $category, $mobilizon_compatibilty = true ) {
if ( $mobilizon_compatibilty ) {
public function set_category( $category, $mobilizon_compatibility = true ) {
if ( $mobilizon_compatibility ) {
$this->category = in_array( $category, self::DEFAULT_EVENT_CATEGORIES, true ) ? $category : 'MEETING';
} else {
$this->category = $category;
@ -327,12 +364,14 @@ class Event extends Base_Object {
*
* Automatically sets the joinMode to true if called.
*
* @param string $url
* @param string $url The URL for external participation.
*
* @return Event
*/
public function set_external_participation_url( $url ) {
if ( preg_match( '/^https?:\/\/.*/i', $url ) ) {
$this->external_participation_url = $url;
$this->join_mode = 'external';
$this->join_mode = 'external';
}
return $this;

View File

@ -38,8 +38,8 @@ class Place extends Base_Object {
protected $accuracy;
/**
* Indicates the altitude of a place. The measurement units is indicated using the units property.
* If units is not specified, the default is assumed to be "m" indicating meters.
* Indicates the altitude of a place. The measurement unit is indicated using the unit's property.
* If unit is not specified, the default is assumed to be "m" indicating meters.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-altitude
* @var float xsd:float
@ -63,22 +63,34 @@ class Place extends Base_Object {
protected $longitude;
/**
* The radius from the given latitude and longitude for a Place.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-radius
* @var float
*/
protected $radius;
/**
* Specifies the measurement units for the `radius` and `altitude` properties.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-units
* @var string
*/
protected $units;
/**
* @var Postal_Address|string
* The address of the place.
*
* @see https://schema.org/PostalAddress
* @var array|string
*/
protected $address;
/**
* Set the address of the place.
*
* @param array|string $address The address of the place.
*/
public function set_address( $address ) {
if ( is_string( $address ) || is_array( $address ) ) {
$this->address = $address;

View File

@ -1,225 +0,0 @@
<?php
namespace Activitypub;
use WP_Post;
use WP_Comment;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users;
use Activitypub\Collection\Followers;
use Activitypub\Transformer\Factory;
use Activitypub\Transformer\Post;
use Activitypub\Transformer\Comment;
use function Activitypub\is_single_user;
use function Activitypub\is_user_disabled;
use function Activitypub\safe_remote_post;
use function Activitypub\set_wp_object_state;
/**
* ActivityPub Activity_Dispatcher Class
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/
*/
class Activity_Dispatcher {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_send_post', array( self::class, 'send_post' ), 10, 2 );
\add_action( 'activitypub_send_comment', array( self::class, 'send_comment' ), 10, 2 );
\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 );
\add_action( 'activitypub_send_update_profile_activity', array( self::class, 'send_profile_update' ), 10, 1 );
}
/**
* Send Activities to followers and mentioned users or `Announce` (boost) a blog post.
*
* @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_activity_or_announce( $wp_object, $type ) {
if ( is_user_type_disabled( 'blog' ) ) {
return;
}
if ( is_single_user() ) {
self::send_activity( $wp_object, $type, Users::BLOG_USER_ID );
} else {
self::send_announce( $wp_object, $type );
}
}
/**
* Send Activities to followers and mentioned users.
*
* @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_activity( $wp_object, $type, $user_id = null ) {
$transformer = Factory::get_transformer( $wp_object );
if ( null !== $user_id ) {
$transformer->change_wp_user_id( $user_id );
}
$user_id = $transformer->get_wp_user_id();
if ( is_user_disabled( $user_id ) ) {
return;
}
$activity = $transformer->to_activity( $type );
self::send_activity_to_followers( $activity, $user_id, $wp_object );
}
/**
* Send Announces to followers and mentioned users.
*
* @param mixed $wp_object The ActivityPub Post.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_announce( $wp_object, $type ) {
if ( ! in_array( $type, array( 'Create', 'Update' ), true ) ) {
return;
}
if ( is_user_disabled( Users::BLOG_USER_ID ) ) {
return;
}
$transformer = Factory::get_transformer( $wp_object );
$transformer->change_wp_user_id( Users::BLOG_USER_ID );
$user_id = $transformer->get_wp_user_id();
$activity = $transformer->to_activity( 'Announce' );
self::send_activity_to_followers( $activity, $user_id, $wp_object );
}
/**
* Send a "Update" Activity when a user updates their profile.
*
* @param int $user_id The user ID to send an update for.
*
* @return void
*/
public static function send_profile_update( $user_id ) {
$user = Users::get_by_various( $user_id );
// bail if that's not a good user
if ( is_wp_error( $user ) ) {
return;
}
// build the update
$activity = new Activity();
$activity->set_id( $user->get_url() . '#update' );
$activity->set_type( 'Update' );
$activity->set_actor( $user->get_url() );
$activity->set_object( $user->get_url() );
$activity->set_to( 'https://www.w3.org/ns/activitystreams#Public' );
// send the update
self::send_activity_to_followers( $activity, $user_id, $user );
}
/**
* Send an Activity to all followers and mentioned users.
*
* @param Activity $activity The ActivityPub Activity.
* @param int $user_id The user ID.
* @param WP_User|WP_Post|WP_Comment $wp_object The WordPress object.
*
* @return void
*/
private static function send_activity_to_followers( $activity, $user_id, $wp_object ) {
// check if the Activity should be send to the followers
if ( ! apply_filters( 'activitypub_send_activity_to_followers', true, $activity, $user_id, $wp_object ) ) {
return;
}
$follower_inboxes = Followers::get_inboxes( $user_id );
$mentioned_inboxes = array();
$cc = $activity->get_cc();
if ( $cc ) {
$mentioned_inboxes = Mention::get_inboxes( $cc );
}
$inboxes = array_merge( $follower_inboxes, $mentioned_inboxes );
$inboxes = array_unique( $inboxes );
if ( empty( $inboxes ) ) {
return;
}
$json = $activity->to_json();
foreach ( $inboxes as $inbox ) {
safe_remote_post( $inbox, $json, $user_id );
}
set_wp_object_state( $wp_object, 'federated' );
}
/**
* Send a "Create" or "Update" Activity for a WordPress Post.
*
* @param int $id The WordPress Post ID.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_post( $id, $type ) {
$post = get_post( $id );
if ( ! $post ) {
return;
}
do_action( 'activitypub_send_activity', $post, $type );
do_action(
sprintf(
'activitypub_send_%s_activity',
\strtolower( $type )
),
$post
);
}
/**
* Send a "Create" or "Update" Activity for a WordPress Comment.
*
* @param int $id The WordPress Comment ID.
* @param string $type The Activity-Type.
*
* @return void
*/
public static function send_comment( $id, $type ) {
$comment = get_comment( $id );
if ( ! $comment ) {
return;
}
do_action( 'activitypub_send_activity', $comment, $type );
do_action(
sprintf(
'activitypub_send_%s_activity',
\strtolower( $type )
),
$comment
);
}
}

View File

@ -1,19 +1,20 @@
<?php
/**
* ActivityPub Class.
*
* @package Activitypub
*/
namespace Activitypub;
use Exception;
use Activitypub\Signature;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Followers;
use function Activitypub\is_comment;
use function Activitypub\sanitize_url;
use function Activitypub\is_local_comment;
use function Activitypub\is_activitypub_request;
use function Activitypub\should_comment_be_federated;
use Activitypub\Collection\Extra_Fields;
/**
* ActivityPub Class
* ActivityPub Class.
*
* @author Matthias Pfefferle
*/
@ -22,13 +23,15 @@ class Activitypub {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'template_include', array( self::class, 'render_json_template' ), 99 );
\add_filter( 'template_include', array( self::class, 'render_activitypub_template' ), 99 );
\add_action( 'template_redirect', array( self::class, 'template_redirect' ) );
\add_filter( 'redirect_canonical', array( self::class, 'redirect_canonical' ), 10, 2 );
\add_filter( 'redirect_canonical', array( self::class, 'no_trailing_redirect' ), 10, 2 );
\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' ) ) ? \get_option( 'activitypub_support_post_types', array( 'post' ) ) : array();
// Add support for ActivityPub to custom post types.
$post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) );
foreach ( $post_types as $post_type ) {
\add_post_type_support( $post_type, 'activitypub' );
@ -42,39 +45,84 @@ class Activitypub {
\add_action( 'user_register', array( self::class, 'user_register' ) );
\add_action( 'in_plugin_update_message-' . ACTIVITYPUB_PLUGIN_BASENAME, array( self::class, 'plugin_update_message' ) );
\add_filter( 'activitypub_get_actor_extra_fields', array( Extra_Fields::class, 'default_actor_extra_fields' ), 10, 2 );
// register several post_types
\add_action( 'updated_postmeta', array( self::class, 'updated_postmeta' ), 10, 4 );
\add_action( 'added_post_meta', array( self::class, 'updated_postmeta' ), 10, 4 );
\add_action( 'init', array( self::class, 'register_user_meta' ), 11 );
// Register several post_types.
self::register_post_types();
self::register_oembed_providers();
Embed::init();
}
/**
* Activation Hook
*
* @return void
* Activation Hook.
*/
public static function activate() {
self::flush_rewrite_rules();
Scheduler::register_schedules();
\add_filter( 'pre_wp_update_comment_count_now', array( Comment::class, 'pre_wp_update_comment_count_now' ), 10, 3 );
Migration::update_comment_counts();
}
/**
* Deactivation Hook
*
* @return void
* Deactivation Hook.
*/
public static function deactivate() {
self::flush_rewrite_rules();
Scheduler::deregister_schedules();
\remove_filter( 'pre_wp_update_comment_count_now', array( Comment::class, 'pre_wp_update_comment_count_now' ) );
Migration::update_comment_counts( 2000 );
}
/**
* Uninstall Hook
*
* @return void
* Uninstall Hook.
*/
public static function uninstall() {
Scheduler::deregister_schedules();
\remove_filter( 'pre_wp_update_comment_count_now', array( Comment::class, 'pre_wp_update_comment_count_now' ) );
Migration::update_comment_counts( 2000 );
\delete_option( 'activitypub_actor_mode' );
\delete_option( 'activitypub_allow_likes' );
\delete_option( 'activitypub_allow_replies' );
\delete_option( 'activitypub_attribution_domains' );
\delete_option( 'activitypub_authorized_fetch' );
\delete_option( 'activitypub_application_user_private_key' );
\delete_option( 'activitypub_application_user_public_key' );
\delete_option( 'activitypub_blog_user_also_known_as' );
\delete_option( 'activitypub_blog_user_mailer_new_dm' );
\delete_option( 'activitypub_blog_user_mailer_new_follower' );
\delete_option( 'activitypub_blog_user_mailer_new_mention' );
\delete_option( 'activitypub_blog_user_moved_to' );
\delete_option( 'activitypub_blog_user_private_key' );
\delete_option( 'activitypub_blog_user_public_key' );
\delete_option( 'activitypub_blog_description' );
\delete_option( 'activitypub_blog_identifier' );
\delete_option( 'activitypub_custom_post_content' );
\delete_option( 'activitypub_db_version' );
\delete_option( 'activitypub_default_extra_fields' );
\delete_option( 'activitypub_enable_blog_user' );
\delete_option( 'activitypub_enable_users' );
\delete_option( 'activitypub_header_image' );
\delete_option( 'activitypub_last_post_with_permalink_as_id' );
\delete_option( 'activitypub_max_image_attachments' );
\delete_option( 'activitypub_migration_lock' );
\delete_option( 'activitypub_object_type' );
\delete_option( 'activitypub_outbox_purge_days' );
\delete_option( 'activitypub_shared_inbox' );
\delete_option( 'activitypub_support_post_types' );
\delete_option( 'activitypub_use_hashtags' );
\delete_option( 'activitypub_use_opengraph' );
\delete_option( 'activitypub_use_permalink_as_id_for_blog' );
\delete_option( 'activitypub_vary_header' );
}
/**
@ -84,46 +132,144 @@ class Activitypub {
*
* @return string The new path to the JSON template.
*/
public static function render_json_template( $template ) {
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
public static function render_activitypub_template( $template ) {
if ( \wp_is_serving_rest_request() || \wp_doing_ajax() ) {
return $template;
}
self::add_headers();
if ( ! is_activitypub_request() ) {
return $template;
}
$json_template = false;
$activitypub_template = false;
$activitypub_object = Query::get_instance()->get_activitypub_object();
// check if user can publish posts
if ( \is_author() && is_wp_error( Users::get_by_id( \get_the_author_meta( 'ID' ) ) ) ) {
return $template;
if ( $activitypub_object ) {
if ( \get_query_var( 'preview' ) ) {
\define( 'ACTIVITYPUB_PREVIEW', true );
/**
* Filter the template used for the ActivityPub preview.
*
* @param string $activitypub_template Absolute path to the template file.
*/
$activitypub_template = apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
} else {
$activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php';
}
}
// check if blog-user is enabled
if ( \is_home() && is_wp_error( Users::get_by_id( Users::BLOG_USER_ID ) ) ) {
return $template;
}
if ( \is_author() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/author-json.php';
} elseif ( is_comment() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/comment-json.php';
} elseif ( \is_singular() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/post-json.php';
} elseif ( \is_home() ) {
$json_template = ACTIVITYPUB_PLUGIN_DIR . '/templates/blog-json.php';
}
if ( ACTIVITYPUB_AUTHORIZED_FETCH ) {
/*
* Check if the request is authorized.
*
* @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
* @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
*/
if ( $activitypub_template && use_authorized_fetch() ) {
$verification = Signature::verify_http_signature( $_SERVER );
if ( \is_wp_error( $verification ) ) {
// fallback as template_loader can't return http headers
header( 'HTTP/1.1 401 Unauthorized' );
// Fallback as template_loader can't return http headers.
return $template;
}
}
return $json_template;
if ( $activitypub_template ) {
\set_query_var( 'is_404', false );
// Check if header already sent.
if ( ! \headers_sent() ) {
// Send 200 status header.
\status_header( 200 );
}
return $activitypub_template;
}
return $template;
}
/**
* Add the 'self' link to the header.
*/
public static function add_headers() {
$id = Query::get_instance()->get_activitypub_object_id();
if ( ! $id ) {
return;
}
if ( ! headers_sent() ) {
\header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
if ( \get_option( 'activitypub_vary_header' ) ) {
// Send Vary header for Accept header.
\header( 'Vary: Accept', false );
}
}
add_action(
'wp_head',
function () use ( $id ) {
echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . esc_url( $id ) . '" />' . PHP_EOL;
}
);
}
/**
* Remove trailing slash from ActivityPub @username requests.
*
* @param string $redirect_url The URL to redirect to.
* @param string $requested_url The requested URL.
*
* @return string $redirect_url The possibly-unslashed redirect URL.
*/
public static function no_trailing_redirect( $redirect_url, $requested_url ) {
if ( get_query_var( 'actor' ) ) {
return $requested_url;
}
return $redirect_url;
}
/**
* Add support for `p` and `author` query vars.
*
* @param string $redirect_url The URL to redirect to.
* @param string $requested_url The requested URL.
*
* @return string $redirect_url
*/
public static function redirect_canonical( $redirect_url, $requested_url ) {
if ( ! is_activitypub_request() ) {
return $redirect_url;
}
$query = \wp_parse_url( $requested_url, PHP_URL_QUERY );
if ( ! $query ) {
return $redirect_url;
}
$query_params = \wp_parse_args( $query );
unset( $query_params['activitypub'] );
if ( 1 !== count( $query_params ) ) {
return $redirect_url;
}
if ( isset( $query_params['p'] ) ) {
return null;
}
if ( isset( $query_params['author'] ) ) {
return null;
}
return $requested_url;
}
/**
@ -132,36 +278,64 @@ class Activitypub {
* @return void
*/
public static function template_redirect() {
global $wp_query;
$comment_id = get_query_var( 'c', null );
// check if it seems to be a comment
if ( ! $comment_id ) {
return;
// Check if it seems to be a comment.
if ( $comment_id ) {
$comment = get_comment( $comment_id );
// Load a 404-page if `c` is set but not valid.
if ( ! $comment ) {
$wp_query->set_404();
return;
}
// Stop if it's not an ActivityPub comment.
if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
return;
}
wp_safe_redirect( get_comment_link( $comment ) );
exit;
}
$comment = get_comment( $comment_id );
$actor = get_query_var( 'actor', null );
if ( $actor ) {
$actor = Actors::get_by_username( $actor );
if ( ! $actor || \is_wp_error( $actor ) ) {
$wp_query->set_404();
return;
}
// load a 404 page if `c` is set but not valid
if ( ! $comment ) {
global $wp_query;
$wp_query->set_404();
return;
if ( is_activitypub_request() ) {
return;
}
if ( $actor->get__id() > 0 ) {
$redirect_url = $actor->get_url();
} else {
$redirect_url = get_bloginfo( 'url' );
}
wp_safe_redirect( $redirect_url, 301 );
exit;
}
// stop if it's not an ActivityPub comment
if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
return;
}
wp_safe_redirect( get_comment_link( $comment ) );
exit;
}
/**
* Add the 'activitypub' query variable so WordPress won't mangle it.
*
* @param array $vars The query variables.
*
* @return array The query variables.
*/
public static function add_query_vars( $vars ) {
$vars[] = 'activitypub';
$vars[] = 'preview';
$vars[] = 'author';
$vars[] = 'actor';
$vars[] = 'c';
$vars[] = 'p';
@ -200,7 +374,7 @@ class Activitypub {
}
// Check if comment has an avatar.
$avatar = self::get_avatar_url( $id_or_email->comment_ID );
$avatar = \get_comment_meta( $id_or_email->comment_ID, 'avatar_url', true );
if ( $avatar ) {
if ( empty( $args['class'] ) ) {
@ -218,53 +392,37 @@ class Activitypub {
return $args;
}
/**
* Function to retrieve Avatar URL if stored in meta.
*
* @param int|WP_Comment $comment
*
* @return string $url
*/
public static function get_avatar_url( $comment ) {
if ( \is_numeric( $comment ) ) {
$comment = \get_comment( $comment );
}
return \get_comment_meta( $comment->comment_ID, 'avatar_url', true );
}
/**
* Store permalink in meta, to send delete Activity.
*
* @param string $post_id The Post ID.
*
* @return void
*/
public static function trash_post( $post_id ) {
\add_post_meta(
$post_id,
'activitypub_canonical_url',
'_activitypub_canonical_url',
\get_permalink( $post_id ),
true
);
}
/**
* Delete permalink from meta
* Delete permalink from meta.
*
* @param string $post_id The Post ID
*
* @return void
* @param string $post_id The Post ID.
*/
public static function untrash_post( $post_id ) {
\delete_post_meta( $post_id, 'activitypub_canonical_url' );
\delete_post_meta( $post_id, '_activitypub_canonical_url' );
}
/**
* Add rewrite rules
* Add rewrite rules.
*/
public static function add_rewrite_rules() {
// If another system needs to take precedence over the ActivityPub rewrite rules,
// they can define their own and will manually call the appropriate functions as required.
/*
* If another system needs to take precedence over the ActivityPub rewrite rules,
* they can define their own and will manually call the appropriate functions as required.
*/
if ( ACTIVITYPUB_DISABLE_REWRITES ) {
return;
}
@ -280,27 +438,17 @@ class Activitypub {
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',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo',
'top'
);
}
\add_rewrite_rule(
'^@([\w\-\.]+)',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/users/$matches[1]',
'top'
);
\add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' );
\add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES );
}
/**
* Flush rewrite rules;
* Flush rewrite rules.
*/
public static function flush_rewrite_rules() {
self::add_rewrite_rules();
@ -308,37 +456,10 @@ class Activitypub {
}
/**
* Theme compatibility stuff
*
* @return void
* Theme compatibility stuff.
*/
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 );
}
// We assume that you want to use Post-Formats when enabling the setting
// We assume that you want to use Post-Formats when enabling the setting.
if ( 'wordpress-post-format' === \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ) ) {
if ( ! get_theme_support( 'post-formats' ) ) {
// Add support for the Aside, Gallery Post Formats...
@ -357,35 +478,7 @@ class Activitypub {
}
/**
* 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(),
)
)
);
}
/**
* Register the "Followers" Taxonomy
*
* @return void
* Register Custom Post Types.
*/
private static function register_post_types() {
\register_post_type(
@ -407,7 +500,7 @@ class Activitypub {
\register_post_meta(
Followers::POST_TYPE,
'activitypub_inbox',
'_activitypub_inbox',
array(
'type' => 'string',
'single' => true,
@ -417,7 +510,7 @@ class Activitypub {
\register_post_meta(
Followers::POST_TYPE,
'activitypub_errors',
'_activitypub_errors',
array(
'type' => 'string',
'single' => false,
@ -433,7 +526,7 @@ class Activitypub {
\register_post_meta(
Followers::POST_TYPE,
'activitypub_user_id',
'_activitypub_user_id',
array(
'type' => 'string',
'single' => false,
@ -445,7 +538,7 @@ class Activitypub {
\register_post_meta(
Followers::POST_TYPE,
'activitypub_actor_json',
'_activitypub_actor_json',
array(
'type' => 'string',
'single' => true,
@ -455,14 +548,168 @@ class Activitypub {
)
);
// Register Outbox Post-Type.
register_post_type(
Outbox::POST_TYPE,
array(
'labels' => array(
'name' => _x( 'Outbox', 'post_type plural name', 'activitypub' ),
'singular_name' => _x( 'Outbox Item', 'post_type single name', 'activitypub' ),
),
'capabilities' => array(
'create_posts' => false,
),
'map_meta_cap' => true,
'public' => false,
'show_in_rest' => true,
'rewrite' => false,
'query_var' => false,
'supports' => array( 'title', 'editor', 'author', 'custom-fields' ),
'delete_with_user' => true,
'can_export' => true,
'exclude_from_search' => true,
)
);
/**
* Register Activity Type meta for Outbox items.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
*/
\register_post_meta(
Outbox::POST_TYPE,
'_activitypub_activity_type',
array(
'type' => 'string',
'description' => 'The type of the activity',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => function ( $value ) {
$value = ucfirst( strtolower( $value ) );
$schema = array(
'type' => 'string',
'enum' => array( 'Accept', 'Add', 'Announce', 'Arrive', 'Block', 'Create', 'Delete', 'Dislike', 'Flag', 'Follow', 'Ignore', 'Invite', 'Join', 'Leave', 'Like', 'Listen', 'Move', 'Offer', 'Question', 'Reject', 'Read', 'Remove', 'TentativeReject', 'TentativeAccept', 'Travel', 'Undo', 'Update', 'View' ),
'default' => 'Announce',
);
if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) {
return $schema['default'];
}
return $value;
},
)
);
\register_post_meta(
Outbox::POST_TYPE,
'_activitypub_activity_actor',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => function ( $value ) {
$schema = array(
'type' => 'string',
'enum' => array( 'application', 'blog', 'user' ),
'default' => 'user',
);
if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) {
return $schema['default'];
}
return $value;
},
)
);
\register_post_meta(
Outbox::POST_TYPE,
'_activitypub_outbox_offset',
array(
'type' => 'integer',
'single' => true,
'description' => 'Keeps track of the followers offset when processing outbox items.',
'sanitize_callback' => 'absint',
'default' => 0,
)
);
\register_post_meta(
Outbox::POST_TYPE,
'_activitypub_object_id',
array(
'type' => 'string',
'single' => true,
'description' => 'The ID (ActivityPub URI) of the object that the outbox item is about.',
'sanitize_callback' => 'sanitize_url',
)
);
\register_post_meta(
Outbox::POST_TYPE,
'activitypub_content_visibility',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => function ( $value ) {
$schema = array(
'type' => 'string',
'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ),
'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
);
if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) {
return $schema['default'];
}
return $value;
},
)
);
// Both User and Blog Extra Fields types have the same args.
$args = array(
'labels' => array(
'name' => _x( 'Extra fields', 'post_type plural name', 'activitypub' ),
'singular_name' => _x( 'Extra field', 'post_type single name', 'activitypub' ),
'add_new' => __( 'Add new', 'activitypub' ),
'add_new_item' => __( 'Add new extra field', 'activitypub' ),
'new_item' => __( 'New extra field', 'activitypub' ),
'edit_item' => __( 'Edit extra field', 'activitypub' ),
'view_item' => __( 'View extra field', 'activitypub' ),
'all_items' => __( 'All extra fields', 'activitypub' ),
),
'public' => false,
'hierarchical' => false,
'query_var' => false,
'has_archive' => false,
'publicly_queryable' => false,
'show_in_menu' => false,
'delete_with_user' => true,
'can_export' => true,
'exclude_from_search' => true,
'show_in_rest' => true,
'map_meta_cap' => true,
'show_ui' => true,
'supports' => array( 'title', 'editor', 'page-attributes' ),
);
\register_post_type( Extra_Fields::USER_POST_TYPE, $args );
\register_post_type( Extra_Fields::BLOG_POST_TYPE, $args );
/**
* Fires after ActivityPub custom post types have been registered.
*/
\do_action( 'activitypub_after_register_post_type' );
}
/**
* Add the 'activitypub' query variable so WordPress won't mangle it.
* Add the 'activitypub' capability to users who can publish posts.
*
* @param int $user_id User ID.
* @param array $userdata The raw array of data passed to wp_insert_user().
* @param int $user_id User ID.
*/
public static function user_register( $user_id ) {
if ( \user_can( $user_id, 'publish_posts' ) ) {
@ -470,4 +717,182 @@ class Activitypub {
$user->add_cap( 'activitypub' );
}
}
/**
* Delete `activitypub_content_visibility` when updated to an empty value.
*
* @param int $meta_id ID of updated metadata entry.
* @param int $object_id Post ID.
* @param string $meta_key Metadata key.
* @param mixed $meta_value Metadata value. This will be a PHP-serialized string representation of the value
* if the value is an array, an object, or itself a PHP-serialized string.
*/
public static function updated_postmeta( $meta_id, $object_id, $meta_key, $meta_value ) {
if ( 'activitypub_content_visibility' === $meta_key && empty( $meta_value ) ) {
\delete_post_meta( $object_id, 'activitypub_content_visibility' );
}
}
/**
* Register some Mastodon oEmbed providers.
*/
public static function register_oembed_providers() {
\wp_oembed_add_provider( '#https?://mastodon\.social/(@.+)/([0-9]+)#i', 'https://mastodon.social/api/oembed', true );
\wp_oembed_add_provider( '#https?://mastodon\.online/(@.+)/([0-9]+)#i', 'https://mastodon.online/api/oembed', true );
\wp_oembed_add_provider( '#https?://mastodon\.cloud/(@.+)/([0-9]+)#i', 'https://mastodon.cloud/api/oembed', true );
\wp_oembed_add_provider( '#https?://mstdn\.social/(@.+)/([0-9]+)#i', 'https://mstdn.social/api/oembed', true );
\wp_oembed_add_provider( '#https?://mastodon\.world/(@.+)/([0-9]+)#i', 'https://mastodon.world/api/oembed', true );
\wp_oembed_add_provider( '#https?://mas\.to/(@.+)/([0-9]+)#i', 'https://mas.to/api/oembed', true );
}
/**
* Register user meta.
*/
public static function register_user_meta() {
$blog_prefix = $GLOBALS['wpdb']->get_blog_prefix();
\register_meta(
'user',
$blog_prefix . 'activitypub_also_known_as',
array(
'type' => 'array',
'description' => 'An array of URLs that the user is known by.',
'single' => true,
'default' => array(),
'sanitize_callback' => array( Sanitize::class, 'url_list' ),
)
);
\register_meta(
'user',
$blog_prefix . 'activitypub_old_host_data',
array(
'description' => 'Actor object for the user on the old host.',
'single' => true,
)
);
\register_meta(
'user',
$blog_prefix . 'activitypub_moved_to',
array(
'type' => 'string',
'description' => 'The new URL of the user.',
'single' => true,
'sanitize_callback' => 'sanitize_url',
)
);
\register_meta(
'user',
$blog_prefix . 'activitypub_description',
array(
'type' => 'string',
'description' => 'The users description.',
'single' => true,
'default' => '',
'sanitize_callback' => function ( $value ) {
return wp_kses( $value, 'user_description' );
},
)
);
\register_meta(
'user',
$blog_prefix . 'activitypub_icon',
array(
'type' => 'integer',
'description' => 'The attachment ID for users profile image.',
'single' => true,
'default' => 0,
'sanitize_callback' => 'absint',
)
);
\register_meta(
'user',
$blog_prefix . 'activitypub_header_image',
array(
'type' => 'integer',
'description' => 'The attachment ID for the users header image.',
'single' => true,
'default' => 0,
'sanitize_callback' => 'absint',
)
);
\register_meta(
'user',
$blog_prefix . 'activitypub_mailer_new_dm',
array(
'type' => 'integer',
'description' => 'Send a notification when someone sends this user a direct message.',
'single' => true,
'sanitize_callback' => 'absint',
)
);
\add_filter( 'get_user_option_activitypub_mailer_new_dm', array( self::class, 'user_options_default' ) );
\register_meta(
'user',
$blog_prefix . 'activitypub_mailer_new_follower',
array(
'type' => 'integer',
'description' => 'Send a notification when someone starts to follow this user.',
'single' => true,
'sanitize_callback' => 'absint',
)
);
\add_filter( 'get_user_option_activitypub_mailer_new_follower', array( self::class, 'user_options_default' ) );
\register_meta(
'user',
$blog_prefix . 'activitypub_mailer_new_mention',
array(
'type' => 'integer',
'description' => 'Send a notification when someone mentions this user.',
'single' => true,
'sanitize_callback' => 'absint',
)
);
\add_filter( 'get_user_option_activitypub_mailer_new_mention', array( self::class, 'user_options_default' ) );
\register_meta(
'user',
'activitypub_show_welcome_tab',
array(
'type' => 'integer',
'description' => 'Whether to show the welcome tab.',
'single' => true,
'default' => 1,
'sanitize_callback' => 'absint',
)
);
\register_meta(
'user',
'activitypub_show_advanced_tab',
array(
'type' => 'integer',
'description' => 'Whether to show the advanced tab.',
'single' => true,
'default' => 0,
'sanitize_callback' => 'absint',
)
);
}
/**
* Set default values for user options.
*
* @param bool|string $value Option value.
* @return bool|string
*/
public static function user_options_default( $value ) {
if ( false === $value ) {
return '1';
}
return $value;
}
}

View File

@ -1,472 +0,0 @@
<?php
namespace Activitypub;
use WP_User_Query;
use Activitypub\Model\Blog_User;
use function Activitypub\is_user_disabled;
use function Activitypub\was_comment_received;
use function Activitypub\is_comment_federatable;
/**
* ActivityPub Admin Class
*
* @author Matthias Pfefferle
*/
class Admin {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_action( 'admin_menu', array( self::class, 'admin_menu' ) );
\add_action( 'admin_init', array( self::class, 'register_settings' ) );
\add_action( 'load-comment.php', array( self::class, 'edit_comment' ) );
\add_action( 'personal_options_update', array( self::class, 'save_user_description' ) );
\add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_scripts' ) );
\add_action( 'admin_notices', array( self::class, 'admin_notices' ) );
\add_filter( 'comment_row_actions', array( self::class, 'comment_row_actions' ), 10, 2 );
\add_filter( 'manage_edit-comments_columns', array( static::class, 'manage_comment_columns' ) );
\add_filter( 'manage_comments_custom_column', array( static::class, 'manage_comments_custom_column' ), 9, 2 );
\add_filter( 'manage_users_columns', array( self::class, 'manage_users_columns' ), 10, 1 );
\add_filter( 'manage_users_custom_column', array( self::class, 'manage_users_custom_column' ), 10, 3 );
\add_filter( 'bulk_actions-users', array( self::class, 'user_bulk_options' ) );
\add_filter( 'handle_bulk_actions-users', array( self::class, 'handle_bulk_request' ), 10, 3 );
if ( ! is_user_disabled( get_current_user_id() ) ) {
\add_action( 'show_user_profile', array( self::class, 'add_profile' ) );
}
}
/**
* Add admin menu entry
*/
public static function admin_menu() {
$settings_page = \add_options_page(
'Welcome',
'ActivityPub',
'manage_options',
'activitypub',
array( self::class, 'settings_page' )
);
\add_action( 'load-' . $settings_page, array( self::class, 'add_settings_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' ) );
}
}
/**
* Display admin menu notices about configuration problems or conflicts.
*
* @return void
*/
public static function admin_notices() {
$permalink_structure = \get_option( 'permalink_structure' );
if ( empty( $permalink_structure ) ) {
$admin_notice = \__( 'You are using the ActivityPub plugin with a permalink structure of "plain". This will prevent ActivityPub from working. Please go to "Settings" / "Permalinks" and choose a permalink structure other than "plain".', 'activitypub' );
self::show_admin_notice( $admin_notice, 'error' );
}
}
/**
* Display one admin menu notice about configuration problems or conflicts.
*
* @param string $admin_notice The notice to display.
* @param string $level The level of the notice (error, warning, success, info).
*
* @return void
*/
private static function show_admin_notice( $admin_notice, $level ) {
?>
<div class="notice notice-<?php echo esc_attr( $level ); ?>">
<p><?php echo wp_kses( $admin_notice, 'data' ); ?></p>
</div>
<?php
}
/**
* Load settings page
*/
public static function settings_page() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['tab'] ) ) {
$tab = 'welcome';
} else {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$tab = sanitize_key( $_GET['tab'] );
}
switch ( $tab ) {
case 'settings':
\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:
wp_enqueue_script( 'plugin-install' );
add_thickbox();
wp_enqueue_script( 'updates' );
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/welcome.php' );
break;
}
}
/**
* Load user settings page
*/
public static function followers_list_page() {
// 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' );
}
}
/**
* Register ActivityPub settings
*/
public static function register_settings() {
\register_setting(
'activitypub',
'activitypub_post_content_type',
array(
'type' => 'string',
'description' => \__( 'Use title and link, summary, full or custom content', 'activitypub' ),
'show_in_rest' => array(
'schema' => array(
'enum' => array(
'title',
'excerpt',
'content',
),
),
),
'default' => 'content',
)
);
\register_setting(
'activitypub',
'activitypub_custom_post_content',
array(
'type' => 'string',
'description' => \__( 'Define your own custom post template', 'activitypub' ),
'show_in_rest' => true,
'default' => ACTIVITYPUB_CUSTOM_POST_CONTENT,
)
);
\register_setting(
'activitypub',
'activitypub_max_image_attachments',
array(
'type' => 'integer',
'description' => \__( 'Number of images to attach to posts.', 'activitypub' ),
'default' => ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS,
)
);
\register_setting(
'activitypub',
'activitypub_object_type',
array(
'type' => 'string',
'description' => \__( 'The Activity-Object-Type', 'activitypub' ),
'show_in_rest' => array(
'schema' => array(
'enum' => array(
'note',
'wordpress-post-format',
),
),
),
'default' => 'note',
)
);
\register_setting(
'activitypub',
'activitypub_use_hashtags',
array(
'type' => 'boolean',
'description' => \__( 'Add hashtags in the content as native tags and replace the #tag with the tag-link', 'activitypub' ),
'default' => '0',
)
);
\register_setting(
'activitypub',
'activitypub_support_post_types',
array(
'type' => 'string',
'description' => \esc_html__( 'Enable ActivityPub support for post types', 'activitypub' ),
'show_in_rest' => true,
'default' => array( 'post' ),
)
);
\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' => 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 );
}
$sanitized = implode( '.', $sanitized );
// check for login or nicename.
$user = new WP_User_Query(
array(
'search' => $sanitized,
'search_columns' => array( 'user_login', 'user_nicename' ),
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
)
);
if ( $user->results ) {
add_settings_error(
'activitypub_blog_user_identifier',
'activitypub_blog_user_identifier',
\esc_html__( 'You cannot use an existing author\'s name for the blog profile ID.', 'activitypub' ),
'error'
);
return Blog_User::get_default_username();
}
return $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 ACTIVITYPUB_PLUGIN_DIR . 'includes/help.php';
}
public static function add_followers_list_help_tab() {
// todo
}
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 );
}
}
public static function enqueue_scripts( $hook_suffix ) {
if ( false !== strpos( $hook_suffix, 'activitypub' ) ) {
wp_enqueue_style( 'activitypub-admin-styles', plugins_url( 'assets/css/activitypub-admin.css', ACTIVITYPUB_PLUGIN_FILE ), array(), '1.0.0' );
wp_enqueue_script( 'activitypub-admin-styles', plugins_url( 'assets/js/activitypub-admin.js', ACTIVITYPUB_PLUGIN_FILE ), array( 'jquery' ), '1.0.0', false );
}
}
/**
* Hook into the edit_comment functionality
*
* * Disable the edit_comment capability for federated comments.
*
* @return void
*/
public static function edit_comment() {
// Disable the edit_comment capability for federated comments.
\add_filter(
'user_has_cap',
function ( $allcaps, $caps, $arg ) {
if ( 'edit_comment' !== $arg[0] ) {
return $allcaps;
}
if ( was_comment_received( $arg[2] ) ) {
return false;
}
return $allcaps;
},
1,
3
);
}
public static function comment_row_actions( $actions, $comment ) {
if ( was_comment_received( $comment ) ) {
unset( $actions['edit'] );
unset( $actions['quickedit'] );
}
return $actions;
}
/**
* Add a column "activitypub"
*
* This column shows if the user has the capability to use ActivityPub.
*
* @param array $columns The columns.
*
* @return array The columns extended by the activitypub.
*/
public static function manage_users_columns( $columns ) {
$columns['activitypub'] = __( 'ActivityPub', 'activitypub' );
return $columns;
}
/**
* Add "comment-type" and "protocol" as column in WP-Admin
*
* @param array $columns the list of column names
*/
public static function manage_comment_columns( $columns ) {
$columns['comment_type'] = esc_attr__( 'Comment-Type', 'activitypub' );
$columns['comment_protocol'] = esc_attr__( 'Protocol', 'activitypub' );
return $columns;
}
/**
* Add "comment-type" and "protocol" as column in WP-Admin
*
* @param array $column The column to implement
* @param int $comment_id The comment id
*/
public static function manage_comments_custom_column( $column, $comment_id ) {
if ( 'comment_type' === $column && ! defined( 'WEBMENTION_PLUGIN_DIR' ) ) {
echo esc_attr( ucfirst( get_comment_type( $comment_id ) ) );
} elseif ( 'comment_protocol' === $column ) {
$protocol = get_comment_meta( $comment_id, 'protocol', true );
if ( $protocol ) {
echo esc_attr( ucfirst( str_replace( 'activitypub', 'ActivityPub', $protocol ) ) );
} else {
esc_attr_e( 'Local', 'activitypub' );
}
}
}
/**
* Return the results for the activitypub column.
*
* @param string $output Custom column output. Default empty.
* @param string $column_name Column name.
* @param int $user_id ID of the currently-listed user.
*
* @return string The column contents.
*/
public static function manage_users_custom_column( $output, $column_name, $user_id ) {
if ( 'activitypub' !== $column_name ) {
return $output;
}
if ( \user_can( $user_id, 'activitypub' ) ) {
return '&#x2713;';
} else {
return '&#x2717;';
}
}
/**
* Add options to the Bulk dropdown on the users page
*
* @param array $actions The existing bulk options.
*
* @return array The extended bulk options.
*/
public static function user_bulk_options( $actions ) {
$actions['add_activitypub_cap'] = __( 'Enable for ActivityPub', 'activitypub' );
$actions['remove_activitypub_cap'] = __( 'Disable for ActivityPub', 'activitypub' );
return $actions;
}
/**
* Handle bulk activitypub requests
*
* * `add_activitypub_cap` - Add the activitypub capability to the selected users.
* * `remove_activitypub_cap` - Remove the activitypub capability from the selected users.
*
* @param string $sendback The URL to send the user back to.
* @param string $action The requested action.
* @param array $users The selected users.
*
* @return string The URL to send the user back to.
*/
public static function handle_bulk_request( $sendback, $action, $users ) {
if (
'remove_activitypub_cap' !== $action &&
'add_activitypub_cap' !== $action
) {
return $sendback;
}
foreach ( $users as $user_id ) {
$user = new \WP_User( $user_id );
if (
'add_activitypub_cap' === $action &&
user_can( $user_id, 'publish_posts' )
) {
$user->add_cap( 'activitypub' );
} elseif ( 'remove_activitypub_cap' === $action ) {
$user->remove_cap( 'activitypub' );
}
}
return $sendback;
}
}

View File

@ -0,0 +1,106 @@
<?php
/**
* Autoloader for Activitypub.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* An Autoloader that respects WordPress's filename standards.
*/
class Autoloader {
/**
* Namespace separator.
*/
const NS_SEPARATOR = '\\';
/**
* The prefix to compare classes against.
*
* @var string
* @access protected
*/
protected $prefix;
/**
* Length of the prefix string.
*
* @var int
* @access protected
*/
protected $prefix_length;
/**
* Path to the file to be loaded.
*
* @var string
* @access protected
*/
protected $path;
/**
* Constructor.
*
* @param string $prefix Namespace prefix all classes have in common.
* @param string $path Path to the files to be loaded.
*/
public function __construct( $prefix, $path ) {
$this->prefix = $prefix;
$this->prefix_length = \strlen( $prefix );
$this->path = \rtrim( $path . '/' );
}
/**
* Registers Autoloader's autoload function.
*
* @throws \Exception When autoload_function cannot be registered.
*
* @param string $prefix Namespace prefix all classes have in common.
* @param string $path Path to the files to be loaded.
*/
public static function register_path( $prefix, $path ) {
$loader = new self( $prefix, $path );
\spl_autoload_register( array( $loader, 'load' ) );
}
/**
* Loads a class if its namespace starts with `$this->prefix`.
*
* @param string $class_name The class to be loaded.
*/
public function load( $class_name ) {
if ( \strpos( $class_name, $this->prefix . self::NS_SEPARATOR ) !== 0 ) {
return;
}
// Strip prefix from the start (ala PSR-4).
$class_name = \substr( $class_name, $this->prefix_length + 1 );
$class_name = \strtolower( $class_name );
$dir = '';
$last_ns_pos = \strripos( $class_name, self::NS_SEPARATOR );
if ( false !== $last_ns_pos ) {
$namespace = \substr( $class_name, 0, $last_ns_pos );
$namespace = \str_replace( '_', '-', $namespace );
$class_name = \substr( $class_name, $last_ns_pos + 1 );
$dir = \str_replace( self::NS_SEPARATOR, DIRECTORY_SEPARATOR, $namespace ) . DIRECTORY_SEPARATOR;
}
$path = $this->path . $dir . 'class-' . \str_replace( '_', '-', $class_name ) . '.php';
if ( ! \file_exists( $path ) ) {
$path = $this->path . $dir . 'interface-' . \str_replace( '_', '-', $class_name ) . '.php';
}
if ( ! \file_exists( $path ) ) {
$path = $this->path . $dir . 'trait-' . \str_replace( '_', '-', $class_name ) . '.php';
}
if ( \file_exists( $path ) ) {
require_once $path;
}
}
}

View File

@ -1,36 +1,136 @@
<?php
/**
* Blocks file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Users as User_Collection;
use function Activitypub\object_to_uri;
use function Activitypub\is_user_type_disabled;
/**
* Block class.
*/
class Blocks {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
// this is already being called on the init hook, so just add it.
// 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' ) );
\add_action( 'wp_head', array( self::class, 'inject_activitypub_options' ), 11 );
\add_action( 'admin_print_scripts', array( self::class, 'inject_activitypub_options' ) );
\add_action( 'load-post-new.php', array( self::class, 'handle_in_reply_to_get_param' ) );
// Add editor plugin.
\add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
\add_action( 'init', array( self::class, 'register_postmeta' ), 11 );
\add_filter( 'activitypub_import_mastodon_post_data', array( self::class, 'filter_import_mastodon_post_data' ), 10, 2 );
}
public static function add_data() {
$context = is_admin() ? 'editor' : 'view';
$followers_handle = 'activitypub-followers-' . $context . '-script';
$follow_me_handle = 'activitypub-follow-me-' . $context . '-script';
/**
* Register post meta for content warnings.
*/
public static function register_postmeta() {
$ap_post_types = \get_post_types_by_support( 'activitypub' );
foreach ( $ap_post_types as $post_type ) {
\register_post_meta(
$post_type,
'activitypub_content_warning',
array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => function ( $warning ) {
if ( $warning ) {
return \sanitize_text_field( $warning );
}
return null;
},
)
);
\register_post_meta(
$post_type,
'activitypub_content_visibility',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => function ( $value ) {
$schema = array(
'type' => 'string',
'enum' => array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ),
'default' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
);
if ( is_wp_error( rest_validate_enum( $value, $schema, '' ) ) ) {
return $schema['default'];
}
return $value;
},
)
);
}
}
/**
* Enqueue the block editor assets.
*/
public static function enqueue_editor_assets() {
// Check for our supported post types.
$current_screen = \get_current_screen();
$ap_post_types = \get_post_types_by_support( 'activitypub' );
if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
return;
}
$asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php';
$plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
}
/**
* Enqueue the reply handle script if the in_reply_to GET param is set.
*/
public static function handle_in_reply_to_get_param() {
// Only load the script if the in_reply_to GET param is set, action happens there, not here.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['in_reply_to'] ) ) {
return;
}
$asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/reply-intent/plugin.asset.php';
$plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
}
/**
* Output ActivityPub options as a script tag.
*/
public static function inject_activitypub_options() {
$data = array(
'namespace' => ACTIVITYPUB_REST_NAMESPACE,
'enabled' => array(
'site' => ! is_user_type_disabled( 'blog' ),
'namespace' => ACTIVITYPUB_REST_NAMESPACE,
'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
'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( $followers_handle, $js, 'before' );
\wp_add_inline_script( $follow_me_handle, $js, 'before' );
printf(
"\n<script>var _activityPubOptions = %s;</script>",
wp_json_encode( $data )
);
}
/**
* Register the blocks.
*/
public static function register_blocks() {
\register_block_type_from_metadata(
ACTIVITYPUB_PLUGIN_DIR . '/build/followers',
@ -44,49 +144,135 @@ class Blocks {
'render_callback' => array( self::class, 'render_follow_me_block' ),
)
);
\register_block_type_from_metadata(
ACTIVITYPUB_PLUGIN_DIR . '/build/reply',
array(
'render_callback' => array( self::class, 'render_reply_block' ),
)
);
\register_block_type_from_metadata(
ACTIVITYPUB_PLUGIN_DIR . '/build/reactions',
array(
'render_callback' => array( self::class, 'render_post_reactions_block' ),
)
);
}
/**
* Render the post reactions block.
*
* @param array $attrs The block attributes.
*
* @return string The HTML to render.
*/
public static function render_post_reactions_block( $attrs ) {
if ( ! isset( $attrs['postId'] ) ) {
$attrs['postId'] = get_the_ID();
}
$wrapper_attributes = get_block_wrapper_attributes(
array(
'class' => 'activitypub-reactions-block',
'data-attrs' => wp_json_encode( $attrs ),
)
);
return sprintf(
'<div %s></div>',
$wrapper_attributes
);
}
/**
* Get the user ID from a user string.
*
* @param string $user_string The user string. Can be a user ID, 'site', or 'inherit'.
* @return int|null The user ID, or null if the 'inherit' string is not supported in this context.
*/
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;
// If the user string is 'site', return the Blog User ID.
if ( 'site' === $user_string ) {
return Actors::BLOG_USER_ID;
}
// The only other value should be 'inherit', which means to use the query context to determine the User.
if ( 'inherit' !== $user_string ) {
return null;
}
// For a homepage/front page, if the Blog User is active, use it.
if ( ( is_front_page() || is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
return Actors::BLOG_USER_ID;
}
// If we're in a loop, use the post author.
$author_id = get_the_author_meta( 'ID' );
if ( $author_id ) {
return $author_id;
}
// For other pages, the queried object will clue us in.
$queried_object = get_queried_object();
if ( ! $queried_object ) {
return null;
}
// If we're on a user archive page, use that user's ID.
if ( is_a( $queried_object, 'WP_User' ) ) {
return $queried_object->ID;
}
// For a single post, use the post author's ID.
if ( is_a( $queried_object, 'WP_Post' ) ) {
return get_the_author_meta( 'ID' );
}
// We won't properly account for some conditions, like tag archives.
return null;
}
/**
* Filter an array by a list of keys.
* @param array $array The array to filter.
*
* @param array $data The array to filter.
* @param array $keys The keys to keep.
* @return array The filtered array.
*/
protected static function filter_array_by_keys( $array, $keys ) {
return array_intersect_key( $array, array_flip( $keys ) );
protected static function filter_array_by_keys( $data, $keys ) {
return array_intersect_key( $data, array_flip( $keys ) );
}
/**
* Render the follow me block.
*
* @param array $attrs The block attributes.
* @return string The HTML to render.
*/
public static function render_follow_me_block( $attrs ) {
$user_id = self::get_user_id( $attrs['selectedUser'] );
$user = User_Collection::get_by_id( $user_id );
if ( ! is_wp_error( $user ) ) {
$attrs['profileData'] = self::filter_array_by_keys(
$user->to_array(),
array( 'icon', 'name', 'webfinger' )
);
$user = Actors::get_by_id( $user_id );
if ( is_wp_error( $user ) ) {
if ( 'inherit' === $attrs['selectedUser'] ) {
// If the user is 'inherit' and we couldn't determine the user, don't render anything.
return '<!-- Follow Me block: `inherit` mode does not display on this type of page -->';
} else {
// If the user is a specific ID and we couldn't find it, render an error message.
return '<!-- Follow Me block: user not found -->';
}
}
// add `@` prefix if it's missing
if ( '@' !== substr( $attrs['profileData']['webfinger'], 0, 1 ) ) {
$attrs['profileData']['webfinger'] = '@' . $attrs['profileData']['webfinger'];
}
$attrs['profileData'] = self::filter_array_by_keys(
$user->to_array(),
array( 'icon', 'name', 'webfinger' )
);
$wrapper_attributes = get_block_wrapper_attributes(
array(
'aria-label' => __( 'Follow me on the Fediverse', 'activitypub' ),
'class' => 'activitypub-follow-me-block-wrapper',
'data-attrs' => wp_json_encode( $attrs ),
)
@ -95,12 +281,28 @@ class Blocks {
return '<div ' . $wrapper_attributes . '></div>';
}
/**
* Render the follower block.
*
* @param array $attrs The block attributes.
*
* @return string The HTML to render.
*/
public static function render_follower_block( $attrs ) {
$followee_user_id = self::get_user_id( $attrs['selectedUser'] );
$per_page = absint( $attrs['per_page'] );
if ( is_null( $followee_user_id ) ) {
return '<!-- Followers block: `inherit` mode does not display on this type of page -->';
}
$user = Actors::get_by_id( $followee_user_id );
if ( is_wp_error( $user ) ) {
return '<!-- Followers block: `' . $followee_user_id . '` not an active ActivityPub user -->';
}
$per_page = absint( $attrs['per_page'] );
$follower_data = Followers::get_followers_with_count( $followee_user_id, $per_page );
$attrs['followerData']['total'] = $follower_data['total'];
$attrs['followerData']['total'] = $follower_data['total'];
$attrs['followerData']['followers'] = array_map(
function ( $follower ) {
return self::filter_array_by_keys(
@ -110,7 +312,7 @@ class Blocks {
},
$follower_data['followers']
);
$wrapper_attributes = get_block_wrapper_attributes(
$wrapper_attributes = get_block_wrapper_attributes(
array(
'aria-label' => __( 'Fediverse Followers', 'activitypub' ),
'class' => 'activitypub-follower-block',
@ -131,9 +333,65 @@ class Blocks {
return $html;
}
/**
* Render the reply block.
*
* @param array $attrs The block attributes.
*
* @return string The HTML to render.
*/
public static function render_reply_block( $attrs ) {
// Return early if no URL is provided.
if ( empty( $attrs['url'] ) ) {
return null;
}
$show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
$wrapper_attrs = get_block_wrapper_attributes(
array(
'aria-label' => __( 'Reply', 'activitypub' ),
'class' => 'activitypub-reply-block',
'data-in-reply-to' => $attrs['url'],
)
);
$html = '<div ' . $wrapper_attrs . '>';
// Try to get and append the embed if requested.
if ( $show_embed ) {
$embed = wp_oembed_get( $attrs['url'] );
if ( $embed ) {
$html .= $embed;
}
}
// Only show the link if we're not showing the embed.
if ( ! $show_embed ) {
$html .= sprintf(
'<p><a title="%2$s" aria-label="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
esc_url( $attrs['url'] ),
esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
// translators: %s is the URL of the post being replied to.
sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
);
}
$html .= '</div>';
return $html;
}
/**
* Render a follower.
*
* @param \Activitypub\Model\Follower $follower The follower to render.
*
* @return string The HTML to render.
*/
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 =
$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">
@ -156,4 +414,33 @@ class Blocks {
$external_svg
);
}
/**
* Converts content to blocks before saving to the database.
*
* @param array $data The post data to be inserted.
* @param object $post The Mastodon Create activity.
*
* @return array
*/
public static function filter_import_mastodon_post_data( $data, $post ) {
// Convert paragraphs to blocks.
\preg_match_all( '#<p>.*?</p>#is', $data['post_content'], $matches );
$blocks = \array_map(
function ( $paragraph ) {
return '<!-- wp:paragraph -->' . PHP_EOL . $paragraph . PHP_EOL . '<!-- /wp:paragraph -->' . PHP_EOL;
},
$matches[0] ?? array()
);
$data['post_content'] = \rtrim( \implode( PHP_EOL, $blocks ), PHP_EOL );
// Add reply block if it's a reply.
if ( null !== $post->object->inReplyTo ) {
$reply_block = \sprintf( '<!-- wp:activitypub/reply {"url":"%1$s","embedPost":true} /-->' . PHP_EOL, \esc_url( $post->object->inReplyTo ) );
$data['post_content'] = $reply_block . $data['post_content'];
}
return $data;
}
}

View File

@ -0,0 +1,230 @@
<?php
/**
* WP-CLI file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Collection\Outbox;
/**
* WP-CLI commands.
*
* @package Activitypub
*/
class Cli extends \WP_CLI_Command {
/**
* Remove the entire blog from the Fediverse.
*
* ## EXAMPLES
*
* $ wp activitypub self-destruct
*
* @param array|null $args The arguments.
* @param array|null $assoc_args The associative arguments.
*
* @return void
*/
public function self_destruct( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
\WP_CLI::warning( 'Self-Destructing is not implemented yet.' );
}
/**
* Delete or Update a Post, Page, Custom Post Type or Attachment.
*
* ## OPTIONS
*
* <action>
* : The action to perform. Either `delete` or `update`.
* ---
* options:
* - delete
* - update
* ---
*
* <id>
* : The id of the Post, Page, Custom Post Type or Attachment.
*
* ## EXAMPLES
*
* $ wp activitypub post delete 1
*
* @synopsis <action> <id>
*
* @param array $args The arguments.
*/
public function post( $args ) {
$post = get_post( $args[1] );
if ( ! $post ) {
\WP_CLI::error( 'Post not found.' );
}
switch ( $args[0] ) {
case 'delete':
\WP_CLI::confirm( 'Do you really want to delete the (Custom) Post with the ID: ' . $args[1] );
add_to_outbox( $post, 'Delete', $post->post_author );
\WP_CLI::success( '"Delete" activity is queued.' );
break;
case 'update':
add_to_outbox( $post, 'Update', $post->post_author );
\WP_CLI::success( '"Update" activity is queued.' );
break;
default:
\WP_CLI::error( 'Unknown action.' );
}
}
/**
* Delete or Update a Comment.
*
* ## OPTIONS
*
* <action>
* : The action to perform. Either `delete` or `update`.
* ---
* options:
* - delete
* - update
* ---
*
* <id>
* : The id of the Comment.
*
* ## EXAMPLES
*
* $ wp activitypub comment delete 1
*
* @synopsis <action> <id>
*
* @param array $args The arguments.
*/
public function comment( $args ) {
$comment = get_comment( $args[1] );
if ( ! $comment ) {
\WP_CLI::error( 'Comment not found.' );
}
if ( was_comment_received( $comment ) ) {
\WP_CLI::error( 'This comment was received via ActivityPub and cannot be deleted or updated.' );
}
switch ( $args[0] ) {
case 'delete':
\WP_CLI::confirm( 'Do you really want to delete the Comment with the ID: ' . $args[1] );
add_to_outbox( $comment, 'Delete', $comment->user_id );
\WP_CLI::success( '"Delete" activity is queued.' );
break;
case 'update':
add_to_outbox( $comment, 'Update', $comment->user_id );
\WP_CLI::success( '"Update" activity is queued.' );
break;
default:
\WP_CLI::error( 'Unknown action.' );
}
}
/**
* Undo an activity that was sent to the Fediverse.
*
* ## OPTIONS
*
* <outbox_item_id>
* The ID or URL of the outbox item to undo.
*
* ## EXAMPLES
*
* $ wp activitypub undo 123
* $ wp activitypub undo "https://example.com/?post_type=ap_outbox&p=123"
*
* @synopsis <outbox_item_id>
*
* @param array $args The arguments.
*/
public function undo( $args ) {
$outbox_item_id = $args[0];
if ( ! is_numeric( $outbox_item_id ) ) {
$outbox_item_id = url_to_postid( $outbox_item_id );
}
$outbox_item_id = get_post( $outbox_item_id );
if ( ! $outbox_item_id ) {
\WP_CLI::error( 'Activity not found.' );
}
$undo_id = Outbox::undo( $outbox_item_id );
if ( ! $undo_id ) {
\WP_CLI::error( 'Failed to undo activity.' );
}
\WP_CLI::success( 'Undo activity scheduled.' );
}
/**
* Re-Schedule an activity that was sent to the Fediverse before.
*
* ## OPTIONS
*
* <outbox_item_id>
* The ID or URL of the outbox item to reschedule.
*
* ## EXAMPLES
*
* $ wp activitypub reschedule 123
* $ wp activitypub reschedule "https://example.com/?post_type=ap_outbox&p=123"
*
* @synopsis <outbox_item_id>
*
* @param array $args The arguments.
*/
public function reschedule( $args ) {
$outbox_item_id = $args[0];
if ( ! is_numeric( $outbox_item_id ) ) {
$outbox_item_id = url_to_postid( $outbox_item_id );
}
$outbox_item_id = get_post( $outbox_item_id );
if ( ! $outbox_item_id ) {
\WP_CLI::error( 'Activity not found.' );
}
Outbox::reschedule( $outbox_item_id );
\WP_CLI::success( 'Rescheduled activity.' );
}
/**
* Move the blog to a new URL.
*
* ## OPTIONS
*
* <from>
* The current URL of the blog.
*
* <to>
* The new URL of the blog.
*
* ## EXAMPLES
*
* $ wp activitypub move https://example.com/ https://newsite.com/
*
* @synopsis <from> <to>
*
* @param array $args The arguments.
*/
public function move( $args ) {
$from = $args[0];
$to = $args[1];
$outbox_item_id = Move::account( $from, $to );
if ( is_wp_error( $outbox_item_id ) ) {
\WP_CLI::error( $outbox_item_id->get_error_message() );
} else {
\WP_CLI::success( 'Move Scheduled.' );
}
}
}

View File

@ -1,28 +1,38 @@
<?php
/**
* ActivityPub Comment Class
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use WP_Comment_Query;
use function Activitypub\is_user_disabled;
use function Activitypub\is_single_user;
/**
* ActivityPub Comment Class
* ActivityPub Comment Class.
*
* This class is a helper/utils class that provides a collection of static
* methods that are used to handle comments.
*/
class Comment {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_comment_types();
\add_filter( 'comment_reply_link', array( self::class, 'comment_reply_link' ), 10, 3 );
\add_filter( 'comment_class', array( self::class, 'comment_class' ), 10, 3 );
\add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 3 );
\add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 2 );
\add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue_scripts' ) );
\add_action( 'pre_get_comments', array( static::class, 'comment_query' ) );
\add_filter( 'pre_comment_approved', array( static::class, 'pre_comment_approved' ), 10, 2 );
\add_filter( 'get_avatar_comment_types', array( static::class, 'get_avatar_comment_types' ), 99 );
\add_action( 'update_option_activitypub_allow_likes', array( self::class, 'maybe_update_comment_counts' ), 10, 2 );
\add_action( 'update_option_activitypub_allow_reposts', array( self::class, 'maybe_update_comment_counts' ), 10, 2 );
\add_filter( 'pre_wp_update_comment_count_now', array( static::class, 'pre_wp_update_comment_count_now' ), 10, 3 );
}
/**
@ -31,16 +41,15 @@ class Comment {
* We don't want to show the comment reply link for federated comments
* if the user is disabled for federation.
*
* @param string $link The HTML markup for the comment reply link.
* @param array $args An array of arguments overriding the defaults.
* @param WP_Comment $comment The object of the comment being replied.
* @param string $link The HTML markup for the comment reply link.
* @param array $args An array of arguments overriding the defaults.
* @param \WP_Comment $comment The object of the comment being replied.
*
* @return string The filtered HTML markup for the comment reply link.
*/
public static function comment_reply_link( $link, $args, $comment ) {
if ( self::are_comments_allowed( $comment ) ) {
$user_id = get_current_user_id();
if ( $user_id && self::was_received( $comment ) && \user_can( $user_id, 'activitypub' ) ) {
if ( \current_user_can( 'activitypub' ) && self::was_received( $comment ) ) {
return self::create_fediverse_reply_link( $link, $args );
}
@ -49,19 +58,27 @@ class Comment {
$attrs = array(
'selectedComment' => self::generate_id( $comment ),
'commentId' => $comment->comment_ID,
'commentId' => $comment->comment_ID,
);
$div = sprintf(
'<div class="activitypub-remote-reply" data-attrs="%s"></div>',
'<div class="reply activitypub-remote-reply" data-attrs="%s"></div>',
esc_attr( wp_json_encode( $attrs ) )
);
/**
* Filters the HTML markup for the ActivityPub remote comment reply container.
*
* @param string $div The HTML markup for the remote reply container. Default is a div
* with class 'activitypub-remote-reply' and data attributes for
* the selected comment ID and internal comment ID.
*/
return apply_filters( 'activitypub_comment_reply_link', $div );
}
/**
* Create a link to reply to a federated comment.
*
* This function adds a title attribute to the reply link to inform the user
* that the comment was received from the fediverse and the reply will be sent
* to the original author.
@ -73,7 +90,7 @@ class Comment {
*/
private static function create_fediverse_reply_link( $link, $args ) {
$str_to_replace = sprintf( '>%s<', $args['reply_text'] );
$replace_with = sprintf(
$replace_with = sprintf(
' title="%s">%s<',
esc_attr__( 'This comment was received from the fediverse and your reply will be sent to the original author', 'activitypub' ),
esc_html__( 'Reply with federation', 'activitypub' )
@ -104,17 +121,11 @@ class Comment {
}
if ( is_single_user() && \user_can( $current_user, 'publish_posts' ) ) {
// On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user
$current_user = Users::BLOG_USER_ID;
// On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user.
$current_user = Actors::BLOG_USER_ID;
}
$is_user_disabled = is_user_disabled( $current_user );
if ( $is_user_disabled ) {
return false;
}
return true;
return user_can_activitypub( $current_user );
}
/**
@ -200,7 +211,7 @@ class Comment {
* @return boolean True if the comment should be federated, false otherwise.
*/
public static function should_be_federated( $comment ) {
// we should not federate federated comments
// We should not federate federated comments.
if ( self::was_received( $comment ) ) {
return false;
}
@ -208,29 +219,27 @@ class Comment {
$comment = \get_comment( $comment );
$user_id = $comment->user_id;
// comments without user can't be federated
// Comments without user can't be federated.
if ( ! $user_id ) {
return false;
}
if ( is_single_user() && \user_can( $user_id, 'publish_posts' ) ) {
// On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user
$user_id = Users::BLOG_USER_ID;
if ( is_single_user() && \user_can( $user_id, 'activitypub' ) ) {
// On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user.
$user_id = Actors::BLOG_USER_ID;
}
$is_user_disabled = is_user_disabled( $user_id );
// user is disabled for federation
if ( $is_user_disabled ) {
// User is not allowed to federate comments.
if ( ! user_can_activitypub( $user_id ) ) {
return false;
}
// it is a comment to the post and can be federated
// It is a comment to the post and can be federated.
if ( empty( $comment->comment_parent ) ) {
return true;
}
// check if parent comment is federated
// Check if parent comment is federated.
$parent_comment = \get_comment( $comment->comment_parent );
return ! self::is_local( $parent_comment );
@ -241,13 +250,15 @@ class Comment {
*
* @param string $id ActivityPub object ID (usually a URL) to check.
*
* @return int|boolean Comment ID, or false on failure.
* @return \WP_Comment|false Comment object, or false on failure.
*/
public static function object_id_to_comment( $id ) {
$comment_query = new WP_Comment_Query(
array(
'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'orderby' => 'comment_date',
'order' => 'DESC',
)
);
@ -255,28 +266,24 @@ class Comment {
return false;
}
if ( count( $comment_query->comments ) > 1 ) {
return false;
}
return $comment_query->comments[0];
}
/**
* Verify if URL is a local comment, or if it is a previously received
* remote comment (For threading comments locally)
* remote comment (For threading comments locally).
*
* @param string $url The URL to check.
*
* @return int comment_ID or null if not found
* @return string|null Comment ID or null if not found.
*/
public static function url_to_commentid( $url ) {
if ( ! $url || ! filter_var( $url, \FILTER_VALIDATE_URL ) ) {
return null;
}
// check for local comment
if ( \wp_parse_url( \site_url(), \PHP_URL_HOST ) === \wp_parse_url( $url, \PHP_URL_HOST ) ) {
// Check for local comment.
if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) === \wp_parse_url( $url, \PHP_URL_HOST ) ) {
$query = \wp_parse_url( $url, \PHP_URL_QUERY );
if ( $query ) {
@ -327,7 +334,7 @@ class Comment {
* @return string[] An array of classes.
*/
public static function comment_class( $classes, $css_class, $comment_id ) {
// check if ActivityPub comment
// Check if ActivityPub comment.
if ( 'activitypub' === get_comment_meta( $comment_id, 'protocol', true ) ) {
$classes[] = 'activitypub-comment';
}
@ -335,11 +342,51 @@ class Comment {
return $classes;
}
/**
* Gets the public comment id via the WordPress comments meta.
*
* @param int $wp_comment_id The internal WordPress comment ID.
* @param bool $fallback Whether the code should fall back to `source_url` if `source_id` is not set.
*
* @return string|null The ActivityPub id/url of the comment.
*/
public static function get_source_id( $wp_comment_id, $fallback = true ) {
$comment_meta = \get_comment_meta( $wp_comment_id );
if ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] ) && $fallback ) {
return $comment_meta['source_url'][0];
}
return null;
}
/**
* Gets the public comment url via the WordPress comments meta.
*
* @param int $wp_comment_id The internal WordPress comment ID.
* @param bool $fallback Whether the code should fall back to `source_id` if `source_url` is not set.
*
* @return string|null The ActivityPub id/url of the comment.
*/
public static function get_source_url( $wp_comment_id, $fallback = true ) {
$comment_meta = \get_comment_meta( $wp_comment_id );
if ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
} elseif ( ! empty( $comment_meta['source_id'][0] ) && $fallback ) {
return $comment_meta['source_id'][0];
}
return null;
}
/**
* Link remote comments to source url.
*
* @param string $comment_link
* @param object|WP_Comment $comment
* @param string $comment_link The comment link.
* @param object|\WP_Comment $comment The comment object.
*
* @return string $url
*/
@ -348,43 +395,31 @@ class Comment {
return $comment_link;
}
$comment_meta = \get_comment_meta( $comment->comment_ID );
$public_comment_link = self::get_source_url( $comment->comment_ID );
if ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
} elseif ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
}
return $comment_link;
return $public_comment_link ?? $comment_link;
}
/**
* Generates an ActivityPub URI for a comment
*
* @param WP_Comment|int $comment A comment object or comment ID
* @param \WP_Comment|int $comment A comment object or comment ID.
*
* @return string ActivityPub URI for comment
*/
public static function generate_id( $comment ) {
$comment = \get_comment( $comment );
$comment_meta = \get_comment_meta( $comment->comment_ID );
$comment = \get_comment( $comment );
// show external comment ID if it exists
if ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
// Show external comment ID if it exists.
$public_comment_link = self::get_source_id( $comment->comment_ID );
if ( $public_comment_link ) {
return $public_comment_link;
}
// generate URI based on comment ID
return \add_query_arg(
array(
'c' => $comment->comment_ID,
),
\trailingslashit( site_url() )
);
// Generate URI based on comment ID.
return \add_query_arg( 'c', $comment->comment_ID, \trailingslashit( \home_url() ) );
}
/**
@ -397,7 +432,8 @@ class Comment {
private static function post_has_remote_comments( $post_id ) {
$comments = \get_comments(
array(
'post_id' => $post_id,
'post_id' => $post_id,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
'relation' => 'AND',
array(
@ -421,28 +457,29 @@ class Comment {
*/
public static function enqueue_scripts() {
if ( ! \is_singular() || \is_user_logged_in() ) {
// only on single pages, only for logged out users
// Only on single pages, only for logged-out users.
return;
}
if ( ! \post_type_supports( \get_post_type(), 'activitypub' ) ) {
// post type does not support ActivityPub
// Post type does not support ActivityPub.
return;
}
if ( ! \comments_open() || ! \get_comments_number() ) {
// no comments, no need to load the script
// No comments, no need to load the script.
return;
}
if ( ! self::post_has_remote_comments( \get_the_ID() ) ) {
// no remote comments, no need to load the script
// No remote comments, no need to load the script.
return;
}
$handle = 'activitypub-remote-reply';
$data = array(
'namespace' => ACTIVITYPUB_REST_NAMESPACE,
'namespace' => ACTIVITYPUB_REST_NAMESPACE,
'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
);
$js = sprintf( 'var _activityPubOptions = %s;', wp_json_encode( $data ) );
$asset_file = ACTIVITYPUB_PLUGIN_DIR . 'build/remote-reply/index.asset.php';
@ -458,13 +495,323 @@ class Comment {
true
);
\wp_add_inline_script( $handle, $js, 'before' );
\wp_set_script_translations( $handle, 'activitypub' );
\wp_enqueue_style(
$handle,
\plugins_url( 'build/remote-reply/style-index.css', __DIR__ ),
[ 'wp-components' ],
array( 'wp-components' ),
$assets['version']
);
}
}
/**
* Get the comment type by activity type.
*
* @param string $activity_type The activity type.
*
* @return array|null The comment type.
*/
public static function get_comment_type_by_activity_type( $activity_type ) {
$activity_type = \strtolower( $activity_type );
$activity_type = \sanitize_key( $activity_type );
$comment_types = self::get_comment_types();
foreach ( $comment_types as $comment_type ) {
if ( in_array( $activity_type, $comment_type['activity_types'], true ) ) {
return $comment_type;
}
}
return null;
}
/**
* Return the registered custom comment types.
*
* @return array The registered custom comment types
*/
public static function get_comment_types() {
global $activitypub_comment_types;
return $activitypub_comment_types;
}
/**
* Is this a registered comment type.
*
* @param string $slug The slug of the type.
*
* @return boolean True if registered.
*/
public static function is_registered_comment_type( $slug ) {
$slug = \strtolower( $slug );
$slug = \sanitize_key( $slug );
$comment_types = self::get_comment_types();
return isset( $comment_types[ $slug ] );
}
/**
* Return the registered custom comment type slugs.
*
* @return array The registered custom comment type slugs.
*/
public static function get_comment_type_slugs() {
return array_keys( self::get_comment_types() );
}
/**
* Return the registered custom comment type slugs.
*
* @deprecated 4.5.0 Use get_comment_type_slugs instead.
*
* @return array The registered custom comment type slugs.
*/
public static function get_comment_type_names() {
_deprecated_function( __METHOD__, '4.5.0', 'get_comment_type_slugs' );
return self::get_comment_type_slugs();
}
/**
* Get the custom comment type.
*
* Check if the type is registered, if not, check if it is a custom type.
*
* It looks for the array key in the registered types and returns the array.
* If it is not found, it looks for the type in the custom types and returns the array.
*
* @param string $type The comment type.
*
* @return array The comment type.
*/
public static function get_comment_type( $type ) {
$type = strtolower( $type );
$type = sanitize_key( $type );
$comment_types = self::get_comment_types();
$type_array = array();
// Check array keys.
if ( in_array( $type, array_keys( $comment_types ), true ) ) {
$type_array = $comment_types[ $type ];
}
/**
* Filter the comment type.
*
* @param array $type_array The comment type.
*/
return apply_filters( "activitypub_comment_type_{$type}", $type_array );
}
/**
* Get a comment type attribute.
*
* @param string $type The comment type.
* @param string $attr The attribute to get.
*
* @return mixed The value of the attribute.
*/
public static function get_comment_type_attr( $type, $attr ) {
$type_array = self::get_comment_type( $type );
if ( $type_array && isset( $type_array[ $attr ] ) ) {
$value = $type_array[ $attr ];
} else {
$value = '';
}
/**
* Filter the comment type attribute.
*
* @param mixed $value The value of the attribute.
* @param string $type The comment type.
*/
return apply_filters( "activitypub_comment_type_{$attr}", $value, $type );
}
/**
* Register the comment types used by the ActivityPub plugin.
*/
public static function register_comment_types() {
register_comment_type(
'repost',
array(
'label' => __( 'Reposts', 'activitypub' ),
'singular' => __( 'Repost', 'activitypub' ),
'description' => __( 'A repost on the indieweb is a post that is purely a 100% re-publication of another (typically someone else\'s) post.', 'activitypub' ),
'icon' => '♻️',
'class' => 'p-repost',
'type' => 'repost',
'collection' => 'reposts',
'activity_types' => array( 'announce' ),
'excerpt' => html_entity_decode( \__( '&hellip; reposted this!', 'activitypub' ) ),
/* translators: %d: Number of reposts */
'count_single' => _x( '%d repost', 'number of reposts', 'activitypub' ),
/* translators: %d: Number of reposts */
'count_plural' => _x( '%d reposts', 'number of reposts', 'activitypub' ),
)
);
register_comment_type(
'like',
array(
'label' => __( 'Likes', 'activitypub' ),
'singular' => __( 'Like', 'activitypub' ),
'description' => __( 'A like is a popular webaction button and in some cases post type on various silos such as Facebook and Instagram.', 'activitypub' ),
'icon' => '👍',
'class' => 'p-like',
'type' => 'like',
'collection' => 'likes',
'activity_types' => array( 'like' ),
'excerpt' => html_entity_decode( \__( '&hellip; liked this!', 'activitypub' ) ),
/* translators: %d: Number of likes */
'count_single' => _x( '%d like', 'number of likes', 'activitypub' ),
/* translators: %d: Number of likes */
'count_plural' => _x( '%d likes', 'number of likes', 'activitypub' ),
)
);
}
/**
* Show avatars on Activities if set.
*
* @param array $types List of avatar enabled comment types.
*
* @return array show avatars on Activities
*/
public static function get_avatar_comment_types( $types ) {
$comment_types = self::get_comment_type_slugs();
$types = array_merge( $types, $comment_types );
return array_unique( $types );
}
/**
* Excludes likes and reposts from comment queries.
*
* @author Jan Boddez
*
* @see https://github.com/janboddez/indieblocks/blob/a2d59de358031056a649ee47a1332ce9e39d4ce2/includes/functions.php#L423-L432
*
* @param WP_Comment_Query $query Comment count.
*/
public static function comment_query( $query ) {
if ( ! $query instanceof WP_Comment_Query ) {
return;
}
// Do not exclude likes and reposts on ActivityPub requests.
if ( defined( 'ACTIVITYPUB_REQUEST' ) && ACTIVITYPUB_REQUEST ) {
return;
}
// Do not exclude likes and reposts on REST requests.
if ( \wp_is_serving_rest_request() ) {
return;
}
// Do not exclude likes and reposts on admin pages or on non-singular pages.
if ( is_admin() || ! is_singular() ) {
return;
}
// Do not exclude likes and reposts if the query is for comments.
if ( ! empty( $query->query_vars['type__in'] ) || ! empty( $query->query_vars['type'] ) ) {
return;
}
// Exclude likes and reposts by the ActivityPub plugin.
$query->query_vars['type__not_in'] = self::get_comment_type_slugs();
}
/**
* Filter the comment status before it is set.
*
* @param string $approved The approved comment status.
* @param array $commentdata The comment data.
*
* @return boolean `true` if the comment is approved, `false` otherwise.
*/
public static function pre_comment_approved( $approved, $commentdata ) {
if ( $approved || \is_wp_error( $approved ) ) {
return $approved;
}
if ( '1' !== \get_option( 'comment_previously_approved' ) ) {
return $approved;
}
if (
empty( $commentdata['comment_meta']['protocol'] ) ||
'activitypub' !== $commentdata['comment_meta']['protocol']
) {
return $approved;
}
global $wpdb;
$author = $commentdata['comment_author'];
$author_url = $commentdata['comment_author_url'];
// phpcs:ignore
$ok_to_comment = $wpdb->get_var( $wpdb->prepare( "SELECT comment_approved FROM $wpdb->comments WHERE comment_author = %s AND comment_author_url = %s and comment_approved = '1' LIMIT 1", $author, $author_url ) );
if ( 1 === (int) $ok_to_comment ) {
return 1;
}
return $approved;
}
/**
* Update comment counts when interaction settings are disabled.
*
* Triggers a recount when likes or reposts are disabled to ensure accurate comment counts.
*
* @param mixed $old_value The old option value.
* @param mixed $value The new option value.
*/
public static function maybe_update_comment_counts( $old_value, $value ) {
if ( '1' === $old_value && '1' !== $value ) {
Migration::update_comment_counts();
}
}
/**
* Filters the comment count to exclude ActivityPub comment types.
*
* @param int|null $new_count The new comment count. Default null.
* @param int $old_count The old comment count.
* @param int $post_id Post ID.
*
* @return int|null The updated comment count, or null to use the default query.
*/
public static function pre_wp_update_comment_count_now( $new_count, $old_count, $post_id ) {
if ( null === $new_count ) {
$excluded_types = array_filter( self::get_comment_type_slugs(), array( self::class, 'is_comment_type_enabled' ) );
if ( ! empty( $excluded_types ) ) {
global $wpdb;
// phpcs:ignore WordPress.DB
$new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ('" . implode( "','", $excluded_types ) . "')", $post_id ) );
}
}
return $new_count;
}
/**
* Check if a comment type is enabled.
*
* @param string $comment_type The comment type.
* @return bool True if the comment type is enabled.
*/
public static function is_comment_type_enabled( $comment_type ) {
return '1' === get_option( "activitypub_allow_{$comment_type}s", '1' );
}
}

View File

@ -1,37 +1,79 @@
<?php
/**
* Debug Class.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_DEBUG;
use WP_DEBUG_LOG;
/**
* ActivityPub Debug Class
* ActivityPub Debug Class.
*
* @author Matthias Pfefferle
*/
class Debug {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
if ( WP_DEBUG_LOG ) {
\add_action( 'activitypub_safe_remote_post_response', array( self::class, 'log_remote_post_responses' ), 10, 4 );
if ( \WP_DEBUG && \WP_DEBUG_LOG ) {
\add_action( 'activitypub_safe_remote_post_response', array( self::class, 'log_remote_post_responses' ), 10, 2 );
\add_action( 'activitypub_inbox', array( self::class, 'log_inbox' ), 10, 3 );
\add_action( 'activitypub_sent_to_inbox', array( self::class, 'log_sent_to_inbox' ), 10, 2 );
}
}
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
public static function log_remote_post_responses( $response, $url, $body, $user_id ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, WordPress.PHP.DevelopmentFunctions.error_log_print_r
\error_log( "Request to: {$url} with response: " . \print_r( $response, true ) );
/**
* Log the responses of remote post requests.
*
* @param array $response The response from the remote server.
* @param string $url The URL of the remote server.
*/
public static function log_remote_post_responses( $response, $url ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
\error_log( "[OUTBOX] Request to: {$url} with Response: " . \print_r( $response, true ) );
}
/**
* Log the inbox requests.
*
* @param array $data The Activity array.
* @param int $user_id The ID of the local blog user.
* @param string $type The type of the request.
*/
public static function log_inbox( $data, $user_id, $type ) {
$type = strtolower( $type );
if ( 'delete' !== $type ) {
$actor = $data['actor'] ?? '';
$url = object_to_uri( $actor );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
\error_log( "[INBOX] Request From: {$url} with Activity: " . \print_r( $data, true ) );
}
}
/**
* Log the sent to follower action.
*
* @param array $result The result of the remote post request.
* @param string $inbox The inbox URL.
*/
public static function log_sent_to_inbox( $result, $inbox ) {
if ( \is_wp_error( $result ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
\error_log( "[DISPATCHER] Failed Request to: {$inbox} with Result: " . \print_r( $result, true ) );
}
}
/**
* Write a log entry.
*
* @param mixed $log The log entry.
*/
public static function write_log( $log ) {
if ( \is_array( $log ) || \is_object( $log ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, WordPress.PHP.DevelopmentFunctions.error_log_print_r
\error_log( \print_r( $log, true ) );
} else {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
\error_log( $log );
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
\error_log( \print_r( $log, true ) );
}
}

View File

@ -0,0 +1,466 @@
<?php
/**
* ActivityPub Dispatcher Class.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Outbox;
/**
* ActivityPub Dispatcher Class.
*
* @author Matthias Pfefferle
*
* @see https://www.w3.org/TR/activitypub/
*/
class Dispatcher {
/**
* Batch size.
*
* @var int
*/
public static $batch_size = ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE;
/**
* Callback for the async batch processing.
*
* @var array
*/
public static $callback = array( self::class, 'send_to_followers' );
/**
* Error codes that qualify for a retry.
*
* @see https://github.com/tfredrich/RestApiTutorial.com/blob/fd08b0f67f07450521d143b123cd6e1846cb2e3b/content/advanced/responses/retries.md
* @var int[]
*/
public static $retry_error_codes = array( 408, 429, 500, 502, 503, 504 );
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_process_outbox', array( self::class, 'process_outbox' ) );
// Default filters to add Inboxes to sent to.
\add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_by_mentioned_actors' ), 10, 3 );
\add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_of_replied_urls' ), 10, 3 );
\add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_of_relays' ), 10, 3 );
// Fallback for `activitypub_send_to_inboxes` filter.
\add_filter(
'activitypub_additional_inboxes',
function ( $inboxes, $actor_id, $activity ) {
/**
* Filters the list of interactees inboxes to send the Activity to.
*
* @param array $inboxes The list of inboxes to send to.
* @param int $actor_id The actor ID.
* @param Activity $activity The ActivityPub Activity.
*
* @deprecated 5.2.0 Use `activitypub_additional_inboxes` instead.
* @deprecated 5.4.0 Use `activitypub_additional_inboxes` instead.
*/
$inboxes = \apply_filters_deprecated( 'activitypub_send_to_inboxes', array( $inboxes, $actor_id, $activity ), '5.2.0', 'activitypub_additional_inboxes' );
$inboxes = \apply_filters_deprecated( 'activitypub_interactees_inboxes', array( $inboxes, $actor_id, $activity ), '5.4.0', 'activitypub_additional_inboxes' );
return $inboxes;
},
10,
3
);
}
/**
* Process the outbox.
*
* @param int $id The outbox ID.
*/
public static function process_outbox( $id ) {
$outbox_item = \get_post( $id );
// If the activity is not a post, return.
if ( ! $outbox_item ) {
return;
}
$actor = Outbox::get_actor( $outbox_item );
if ( \is_wp_error( $actor ) ) {
// If the actor is not found, publish the post and don't try again.
\wp_publish_post( $outbox_item );
return;
}
$activity = Outbox::get_activity( $outbox_item );
// Send to mentioned and replied-to users. Everyone other than followers.
self::send_to_additional_inboxes( $activity, $actor->get__id(), $outbox_item );
if ( self::should_send_to_followers( $activity, $actor, $outbox_item ) ) {
Scheduler::async_batch(
self::$callback,
$outbox_item->ID,
self::$batch_size,
\get_post_meta( $outbox_item->ID, '_activitypub_outbox_offset', true ) ?: 0 // phpcs:ignore
);
} else {
// No followers to process for this update. We're done.
\wp_publish_post( $outbox_item );
\delete_post_meta( $outbox_item->ID, '_activitypub_outbox_offset' );
}
}
/**
* Asynchronously runs batch processing routines.
*
* @param int $outbox_item_id The Outbox item ID.
* @param int $batch_size Optional. The batch size. Default ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE.
* @param int $offset Optional. The offset. Default 0.
*
* @return array|void The next batch of followers to process, or void if done.
*/
public static function send_to_followers( $outbox_item_id, $batch_size = ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE, $offset = 0 ) {
$json = Outbox::get_activity( $outbox_item_id )->to_json();
$actor = Outbox::get_actor( \get_post( $outbox_item_id ) );
$inboxes = Followers::get_inboxes_for_activity( $json, $actor->get__id(), $batch_size, $offset );
$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
// Retry failed inboxes.
if ( ! empty( $retries ) ) {
self::schedule_retry( $retries, $outbox_item_id );
}
if ( is_countable( $inboxes ) && count( $inboxes ) < $batch_size ) {
\delete_post_meta( $outbox_item_id, '_activitypub_outbox_offset' );
/**
* Fires when the followers are complete.
*
* @param array $inboxes The inboxes.
* @param string $json The ActivityPub Activity JSON
* @param int $actor_id The actor ID.
* @param int $outbox_item_id The Outbox item ID.
* @param int $batch_size The batch size.
* @param int $offset The offset.
*/
\do_action( 'activitypub_outbox_processing_complete', $inboxes, $json, $actor->get__id(), $outbox_item_id, $batch_size, $offset );
// No more followers to process for this update.
\wp_publish_post( $outbox_item_id );
} else {
\update_post_meta( $outbox_item_id, '_activitypub_outbox_offset', $offset + $batch_size );
/**
* Fires when the batch of followers is complete.
*
* @param array $inboxes The inboxes.
* @param string $json The ActivityPub Activity JSON
* @param int $actor_id The actor ID.
* @param int $outbox_item_id The Outbox item ID.
* @param int $batch_size The batch size.
* @param int $offset The offset.
*/
\do_action( 'activitypub_outbox_processing_batch_complete', $inboxes, $json, $actor->get__id(), $outbox_item_id, $batch_size, $offset );
return array( $outbox_item_id, $batch_size, $offset + $batch_size );
}
}
/**
* Retry sending to followers.
*
* @param string $transient_key The key to retrieve retry inboxes.
* @param int $outbox_item_id The Outbox item ID.
* @param int $attempt The attempt number.
*/
public static function retry_send_to_followers( $transient_key, $outbox_item_id, $attempt = 1 ) {
$inboxes = \get_transient( $transient_key );
if ( false === $inboxes ) {
return;
}
// Delete the transient as we no longer need it.
\delete_transient( $transient_key );
$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
// Retry failed inboxes.
if ( ++$attempt < 3 && ! empty( $retries ) ) {
self::schedule_retry( $retries, $outbox_item_id, $attempt );
}
}
/**
* Send to inboxes.
*
* @param array $inboxes The inboxes to notify.
* @param int $outbox_item_id The Outbox item ID.
* @return array The failed inboxes.
*/
private static function send_to_inboxes( $inboxes, $outbox_item_id ) {
$json = Outbox::get_activity( $outbox_item_id )->to_json();
$actor = Outbox::get_actor( \get_post( $outbox_item_id ) );
$retries = array();
/**
* Fires before sending an Activity to inboxes.
*
* @param string $json The ActivityPub Activity JSON.
* @param array $inboxes The inboxes to send to.
* @param int $outbox_item_id The Outbox item ID.
*/
\do_action( 'activitypub_pre_send_to_inboxes', $json, $inboxes, $outbox_item_id );
foreach ( $inboxes as $inbox ) {
$result = safe_remote_post( $inbox, $json, $actor->get__id() );
if ( is_wp_error( $result ) && in_array( $result->get_error_code(), self::$retry_error_codes, true ) ) {
$retries[] = $inbox;
}
/**
* Fires after an Activity has been sent to an inbox.
*
* @param array $result The result of the remote post request.
* @param string $inbox The inbox URL.
* @param string $json The ActivityPub Activity JSON.
* @param int $actor_id The actor ID.
* @param int $outbox_item_id The Outbox item ID.
*/
\do_action( 'activitypub_sent_to_inbox', $result, $inbox, $json, $actor->get__id(), $outbox_item_id );
}
return $retries;
}
/**
* Schedule a retry.
*
* @param array $retries The inboxes to retry.
* @param int $outbox_item_id The Outbox item ID.
* @param int $attempt Optional. The attempt number. Default 1.
*/
private static function schedule_retry( $retries, $outbox_item_id, $attempt = 1 ) {
$transient_key = 'activitypub_retry_' . \wp_generate_password( 12, false );
\set_transient( $transient_key, $retries, WEEK_IN_SECONDS );
\wp_schedule_single_event(
\time() + ( $attempt * $attempt * HOUR_IN_SECONDS ),
'activitypub_async_batch',
array(
array( self::class, 'retry_send_to_followers' ),
$transient_key,
$outbox_item_id,
$attempt,
)
);
}
/**
* Send an Activity to a custom list of inboxes, like mentioned users or replied-to posts.
*
* For all custom implementations, please use the `activitypub_additional_inboxes` filter.
*
* @param Activity $activity The ActivityPub Activity.
* @param int $actor_id The actor ID.
* @param \WP_Post $outbox_item The WordPress object.
*/
private static function send_to_additional_inboxes( $activity, $actor_id, $outbox_item = null ) {
/**
* Filters the list of inboxes to send the Activity to.
*
* @param array $inboxes The list of inboxes to send to.
* @param int $actor_id The actor ID.
* @param Activity $activity The ActivityPub Activity.
*/
$inboxes = apply_filters( 'activitypub_additional_inboxes', array(), $actor_id, $activity );
$inboxes = array_unique( $inboxes );
$retries = self::send_to_inboxes( $inboxes, $outbox_item->ID );
// Retry failed inboxes.
if ( ! empty( $retries ) ) {
self::schedule_retry( $retries, $outbox_item->ID );
}
}
/**
* Default filter to add Inboxes of Mentioned Actors
*
* @param array $inboxes The list of Inboxes.
* @param int $actor_id The WordPress Actor-ID.
* @param Activity $activity The ActivityPub Activity.
*
* @return array The filtered Inboxes.
*/
public static function add_inboxes_by_mentioned_actors( $inboxes, $actor_id, $activity ) {
$cc = $activity->get_cc() ?? array();
$to = $activity->get_to() ?? array();
$audience = array_merge( $cc, $to );
// Remove "public placeholder" and "same domain" from the audience.
$audience = array_filter(
$audience,
function ( $actor ) {
return 'https://www.w3.org/ns/activitystreams#Public' !== $actor && ! is_same_domain( $actor );
}
);
if ( $audience ) {
$mentioned_inboxes = Mention::get_inboxes( $audience );
return array_merge( $inboxes, $mentioned_inboxes );
}
return $inboxes;
}
/**
* Default filter to add Inboxes of Posts that are set as `in-reply-to`
*
* @param array $inboxes The list of Inboxes.
* @param int $actor_id The WordPress Actor-ID.
* @param Activity $activity The ActivityPub Activity.
*
* @return array The filtered Inboxes
*/
public static function add_inboxes_of_replied_urls( $inboxes, $actor_id, $activity ) {
$in_reply_to = $activity->get_in_reply_to();
if ( ! $in_reply_to ) {
return $inboxes;
}
if ( ! is_array( $in_reply_to ) ) {
$in_reply_to = array( $in_reply_to );
}
foreach ( $in_reply_to as $url ) {
// No need to self-notify.
if ( is_same_domain( $url ) ) {
continue;
}
$object = Http::get_remote_object( $url );
if (
! $object ||
\is_wp_error( $object ) ||
empty( $object['attributedTo'] )
) {
continue;
}
$actor = object_to_uri( $object['attributedTo'] );
$actor = Http::get_remote_object( $actor );
if ( ! $actor || \is_wp_error( $actor ) ) {
continue;
}
if ( ! empty( $actor['endpoints']['sharedInbox'] ) ) {
$inboxes[] = $actor['endpoints']['sharedInbox'];
} elseif ( ! empty( $actor['inbox'] ) ) {
$inboxes[] = $actor['inbox'];
}
}
return $inboxes;
}
/**
* Adds Blog Actor inboxes to Updates so the Blog User's followers are notified of edits.
*
* @deprecated 5.2.0 Use {@see Followers::maybe_add_inboxes_of_blog_user} instead.
*
* @param array $inboxes The list of Inboxes.
* @param int $actor_id The WordPress Actor-ID.
* @param Activity $activity The ActivityPub Activity.
*
* @return array The filtered Inboxes.
*/
public static function maybe_add_inboxes_of_blog_user( $inboxes, $actor_id, $activity ) { // phpcs:ignore
_deprecated_function( __METHOD__, '5.2.0', 'Followers::maybe_add_inboxes_of_blog_user' );
return $inboxes;
}
/**
* Check if passed Activity is public.
*
* @param Activity $activity The Activity object.
* @param \Activitypub\Model\User|\Activitypub\Model\Blog $actor The Actor object.
* @param \WP_Post $outbox_item The Outbox item.
*
* @return boolean True if public, false if not.
*/
protected static function should_send_to_followers( $activity, $actor, $outbox_item ) {
// Check if follower endpoint is set.
$cc = $activity->get_cc() ?? array();
$to = $activity->get_to() ?? array();
$audience = array_merge( $cc, $to );
$send = (
// Check if activity is public.
in_array( 'https://www.w3.org/ns/activitystreams#Public', $audience, true ) ||
// ...or check if follower endpoint is set.
in_array( $actor->get_followers(), $audience, true )
);
if ( $send ) {
$followers = Followers::get_inboxes_for_activity( $activity->to_json(), $actor->get__id() );
// Only send if there are followers to send to.
$send = ! is_countable( $followers ) || 0 < count( $followers );
}
/**
* Filters whether to send an Activity to followers.
*
* @param bool $send_activity_to_followers Whether to send the Activity to followers.
* @param Activity $activity The ActivityPub Activity.
* @param int $actor_id The actor ID.
* @param \WP_Post $outbox_item The WordPress object.
*/
return apply_filters( 'activitypub_send_activity_to_followers', $send, $activity, $actor->get__id(), $outbox_item );
}
/**
* Add Inboxes of Relays.
*
* @param array $inboxes The list of Inboxes.
* @param int $actor_id The Actor-ID.
* @param Activity $activity The ActivityPub Activity.
*
* @return array The filtered Inboxes.
*/
public static function add_inboxes_of_relays( $inboxes, $actor_id, $activity ) {
// Check if follower endpoint is set.
$cc = $activity->get_cc() ?? array();
$to = $activity->get_to() ?? array();
$audience = array_merge( $cc, $to );
// Check if activity is public.
if ( ! in_array( 'https://www.w3.org/ns/activitystreams#Public', $audience, true ) ) {
return $inboxes;
}
$relays = \get_option( 'activitypub_relays', array() );
if ( empty( $relays ) ) {
return $inboxes;
}
return array_merge( $inboxes, $relays );
}
}

View File

@ -0,0 +1,263 @@
<?php
/**
* ActivityPub Embed Handler.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* Class to handle embedding ActivityPub content
*/
class Embed {
/**
* Initialize the embed handler
*/
public static function init() {
\add_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 );
\add_filter( 'oembed_dataparse', array( self::class, 'handle_filtered_oembed_result' ), 11, 3 );
\add_filter( 'oembed_request_post_id', array( self::class, 'register_fallback_hook' ) );
}
/**
* Get an ActivityPub embed HTML for a URL.
*
* @param string $url The URL to get the embed for.
* @param boolean $inline_css Whether to inline CSS. Default true.
*
* @return string|false The embed HTML or false if not found.
*/
public static function get_html( $url, $inline_css = true ) {
// Try to get ActivityPub representation.
$object = Http::get_remote_object( $url );
if ( is_wp_error( $object ) ) {
return false;
}
return self::get_html_for_object( $object, $inline_css );
}
/**
* Get an ActivityPub embed HTML for an ActivityPub object.
*
* @param array $activity_object The ActivityPub object to build the embed for.
* @param boolean $inline_css Whether to inline CSS. Default true.
*
* @return string The embed HTML.
*/
public static function get_html_for_object( $activity_object, $inline_css = true ) {
$author_name = $activity_object['attributedTo'] ?? '';
$avatar_url = $activity_object['icon']['url'] ?? '';
$author_url = $author_name;
// If we don't have an avatar URL, but we have an author URL, try to fetch it.
if ( ! $avatar_url && $author_url ) {
$author = Http::get_remote_object( $author_url );
if ( ! is_wp_error( $author ) ) {
$avatar_url = $author['icon']['url'] ?? '';
$author_name = $author['name'] ?? $author_name;
}
}
// Create Webfinger where not found.
if ( empty( $author['webfinger'] ) ) {
if ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
// Construct webfinger-style identifier from username and domain.
$domain = wp_parse_url( $author['url'], PHP_URL_HOST );
$author['webfinger'] = '@' . $author['preferredUsername'] . '@' . $domain;
} else {
// Fallback to URL.
$author['webfinger'] = $author_url;
}
}
$title = $activity_object['name'] ?? '';
$content = $activity_object['content'] ?? '';
$published = isset( $activity_object['published'] ) ? gmdate( get_option( 'date_format' ) . ', ' . get_option( 'time_format' ), strtotime( $activity_object['published'] ) ) : '';
$boosts = isset( $activity_object['shares']['totalItems'] ) ? (int) $activity_object['shares']['totalItems'] : null;
$favorites = isset( $activity_object['likes']['totalItems'] ) ? (int) $activity_object['likes']['totalItems'] : null;
$image = '';
if ( isset( $activity_object['image']['url'] ) ) {
$image = $activity_object['image']['url'];
} elseif ( isset( $activity_object['attachment'] ) ) {
foreach ( $activity_object['attachment'] as $attachment ) {
if ( isset( $attachment['type'] ) && in_array( $attachment['type'], array( 'Image', 'Document' ), true ) ) {
$image = $attachment['url'];
break;
}
}
}
ob_start();
load_template(
ACTIVITYPUB_PLUGIN_DIR . 'templates/reply-embed.php',
false,
array(
'author_name' => $author_name,
'author_url' => $author_url,
'avatar_url' => $avatar_url,
'published' => $published,
'title' => $title,
'content' => $content,
'image' => $image,
'boosts' => $boosts,
'favorites' => $favorites,
'url' => $activity_object['id'],
'webfinger' => $author['webfinger'],
)
);
if ( $inline_css ) {
// Grab the CSS.
$css = \file_get_contents( ACTIVITYPUB_PLUGIN_DIR . 'assets/css/activitypub-embed.css' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
// We embed CSS directly because this may be in an iframe.
printf( '<style>%s</style>', $css ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
// A little light whitespace cleanup.
return preg_replace( '/\s+/', ' ', ob_get_clean() );
}
/**
* Check if a real oEmbed result exists for the given URL.
*
* @param string $url The URL to check.
* @param array $args Additional arguments passed to wp_oembed_get().
* @return bool True if a real oEmbed result exists, false otherwise.
*/
public static function has_real_oembed( $url, $args = array() ) {
// Temporarily remove our filter to avoid infinite loops.
\remove_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 );
// Try to get a "real" oEmbed result. If found, it'll be cached to avoid unnecessary HTTP requests in `wp_oembed_get`.
$oembed_result = \wp_oembed_get( $url, $args );
// Add our filter back.
\add_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 );
return false !== $oembed_result;
}
/**
* Filter the oembed result to handle ActivityPub content when no oEmbed is found.
* Implementation is a bit weird because there's no way to filter on a false result, we have to use `pre_oembed_result`.
*
* @param null|string $result The UNSANITIZED (and potentially unsafe) HTML that should be used to embed.
* @param string $url The URL to the content that should be attempted to be embedded.
* @param array $args Additional arguments passed to wp_oembed_get().
* @return null|string Return null to allow normal oEmbed processing, or string for ActivityPub embed.
*/
public static function maybe_use_activitypub_embed( $result, $url, $args ) {
// If we already have a result, return it.
if ( null !== $result ) {
return $result;
}
// If we found a real oEmbed, return null to allow normal processing.
if ( self::has_real_oembed( $url, $args ) ) {
return null;
}
// No oEmbed found, try to get ActivityPub representation.
$html = get_embed_html( $url );
// If we couldn't get an ActivityPub embed either, return null to allow normal processing.
if ( ! $html ) {
return null;
}
// Return the ActivityPub embed HTML.
return $html;
}
/**
* Handle cases where WordPress has filtered out the oEmbed result for security reasons,
* but we can provide a safe ActivityPub-specific markup.
*
* This runs after wp_filter_oembed_result has potentially nullified the result.
*
* @param string|false $html The returned oEmbed HTML.
* @param object $data A data object result from an oEmbed provider.
* @param string $url The URL of the content to be embedded.
* @return string|false The filtered oEmbed HTML or our ActivityPub embed.
*/
public static function handle_filtered_oembed_result( $html, $data, $url ) {
// If we already have valid HTML, return it.
if ( $html ) {
return $html;
}
// If this isn't a rich or video type, we can't help.
if ( ! isset( $data->type ) || ! \in_array( $data->type, array( 'rich', 'video' ), true ) ) {
return $html;
}
// If there's no HTML in the data, we can't help.
if ( empty( $data->html ) || ! \is_string( $data->html ) ) {
return $html;
}
// Try to get ActivityPub representation.
$activitypub_html = get_embed_html( $url );
if ( ! $activitypub_html ) {
return $html;
}
// Return our safer ActivityPub embed HTML.
return $activitypub_html;
}
/**
* Register the fallback hook for oEmbed requests.
*
* Avoids filtering every single API request.
*
* @param int $post_id The post ID.
* @return int The post ID.
*/
public static function register_fallback_hook( $post_id ) {
\add_filter( 'rest_request_after_callbacks', array( self::class, 'oembed_fediverse_fallback' ), 10, 3 );
return $post_id;
}
/**
* Fallback for oEmbed requests to the Fediverse.
*
* @param \WP_REST_Response|\WP_Error $response Result to send to the client.
* @param array $handler Route handler used for the request.
* @param \WP_REST_Request $request Request used to generate the response.
*
* @return \WP_REST_Response|\WP_Error The response to send to the client.
*/
public static function oembed_fediverse_fallback( $response, $handler, $request ) {
if ( is_wp_error( $response ) && 'oembed_invalid_url' === $response->get_error_code() ) {
$url = $request->get_param( 'url' );
$html = get_embed_html( $url );
if ( $html ) {
$args = $request->get_params();
$data = (object) array(
'provider_name' => 'Embed Handler',
'html' => $html,
'scripts' => array(),
);
/** This filter is documented in wp-includes/class-wp-oembed.php */
$data->html = apply_filters( 'oembed_result', $data->html, $url, $args );
/** This filter is documented in wp-includes/class-wp-oembed-controller.php */
$ttl = apply_filters( 'rest_oembed_ttl', DAY_IN_SECONDS, $url, $args );
set_transient( 'oembed_' . md5( serialize( $args ) ), $data, $ttl ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
$response = new \WP_REST_Response( $data );
}
}
return $response;
}
}

View File

@ -1,9 +1,18 @@
<?php
/**
* Handler class.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Handler\Announce;
use Activitypub\Handler\Create;
use Activitypub\Handler\Delete;
use Activitypub\Handler\Follow;
use Activitypub\Handler\Like;
use Activitypub\Handler\Move;
use Activitypub\Handler\Undo;
use Activitypub\Handler\Update;
@ -12,7 +21,7 @@ use Activitypub\Handler\Update;
*/
class Handler {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
self::register_handlers();
@ -22,12 +31,20 @@ class Handler {
* Register handlers.
*/
public static function register_handlers() {
Announce::init();
Create::init();
Delete::init();
Follow::init();
Undo::init();
Update::init();
Like::init();
Move::init();
/**
* Register additional handlers.
*
* @since 1.3.0
*/
do_action( 'activitypub_register_handlers' );
}
}

View File

@ -1,117 +1,108 @@
<?php
/**
* Hashtag Class.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* ActivityPub Hashtag Class
* ActivityPub Hashtag Class.
*
* @author Matthias Pfefferle
*/
class Hashtag {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
if ( '1' === \get_option( 'activitypub_use_hashtags', '1' ) ) {
\add_action( 'wp_insert_post', array( self::class, 'insert_post' ), 10, 2 );
\add_filter( 'the_content', array( self::class, 'the_content' ), 10, 1 );
\add_filter( 'the_content', array( self::class, 'the_content' ) );
\add_filter( 'activitypub_activity_object_array', array( self::class, 'filter_activity_object' ), 99 );
}
}
/**
* Filter to save #tags as real WordPress tags
* Filter only the activity object and replace summery it with URLs.
*
* @param int $id the rev-id
* @param WP_Post $post the post
* @param array $activity The activity object array.
*
* @return
* @return array The filtered activity object array.
*/
public static function insert_post( $id, $post ) {
if ( \preg_match_all( '/' . ACTIVITYPUB_HASHTAGS_REGEXP . '/i', $post->post_content, $match ) ) {
$tags = \implode( ', ', $match[1] );
\wp_add_post_tags( $post->post_parent, $tags );
public static function filter_activity_object( $activity ) {
/* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
Only changed it for Person and Group as long is not merged: https://github.com/mastodon/mastodon/pull/28629
*/
if ( ! empty( $activity['summary'] ) && in_array( $activity['type'], array( 'Person', 'Group' ), true ) ) {
$activity['summary'] = self::the_content( $activity['summary'] );
}
return $id;
if ( ! empty( $activity['content'] ) ) {
$activity['content'] = self::the_content( $activity['content'] );
}
return $activity;
}
/**
* Filter to replace the #tags in the content with links
* Filter to save #tags as real WordPress tags.
*
* @param string $the_content the post-content
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
*/
public static function insert_post( $post_id, $post ) {
// Check if the post supports ActivityPub.
if ( ! \post_type_supports( \get_post_type( $post ), 'activitypub' ) ) {
return;
}
// Check if the (custom) post supports tags.
$taxonomies = \get_object_taxonomies( $post );
if ( ! in_array( 'post_tag', $taxonomies, true ) ) {
return;
}
$tags = array();
// Skip hashtags in HTML attributes, like hex colors.
$content = wp_strip_all_tags( $post->post_content . "\n" . $post->post_excerpt );
if ( \preg_match_all( '/' . ACTIVITYPUB_HASHTAGS_REGEXP . '/i', $content, $match ) ) {
$tags = array_unique( $match[1] );
}
\wp_add_post_tags( $post->ID, \implode( ', ', $tags ) );
}
/**
* Filter to replace the #tags in the content with links.
*
* @return string the filtered post-content
* @param string $the_content The post content.
*
* @return string The filtered post content.
*/
public static function the_content( $the_content ) {
// small protection against execution timeouts: limit to 1 MB
if ( mb_strlen( $the_content ) > MB_IN_BYTES ) {
return $the_content;
}
$tag_stack = array();
$protected_tags = array(
'pre',
'code',
'textarea',
'style',
'a',
);
$content_with_links = '';
$in_protected_tag = false;
foreach ( wp_html_split( $the_content ) as $chunk ) {
if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
$content_with_links .= $chunk;
continue;
}
if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
$tag = strtolower( $m[2] );
if ( '/' === $m[1] ) {
// Closing tag.
$i = array_search( $tag, $tag_stack, true );
// We can only remove the tag from the stack if it is in the stack.
if ( false !== $i ) {
$tag_stack = array_slice( $tag_stack, 0, $i );
}
} else {
// Opening tag, add it to the stack.
$tag_stack[] = $tag;
}
// If we're in a protected tag, the tag_stack contains at least one protected tag string.
// The protected tag state can only change when we encounter a start or end tag.
$in_protected_tag = array_intersect( $tag_stack, $protected_tags );
// Never inspect tags.
$content_with_links .= $chunk;
continue;
}
if ( $in_protected_tag ) {
// Don't inspect a chunk inside an inspected tag.
$content_with_links .= $chunk;
continue;
}
// Only reachable when there is no protected tag in the stack.
$content_with_links .= \preg_replace_callback( '/' . ACTIVITYPUB_HASHTAGS_REGEXP . '/i', array( '\Activitypub\Hashtag', 'replace_with_links' ), $chunk );
}
return $content_with_links;
return enrich_content_data( $the_content, '/' . ACTIVITYPUB_HASHTAGS_REGEXP . '/i', array( self::class, 'replace_with_links' ) );
}
/**
* A callback for preg_replace to build the term links
* A callback for preg_replace to build the term links.
*
* @param array $result the preg_match results
* @param array $result The preg_match results.
* @return string the final string
*/
public static function replace_with_links( $result ) {
$tag = $result[1];
$tag = $result[1];
$tag_object = \get_term_by( 'name', $tag, 'post_tag' );
if ( ! $tag_object ) {
$tag_object = \get_term_by( 'name', $tag, 'category' );
}
if ( $tag_object ) {
$link = \get_term_link( $tag_object, 'post_tag' );
return \sprintf( '<a rel="tag" class="hashtag u-tag u-category" href="%s">#%s</a>', $link, $tag );
return \sprintf( '<a rel="tag" class="hashtag u-tag u-category" href="%s">#%s</a>', esc_url( $link ), $tag );
}
return '#' . $tag;

View File

@ -1,365 +0,0 @@
<?php
namespace Activitypub;
use WP_Error;
use Activitypub\Webfinger;
use Activitypub\Collection\Users;
use function Activitypub\get_plugin_version;
use function Activitypub\is_user_type_disabled;
use function Activitypub\get_webfinger_resource;
/**
* ActivityPub Health_Check Class
*
* @author Matthias Pfefferle
*/
class Health_Check {
/**
* Initialize health checks
*
* @return void
*/
public static function init() {
\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 ) {
if ( ! is_user_type_disabled( 'user' ) ) {
$tests['direct']['activitypub_test_author_url'] = array(
'label' => \__( 'Author URL test', 'activitypub' ),
'test' => array( self::class, 'test_author_url' ),
);
}
$tests['direct']['activitypub_test_webfinger'] = array(
'label' => __( 'WebFinger Test', 'activitypub' ),
'test' => array( self::class, 'test_webfinger' ),
);
return $tests;
}
/**
* Author URL tests
*
* @return array
*/
public static function test_author_url() {
$result = array(
'label' => \__( 'Author URL accessible', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'Your author URL is accessible and supports the required "Accept" header.', 'activitypub' )
),
'actions' => '',
'test' => 'test_author_url',
);
$check = self::is_author_url_accessible();
if ( true === $check ) {
return $result;
}
$result['status'] = 'critical';
$result['label'] = \__( 'Author URL is not accessible', 'activitypub' );
$result['badge']['color'] = 'red';
$result['description'] = \sprintf(
'<p>%s</p>',
$check->get_error_message()
);
return $result;
}
/**
* System Cron tests
*
* @return array
*/
public static function test_system_cron() {
$result = array(
'label' => \__( 'System Task Scheduler configured', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\esc_html__( 'You seem to use the System Task Scheduler to process WP_Cron tasks.', 'activitypub' )
),
'actions' => '',
'test' => 'test_system_cron',
);
if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
return $result;
}
$result['status'] = 'recommended';
$result['label'] = \__( 'System Task Scheduler not configured', 'activitypub' );
$result['badge']['color'] = 'orange';
$result['description'] = \sprintf(
'<p>%s</p>',
\__( 'Enhance your WordPress sites performance and mitigate potential heavy loads caused by plugins like ActivityPub by setting up a system cron job to run WP Cron. This ensures scheduled tasks are executed consistently and reduces the reliance on website traffic for trigger events.', 'activitypub' )
);
$result['actions'] .= sprintf(
'<p><a href="%s" target="_blank" rel="noopener">%s<span class="screen-reader-text"> %s</span><span aria-hidden="true" class="dashicons dashicons-external"></span></a></p>',
__( 'https://developer.wordpress.org/plugins/cron/hooking-wp-cron-into-the-system-task-scheduler/', 'activitypub' ),
__( 'Learn how to hook the WP-Cron into the System Task Scheduler.', 'activitypub' ),
/* translators: Hidden accessibility text. */
__( '(opens in a new tab)', 'activitypub' )
);
return $result;
}
/**
* WebFinger tests
*
* @return array
*/
public static function test_webfinger() {
$result = array(
'label' => \__( 'WebFinger endpoint', 'activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'ActivityPub', 'activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'Your WebFinger endpoint is accessible and returns the correct information.', 'activitypub' )
),
'actions' => '',
'test' => 'test_webfinger',
);
$check = self::is_webfinger_endpoint_accessible();
if ( true === $check ) {
return $result;
}
$result['status'] = 'critical';
$result['label'] = \__( 'WebFinger endpoint is not accessible', 'activitypub' );
$result['badge']['color'] = 'red';
$result['description'] = \sprintf(
'<p>%s</p>',
$check->get_error_message()
);
return $result;
}
/**
* Check if `author_posts_url` is accessible and that request returns correct JSON
*
* @return boolean|WP_Error
*/
public static function is_author_url_accessible() {
$user = \wp_get_current_user();
$author_url = \get_author_posts_url( $user->ID );
$reference_author_url = self::get_author_posts_url( $user->ID, $user->user_nicename );
// check for "author" in URL
if ( $author_url !== $reference_author_url ) {
return new WP_Error(
'author_url_not_accessible',
\sprintf(
// translators: %s: Author URL
\__(
'Your author URL <code>%s</code> was replaced, this is often done by plugins.',
'activitypub'
),
$author_url
)
);
}
// try to access author URL
$response = \wp_remote_get(
$author_url,
array(
'headers' => array( 'Accept' => 'application/activity+json' ),
'redirection' => 0,
)
);
if ( \is_wp_error( $response ) ) {
return new WP_Error(
'author_url_not_accessible',
\sprintf(
// translators: %s: Author URL
\__(
'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
)
);
}
$response_code = \wp_remote_retrieve_response_code( $response );
// check for redirects
if ( \in_array( $response_code, array( 301, 302, 307, 308 ), true ) ) {
return new WP_Error(
'author_url_not_accessible',
\sprintf(
// translators: %s: Author URL
\__(
'Your author URL <code>%s</code> is redirecting to another page, this is often done by SEO plugins like "Yoast SEO".',
'activitypub'
),
$author_url
)
);
}
// check if response is JSON
$body = \wp_remote_retrieve_body( $response );
if ( ! \is_string( $body ) || ! \is_array( \json_decode( $body, true ) ) ) {
return new WP_Error(
'author_url_not_accessible',
\sprintf(
// translators: %s: Author URL
\__(
'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
)
);
}
return true;
}
/**
* Check if WebFinger endpoint is accessible and profile request returns correct JSON
*
* @return boolean|WP_Error
*/
public static function is_webfinger_endpoint_accessible() {
$user = Users::get_by_id( Users::APPLICATION_USER_ID );
$resource = $user->get_webfinger();
$url = Webfinger::resolve( $resource );
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(
$not_accessible,
$url->get_error_data()
),
'webfinger_url_invalid_response' => \sprintf(
// translators: %s: Author URL
$invalid_response,
$url->get_error_data()
),
);
$message = null;
if ( isset( $health_messages[ $url->get_error_code() ] ) ) {
$message = $health_messages[ $url->get_error_code() ];
}
return new WP_Error(
$url->get_error_code(),
$message,
$url->get_error_data()
);
}
return true;
}
/**
* Retrieve the URL to the author page for the user with the ID provided.
*
* @global WP_Rewrite $wp_rewrite WordPress rewrite component.
*
* @param int $author_id Author ID.
* @param string $author_nicename Optional. The author's nicename (slug). Default empty.
*
* @return string The URL to the author's page.
*/
public static function get_author_posts_url( $author_id, $author_nicename = '' ) {
global $wp_rewrite;
$auth_id = (int) $author_id;
$link = $wp_rewrite->get_author_permastruct();
if ( empty( $link ) ) {
$file = home_url( '/' );
$link = $file . '?author=' . $auth_id;
} else {
if ( '' === $author_nicename ) {
$user = get_userdata( $author_id );
if ( ! empty( $user->user_nicename ) ) {
$author_nicename = $user->user_nicename;
}
}
$link = str_replace( '%author%', $author_nicename, $link );
$link = home_url( user_trailingslashit( $link ) );
}
return $link;
}
/**
* 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 information
*/
public static function debug_information( $info ) {
$info['activitypub'] = array(
'label' => __( 'ActivityPub', 'activitypub' ),
'fields' => array(
'webfinger' => array(
'label' => __( 'WebFinger Resource', 'activitypub' ),
'value' => Webfinger::get_user_resource( wp_get_current_user()->ID ),
'private' => true,
),
'author_url' => array(
'label' => __( 'Author URL', 'activitypub' ),
'value' => get_author_posts_url( wp_get_current_user()->ID ),
'private' => true,
),
'plugin_version' => array(
'label' => __( 'Plugin Version', 'activitypub' ),
'value' => get_plugin_version(),
'private' => true,
),
),
);
return $info;
}
}

View File

@ -1,10 +1,14 @@
<?php
/**
* ActivityPub HTTP Class.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_Error;
use Activitypub\Collection\Users;
use function Activitypub\get_masked_wp_version;
use Activitypub\Collection\Actors;
/**
* ActivityPub HTTP Class
@ -15,87 +19,142 @@ 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
* @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
* @return array|WP_Error The POST Response or an WP_Error.
*/
public static function post( $url, $body, $user_id ) {
/**
* Fires before an HTTP POST request is made.
*
* @param string $url The URL endpoint.
* @param string $body The POST body.
* @param int $user_id The WordPress 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 );
$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_masked_wp_version();
/**
* Filter the HTTP headers user agent.
* Filters the HTTP headers user agent string.
*
* @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,
$args = array(
'timeout' => 100,
'limit_response_size' => 1048576,
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
'Digest' => $digest,
'Signature' => $signature,
'Date' => $date,
'Digest' => $digest,
'Signature' => $signature,
'Date' => $date,
),
'body' => $body,
'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' ), array( 'status' => $code ) );
$response = new WP_Error(
$code,
__( 'Failed HTTP Request', 'activitypub' ),
array(
'status' => $code,
'response' => $response,
)
);
}
/**
* Action to save the response of the remote POST request.
*
* @param array|WP_Error $response The response of the remote POST request.
* @param string $url The URL endpoint.
* @param string $body The Post Body.
* @param int $user_id The WordPress User-ID.
*/
\do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
return $response;
}
/**
* Send a GET Request with the needed HTTP Headers
* Send a GET Request with the needed HTTP Headers.
*
* @param string $url The URL endpoint
* @param int $user_id The WordPress User-ID
* @param string $url The URL endpoint.
* @param bool|int $cached Optional. Whether the result should be cached, or its duration. Default false.
*
* @return array|WP_Error The GET Response or an WP_ERROR
* @return array|WP_Error The GET Response or a WP_Error.
*/
public static function get( $url ) {
public static function get( $url, $cached = false ) {
/**
* Fires before an HTTP GET request is made.
*
* @param string $url The URL endpoint.
*/
\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 );
if ( $cached ) {
$transient_key = self::generate_cache_key( $url );
$response = \get_transient( $transient_key );
if ( $response ) {
/**
* Action to save the response of the remote GET request.
*
* @param array|WP_Error $response The response of the remote GET request.
* @param string $url The URL endpoint.
*/
\do_action( 'activitypub_safe_remote_get_response', $response, $url );
return $response;
}
}
$date = \gmdate( 'D, d M Y H:i:s T' );
$signature = Signature::generate_signature( Actors::APPLICATION_USER_ID, 'get', $url, $date );
$wp_version = get_masked_wp_version();
/**
* Filter the HTTP headers user agent.
* Filters the HTTP headers user agent string.
*
* This filter allows developers to modify the user agent string that is
* sent with HTTP requests.
*
* @param string $user_agent The user agent string.
*/
$user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
/**
* Filters the timeout duration for remote GET requests in ActivityPub.
*
* @param int $timeout The timeout value in seconds. Default 100 seconds.
*/
$timeout = \apply_filters( 'activitypub_remote_get_timeout', 100 );
$args = array(
'timeout' => apply_filters( 'activitypub_remote_get_timeout', 100 ),
'timeout' => $timeout,
'limit_response_size' => 1048576,
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'redirection' => 3,
'user-agent' => "$user_agent; ActivityPub",
'headers' => array(
'Accept' => 'application/activity+json',
'Content-Type' => 'application/activity+json',
'Signature' => $signature,
'Date' => $date,
'Signature' => $signature,
'Date' => $date,
),
);
@ -106,8 +165,22 @@ class Http {
$response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
}
/**
* Action to save the response of the remote GET request.
*
* @param array|WP_Error $response The response of the remote GET request.
* @param string $url The URL endpoint.
*/
\do_action( 'activitypub_safe_remote_get_response', $response, $url );
if ( $cached ) {
$cache_duration = $cached;
if ( ! is_int( $cache_duration ) ) {
$cache_duration = HOUR_IN_SECONDS;
}
\set_transient( $transient_key, $response, $cache_duration );
}
return $response;
}
@ -119,15 +192,114 @@ class Http {
* @return bool True if the URL is a tombstone.
*/
public static function is_tombstone( $url ) {
/**
* Fires before checking if the URL is a tombstone.
*
* @param string $url The URL to check.
*/
\do_action( 'activitypub_pre_http_is_tombstone', $url );
$response = \wp_safe_remote_get( $url );
$response = \wp_safe_remote_get( $url, array( 'headers' => array( 'Accept' => 'application/activity+json' ) ) );
$code = \wp_remote_retrieve_response_code( $response );
if ( in_array( (int) $code, array( 404, 410 ), true ) ) {
return true;
}
$data = \wp_remote_retrieve_body( $response );
$data = \json_decode( $data, true );
if ( $data && isset( $data['type'] ) && 'Tombstone' === $data['type'] ) {
return true;
}
return false;
}
/**
* Generate a cache key for the URL.
*
* @param string $url The URL to generate the cache key for.
*
* @return string The cache key.
*/
public static function generate_cache_key( $url ) {
return 'activitypub_http_' . \md5( $url );
}
/**
* Requests the Data from the Object-URL or Object-Array.
*
* @param array|string $url_or_object The Object or the Object URL.
* @param bool $cached Optional. Whether the result should be cached. Default true.
*
* @return array|WP_Error The Object data as array or WP_Error on failure.
*/
public static function get_remote_object( $url_or_object, $cached = true ) {
$url = object_to_uri( $url_or_object );
if ( preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $url ) ) {
$url = Webfinger::resolve( $url );
}
if ( ! $url ) {
return new WP_Error(
'activitypub_no_valid_actor_identifier',
\__( 'The "actor" identifier is not valid', 'activitypub' ),
array(
'status' => 404,
'object' => $url,
)
);
}
if ( is_wp_error( $url ) ) {
return $url;
}
$transient_key = self::generate_cache_key( $url );
// Only check the cache if needed.
if ( $cached ) {
$data = \get_transient( $transient_key );
if ( $data ) {
return $data;
}
}
if ( ! \wp_http_validate_url( $url ) ) {
return new WP_Error(
'activitypub_no_valid_object_url',
\__( 'The "object" is/has no valid URL', 'activitypub' ),
array(
'status' => 400,
'object' => $url,
)
);
}
$response = self::get( $url );
if ( \is_wp_error( $response ) ) {
return $response;
}
$data = \wp_remote_retrieve_body( $response );
$data = \json_decode( $data, true );
if ( ! $data ) {
return new WP_Error(
'activitypub_invalid_json',
\__( 'No valid JSON data', 'activitypub' ),
array(
'status' => 400,
'object' => $url,
)
);
}
\set_transient( $transient_key, $data, WEEK_IN_SECONDS );
return $data;
}
}

View File

@ -0,0 +1,132 @@
<?php
/**
* Link class.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* ActivityPub Summery Links Class.
*/
class Link {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'activitypub_extra_field_content', array( self::class, 'the_content' ) );
\add_filter( 'activitypub_activity_object_array', array( self::class, 'filter_activity_object' ), 99 );
}
/**
* Filter only the activity object and replace the summary with URLs.
*
* @param array $activity The activity object array.
*
* @return array Rhe activity object array.
*/
public static function filter_activity_object( $activity ) {
/* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
Only changed it for Person and Group as long is not merged: https://github.com/mastodon/mastodon/pull/28629
*/
if ( ! empty( $activity['summary'] ) && in_array( $activity['type'], array( 'Person', 'Group' ), true ) ) {
$activity['summary'] = self::the_content( $activity['summary'] );
}
if ( ! empty( $activity['content'] ) ) {
$activity['content'] = self::the_content( $activity['content'] );
}
return $activity;
}
/**
* Filter to replace the URLS in the content with links
*
* @param string $the_content The post content.
*
* @return string the filtered post content.
*/
public static function the_content( $the_content ) {
return enrich_content_data( $the_content, '/' . ACTIVITYPUB_URL_REGEXP . '/i', array( self::class, 'replace_with_links' ) );
}
/**
* A callback for preg_replace to build the links.
*
* Link shortening https://docs.joinmastodon.org/api/guidelines/#links
*
* @param array $result The preg_match results.
*
* @return string The final string.
*/
public static function replace_with_links( $result ) {
if ( 'www.' === substr( $result[0], 0, 4 ) ) {
$result[0] = 'https://' . $result[0];
}
$parsed_url = \wp_parse_url( html_entity_decode( $result[0] ) );
if ( ! $parsed_url || empty( $parsed_url['host'] ) ) {
return $result[0];
}
if ( empty( $parsed_url['scheme'] ) ) {
$invisible_prefix = 'https://';
} else {
$invisible_prefix = $parsed_url['scheme'] . '://';
}
if ( ! empty( $parsed_url['user'] ) ) {
$invisible_prefix .= $parsed_url['user'];
}
if ( ! empty( $parsed_url['pass'] ) ) {
$invisible_prefix .= ':' . $parsed_url['pass'];
}
if ( ! empty( $parsed_url['user'] ) ) {
$invisible_prefix .= '@';
}
$text_url = $parsed_url['host'];
if ( 'www.' === substr( $text_url, 0, 4 ) ) {
$text_url = substr( $text_url, 4 );
$invisible_prefix .= 'www.';
}
if ( ! empty( $parsed_url['port'] ) ) {
$text_url .= ':' . $parsed_url['port'];
}
if ( ! empty( $parsed_url['path'] ) ) {
$text_url .= $parsed_url['path'];
}
if ( ! empty( $parsed_url['query'] ) ) {
$text_url .= '?' . $parsed_url['query'];
}
if ( ! empty( $parsed_url['fragment'] ) ) {
$text_url .= '#' . $parsed_url['fragment'];
}
$display = \substr( $text_url, 0, 30 );
$invisible_suffix = \substr( $text_url, 30 );
$display_class = '';
if ( $invisible_suffix ) {
$display_class .= 'ellipsis';
}
/**
* Filters the rel attribute for ActivityPub links.
*
* @param string $rel The rel attribute string. Default 'nofollow noopener noreferrer'.
*/
$rel = apply_filters( 'activitypub_link_rel', 'nofollow noopener noreferrer' );
return \sprintf(
'<a href="%s" target="_blank" rel="%s" translate="no"><span class="invisible">%s</span><span class="%s">%s</span><span class="invisible">%s</span></a>',
esc_url( $result[0] ),
$rel,
esc_html( $invisible_prefix ),
$display_class,
esc_html( $display ),
esc_html( $invisible_suffix )
);
}
}

View File

@ -0,0 +1,337 @@
<?php
/**
* Mailer Class.
*
* @package ActivityPub
*/
namespace Activitypub;
use Activitypub\Collection\Actors;
/**
* Mailer Class.
*/
class Mailer {
/**
* Initialize the Mailer.
*/
public static function init() {
\add_filter( 'comment_notification_subject', array( self::class, 'comment_notification_subject' ), 10, 2 );
\add_filter( 'comment_notification_text', array( self::class, 'comment_notification_text' ), 10, 2 );
\add_action( 'activitypub_inbox_follow', array( self::class, 'new_follower' ), 10, 2 );
\add_action( 'activitypub_inbox_create', array( self::class, 'direct_message' ), 10, 2 );
\add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 10, 2 );
}
/**
* Filter the subject line for Like and Announce notifications.
*
* @param string $subject The default subject line.
* @param int|string $comment_id The comment ID.
*
* @return string The filtered subject line.
*/
public static function comment_notification_subject( $subject, $comment_id ) {
$comment = \get_comment( $comment_id );
if ( ! $comment ) {
return $subject;
}
$type = \get_comment_meta( $comment->comment_ID, 'protocol', true );
if ( 'activitypub' !== $type ) {
return $subject;
}
$singular = Comment::get_comment_type_attr( $comment->comment_type, 'singular' );
if ( ! $singular ) {
return $subject;
}
$post = \get_post( $comment->comment_post_ID );
/* translators: 1: Blog name, 2: Like or Repost, 3: Post title */
return \sprintf( \esc_html__( '[%1$s] %2$s: %3$s', 'activitypub' ), \esc_html( get_option( 'blogname' ) ), \esc_html( $singular ), \esc_html( $post->post_title ) );
}
/**
* Filter the notification text for Like and Announce notifications.
*
* @param string $message The default notification text.
* @param int|string $comment_id The comment ID.
*
* @return string The filtered notification text.
*/
public static function comment_notification_text( $message, $comment_id ) {
$comment = \get_comment( $comment_id );
if ( ! $comment ) {
return $message;
}
$type = \get_comment_meta( $comment->comment_ID, 'protocol', true );
if ( 'activitypub' !== $type ) {
return $message;
}
$comment_type = Comment::get_comment_type( $comment->comment_type );
if ( ! $comment_type ) {
return $message;
}
$post = \get_post( $comment->comment_post_ID );
$comment_author_domain = \gethostbyaddr( $comment->comment_author_IP );
/* translators: 1: Comment type, 2: Post title */
$notify_message = \sprintf( html_entity_decode( esc_html__( 'New %1$s on your post &#8220;%2$s&#8221;.', 'activitypub' ) ), \esc_html( $comment_type['singular'] ), \esc_html( $post->post_title ) ) . "\r\n\r\n";
/* translators: 1: Website name, 2: Website IP address, 3: Website hostname. */
$notify_message .= \sprintf( \esc_html__( 'From: %1$s (IP address: %2$s, %3$s)', 'activitypub' ), \esc_html( $comment->comment_author ), \esc_html( $comment->comment_author_IP ), \esc_html( $comment_author_domain ) ) . "\r\n";
/* translators: Reaction author URL. */
$notify_message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $comment->comment_author_url ) ) . "\r\n\r\n";
/* translators: Comment type label */
$notify_message .= \sprintf( \esc_html__( 'You can see all %s on this post here:', 'activitypub' ), \esc_html( $comment_type['label'] ) ) . "\r\n";
$notify_message .= \get_permalink( $comment->comment_post_ID ) . '#' . \esc_attr( $comment_type['type'] ) . "\r\n\r\n";
return $notify_message;
}
/**
* Send a notification email for every new follower.
*
* @param array $activity The activity object.
* @param int $user_id The id of the local blog-user.
*/
public static function new_follower( $activity, $user_id ) {
if ( $user_id > Actors::BLOG_USER_ID ) {
if ( ! \get_user_option( 'activitypub_mailer_new_follower', $user_id ) ) {
return;
}
$email = \get_userdata( $user_id )->user_email;
$admin_url = '/users.php?page=activitypub-followers-list';
} else {
if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_follower', '1' ) ) {
return;
}
$email = \get_option( 'admin_email' );
$admin_url = '/options-general.php?page=activitypub&tab=followers';
}
$actor = get_remote_metadata_by_actor( $activity['actor'] );
if ( ! $actor || \is_wp_error( $actor ) ) {
return;
}
if ( empty( $actor['webfinger'] ) ) {
$actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
}
$template_args = array_merge(
$actor,
array(
'admin_url' => $admin_url,
'user_id' => $user_id,
'stats' => array(
'outbox' => null,
'followers' => null,
'following' => null,
),
)
);
foreach ( $template_args['stats'] as $field => $value ) {
if ( empty( $actor[ $field ] ) ) {
continue;
}
$result = Http::get( $actor[ $field ], true );
if ( 200 === \wp_remote_retrieve_response_code( $result ) ) {
$body = \json_decode( \wp_remote_retrieve_body( $result ), true );
if ( isset( $body['totalItems'] ) ) {
$template_args['stats'][ $field ] = $body['totalItems'];
}
}
}
/* translators: 1: Blog name, 2: Follower name */
$subject = \sprintf( \__( '[%1$s] New Follower: %2$s', 'activitypub' ), \get_option( 'blogname' ), $actor['name'] );
\ob_start();
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-follower.php', false, $template_args );
$html_message = \ob_get_clean();
$alt_function = function ( $mailer ) use ( $actor, $admin_url ) {
/* translators: 1: Follower name */
$message = \sprintf( \__( 'New Follower: %1$s.', 'activitypub' ), $actor['name'] ) . "\r\n\r\n";
/* translators: Follower URL */
$message .= \sprintf( \__( 'URL: %s', 'activitypub' ), \esc_url( $actor['url'] ) ) . "\r\n\r\n";
$message .= \__( 'You can see all followers here:', 'activitypub' ) . "\r\n";
$message .= \esc_url( \admin_url( $admin_url ) ) . "\r\n\r\n";
$mailer->{'AltBody'} = $message;
};
\add_action( 'phpmailer_init', $alt_function );
\wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
\remove_action( 'phpmailer_init', $alt_function );
}
/**
* Send a direct message.
*
* @param array $activity The activity object.
* @param int $user_id The id of the local blog-user.
*/
public static function direct_message( $activity, $user_id ) {
if (
is_activity_public( $activity ) ||
// Only accept messages that have the user in the "to" field.
empty( $activity['to'] ) ||
! in_array( Actors::get_by_id( $user_id )->get_id(), (array) $activity['to'], true )
) {
return;
}
if ( $user_id > Actors::BLOG_USER_ID ) {
if ( ! \get_user_option( 'activitypub_mailer_new_dm', $user_id ) ) {
return;
}
$email = \get_userdata( $user_id )->user_email;
} else {
if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_dm', '1' ) ) {
return;
}
$email = \get_option( 'admin_email' );
}
$actor = get_remote_metadata_by_actor( $activity['actor'] );
if ( ! $actor || \is_wp_error( $actor ) || empty( $activity['object']['content'] ) ) {
return;
}
if ( empty( $actor['webfinger'] ) ) {
$actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
}
$template_args = array(
'activity' => $activity,
'actor' => $actor,
'user_id' => $user_id,
);
/* translators: 1: Blog name, 2 Actor name */
$subject = \sprintf( \esc_html__( '[%1$s] Direct Message from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
\ob_start();
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-dm.php', false, $template_args );
$html_message = \ob_get_clean();
$alt_function = function ( $mailer ) use ( $actor, $activity ) {
$content = \html_entity_decode(
\wp_strip_all_tags(
str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
),
ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
);
/* translators: Actor name */
$message = \sprintf( \esc_html__( 'New Direct Message: %s', 'activitypub' ), $content ) . "\r\n\r\n";
/* translators: Actor name */
$message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
/* translators: Message URL */
$message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
$mailer->{'AltBody'} = $message;
};
\add_action( 'phpmailer_init', $alt_function );
\wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
\remove_action( 'phpmailer_init', $alt_function );
}
/**
* Send a mention notification.
*
* @param array $activity The activity object.
* @param int $user_id The id of the local blog-user.
*/
public static function mention( $activity, $user_id ) {
if (
// Only accept messages that have the user in the "cc" field.
empty( $activity['cc'] ) ||
! in_array( Actors::get_by_id( $user_id )->get_id(), (array) $activity['cc'], true )
) {
return;
}
if ( $user_id > Actors::BLOG_USER_ID ) {
if ( ! \get_user_option( 'activitypub_mailer_new_mention', $user_id ) ) {
return;
}
$email = \get_userdata( $user_id )->user_email;
} else {
if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_mention', '1' ) ) {
return;
}
$email = \get_option( 'admin_email' );
}
$actor = get_remote_metadata_by_actor( $activity['actor'] );
if ( \is_wp_error( $actor ) ) {
return;
}
if ( empty( $actor['webfinger'] ) ) {
$actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
}
$template_args = array(
'activity' => $activity,
'actor' => $actor,
'user_id' => $user_id,
);
/* translators: 1: Blog name, 2 Actor name */
$subject = \sprintf( \esc_html__( '[%1$s] Mention from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
\ob_start();
\load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-mention.php', false, $template_args );
$html_message = \ob_get_clean();
$alt_function = function ( $mailer ) use ( $actor, $activity ) {
$content = \html_entity_decode(
\wp_strip_all_tags(
str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
),
ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
);
/* translators: Message content */
$message = \sprintf( \esc_html__( 'New Mention: %s', 'activitypub' ), $content ) . "\r\n\r\n";
/* translators: Actor name */
$message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
/* translators: Message URL */
$message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
$mailer->{'AltBody'} = $message;
};
\add_action( 'phpmailer_init', $alt_function );
\wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
\remove_action( 'phpmailer_init', $alt_function );
}
}

View File

@ -1,99 +1,77 @@
<?php
/**
* Mention class file.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_Error;
use Activitypub\Webfinger;
/**
* ActivityPub Mention Class
* ActivityPub Mention Class.
*
* @author Alex Kirk
*/
class Mention {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'the_content', array( self::class, 'the_content' ), 99, 1 );
\add_filter( 'comment_text', array( self::class, 'the_content' ), 10, 1 );
\add_filter( 'activitypub_extra_field_content', array( self::class, 'the_content' ), 10, 1 );
\add_filter( 'activitypub_extract_mentions', array( self::class, 'extract_mentions' ), 99, 2 );
\add_filter( 'activitypub_activity_object_array', array( self::class, 'filter_activity_object' ), 99 );
}
/**
* Filter to replace the mentions in the content with links
* Filter only the activity object and replace summery it with URLs
* add tag to user.
*
* @param string $the_content the post-content
* @param array $object_array Array of activity.
*
* @return string the filtered post-content
* @return array The activity object array.
*/
public static function filter_activity_object( $object_array ) {
if ( ! empty( $object_array['summary'] ) ) {
$object_array['summary'] = self::the_content( $object_array['summary'] );
}
if ( ! empty( $object_array['content'] ) ) {
$object_array['content'] = self::the_content( $object_array['content'] );
}
return $object_array;
}
/**
* Filter to replace the mentions in the content with links.
*
* @param string $the_content The post content.
*
* @return string The filtered post-content.
*/
public static function the_content( $the_content ) {
// small protection against execution timeouts: limit to 1 MB
if ( mb_strlen( $the_content ) > MB_IN_BYTES ) {
return $the_content;
}
$tag_stack = array();
$protected_tags = array(
'pre',
'code',
'textarea',
'style',
'a',
);
$content_with_links = '';
$in_protected_tag = false;
foreach ( wp_html_split( $the_content ) as $chunk ) {
if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
$content_with_links .= $chunk;
continue;
}
if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
$tag = strtolower( $m[2] );
if ( '/' === $m[1] ) {
// Closing tag.
$i = array_search( $tag, $tag_stack );
// We can only remove the tag from the stack if it is in the stack.
if ( false !== $i ) {
$tag_stack = array_slice( $tag_stack, 0, $i );
}
} else {
// Opening tag, add it to the stack.
$tag_stack[] = $tag;
}
// If we're in a protected tag, the tag_stack contains at least one protected tag string.
// The protected tag state can only change when we encounter a start or end tag.
$in_protected_tag = array_intersect( $tag_stack, $protected_tags );
// Never inspect tags.
$content_with_links .= $chunk;
continue;
}
if ( $in_protected_tag ) {
// Don't inspect a chunk inside an inspected tag.
$content_with_links .= $chunk;
continue;
}
// Only reachable when there is no protected tag in the stack.
$content_with_links .= \preg_replace_callback( '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/', array( self::class, 'replace_with_links' ), $chunk );
}
return $content_with_links;
return enrich_content_data( $the_content, '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/', array( self::class, 'replace_with_links' ) );
}
/**
* A callback for preg_replace to build the user links
* A callback for preg_replace to build the user links.
*
* @param array $result the preg_match results
* @param array $result The preg_match results.
*
* @return string the final string
* @return string The final string.
*/
public static function replace_with_links( $result ) {
$metadata = get_remote_metadata_by_actor( $result[0] );
if ( ! empty( $metadata ) && ! is_wp_error( $metadata ) && ! empty( $metadata['url'] ) ) {
if (
! empty( $metadata ) &&
! is_wp_error( $metadata ) &&
( ! empty( $metadata['id'] ) || ! empty( $metadata['url'] ) )
) {
$username = ltrim( $result[0], '@' );
if ( ! empty( $metadata['name'] ) ) {
$username = $metadata['name'];
@ -102,24 +80,20 @@ class Mention {
$username = $metadata['preferredUsername'];
}
$url = isset( $metadata['url'] ) ? $metadata['url'] : $metadata['id'];
$url = isset( $metadata['url'] ) ? object_to_uri( $metadata['url'] ) : object_to_uri( $metadata['id'] );
if ( \is_array( $url ) ) {
$url = $url[0];
}
return \sprintf( '<a rel="mention" class="u-url mention" href="%s">@<span>%s</span></a>', esc_url( $url ), esc_html( $username ) );
return \sprintf( '<a rel="mention" class="u-url mention" href="%1$s">@%2$s</a>', esc_url( $url ), esc_html( $username ) );
}
return $result[0];
}
/**
* Get the Inboxes for the mentioned Actors
* Get the Inboxes for the mentioned Actors.
*
* @param array $mentioned The list of Actors that were mentioned
* @param array $mentioned The list of Actors that were mentioned.
*
* @return array The list of Inboxes
* @return array The list of Inboxes.
*/
public static function get_inboxes( $mentioned ) {
$inboxes = array();
@ -136,11 +110,11 @@ class Mention {
}
/**
* Get the inbox from the Remote-Profile of a mentioned Actor
* Get the inbox from the Remote-Profile of a mentioned Actor.
*
* @param string $actor The Actor-URL
* @param string $actor The Actor URL.
*
* @return string The Inbox-URL
* @return string|WP_Error The Inbox-URL or WP_Error if not found.
*/
public static function get_inbox_by_mentioned_actor( $actor ) {
$metadata = get_remote_metadata_by_actor( $actor );
@ -149,7 +123,7 @@ class Mention {
return $metadata;
}
if ( isset( $metadata['endpoints'] ) && isset( $metadata['endpoints']['sharedInbox'] ) ) {
if ( isset( $metadata['endpoints']['sharedInbox'] ) ) {
return $metadata['endpoints']['sharedInbox'];
}
@ -163,10 +137,10 @@ class Mention {
/**
* Extract the mentions from the post_content.
*
* @param array $mentions The already found mentions.
* @param array $mentions The already found mentions.
* @param string $post_content The post content.
*
* @return mixed The discovered mentions.
* @return array The discovered mentions.
*/
public static function extract_mentions( $mentions, $post_content ) {
\preg_match_all( '/@' . ACTIVITYPUB_USERNAME_REGEXP . '/i', $post_content, $matches );
@ -176,6 +150,6 @@ class Mention {
$mentions[ $match ] = $link;
}
}
return $mentions;
return \array_unique( $mentions );
}
}

View File

@ -1,9 +1,17 @@
<?php
/**
* Migration class file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Activitypub;
use Activitypub\Model\Blog_User;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Extra_Fields;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Outbox;
use Activitypub\Transformer\Factory;
/**
* ActivityPub Migration Class
@ -12,10 +20,12 @@ use Activitypub\Collection\Followers;
*/
class Migration {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_migrate', array( self::class, 'async_migration' ) );
\add_action( 'activitypub_upgrade', array( self::class, 'async_upgrade' ), 10, 99 );
\add_action( 'activitypub_update_comment_counts', array( self::class, 'update_comment_counts' ), 10, 2 );
self::maybe_migrate();
}
@ -26,10 +36,14 @@ class Migration {
* This is the version that the database structure will be updated to.
* It is the same as the plugin version.
*
* @deprecated 4.2.0 Use constant ACTIVITYPUB_PLUGIN_VERSION directly.
*
* @return string The target version.
*/
public static function get_target_version() {
return get_plugin_version();
_deprecated_function( __FUNCTION__, '4.2.0', 'ACTIVITYPUB_PLUGIN_VERSION' );
return ACTIVITYPUB_PLUGIN_VERSION;
}
/**
@ -44,16 +58,23 @@ class Migration {
/**
* Locks the database migration process to prevent simultaneous migrations.
*
* @return void
* @return bool|int True if the lock was successful, timestamp of existing lock otherwise.
*/
public static function lock() {
\update_option( 'activitypub_migration_lock', \time() );
global $wpdb;
// Try to lock.
$lock_result = (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'no') /* LOCK */", 'activitypub_migration_lock', \time() ) ); // phpcs:ignore WordPress.DB
if ( ! $lock_result ) {
$lock_result = \get_option( 'activitypub_migration_lock' );
}
return $lock_result;
}
/**
* Unlocks the database migration process.
*
* @return void
*/
public static function unlock() {
\delete_option( 'activitypub_migration_lock' );
@ -87,9 +108,9 @@ class Migration {
* @return bool True if the database structure is up to date, false otherwise.
*/
public static function is_latest_version() {
return (bool) version_compare(
return (bool) \version_compare(
self::get_version(),
self::get_target_version(),
ACTIVITYPUB_PLUGIN_VERSION,
'=='
);
}
@ -110,30 +131,91 @@ class Migration {
$version_from_db = self::get_version();
// check for inital migration
// Check for initial migration.
if ( ! $version_from_db ) {
self::add_default_settings();
$version_from_db = self::get_target_version();
$version_from_db = ACTIVITYPUB_PLUGIN_VERSION;
}
// schedule the async migration
// Schedule the async migration.
if ( ! \wp_next_scheduled( 'activitypub_migrate', $version_from_db ) ) {
\wp_schedule_single_event( \time(), 'activitypub_migrate', $version_from_db );
\wp_schedule_single_event( \time(), 'activitypub_migrate', array( $version_from_db ) );
}
if ( version_compare( $version_from_db, '0.17.0', '<' ) ) {
if ( \version_compare( $version_from_db, '0.17.0', '<' ) ) {
self::migrate_from_0_16();
}
if ( version_compare( $version_from_db, '1.3.0', '<' ) ) {
if ( \version_compare( $version_from_db, '1.3.0', '<' ) ) {
self::migrate_from_1_2_0();
}
if ( version_compare( $version_from_db, '2.1.0', '<' ) ) {
if ( \version_compare( $version_from_db, '2.1.0', '<' ) ) {
self::migrate_from_2_0_0();
}
if ( version_compare( $version_from_db, '2.3.0', '<' ) ) {
if ( \version_compare( $version_from_db, '2.3.0', '<' ) ) {
self::migrate_from_2_2_0();
}
if ( \version_compare( $version_from_db, '3.0.0', '<' ) ) {
self::migrate_from_2_6_0();
}
if ( \version_compare( $version_from_db, '4.0.0', '<' ) ) {
self::migrate_to_4_0_0();
}
if ( \version_compare( $version_from_db, '4.1.0', '<' ) ) {
self::migrate_to_4_1_0();
}
if ( \version_compare( $version_from_db, '4.5.0', '<' ) ) {
\wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, 'activitypub_update_comment_counts' );
}
if ( \version_compare( $version_from_db, '4.7.1', '<' ) ) {
self::migrate_to_4_7_1();
}
if ( \version_compare( $version_from_db, '4.7.2', '<' ) ) {
self::migrate_to_4_7_2();
}
if ( \version_compare( $version_from_db, '4.7.3', '<' ) ) {
add_action( 'init', 'flush_rewrite_rules', 20 );
}
if ( \version_compare( $version_from_db, '5.0.0', '<' ) ) {
Scheduler::register_schedules();
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'create_post_outbox_items' ) );
\wp_schedule_single_event( \time() + 15, 'activitypub_upgrade', array( 'create_comment_outbox_items' ) );
add_action( 'init', 'flush_rewrite_rules', 20 );
}
if ( \version_compare( $version_from_db, '5.2.0', '<' ) ) {
Scheduler::register_schedules();
}
if ( \version_compare( $version_from_db, '5.4.0', '<' ) ) {
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_slashing' ) );
\wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_comment_author_emails' ) );
\add_action( 'init', 'flush_rewrite_rules', 20 );
}
if ( \version_compare( $version_from_db, '5.7.0', '<' ) ) {
self::delete_mastodon_api_orphaned_extra_fields();
}
if ( \version_compare( $version_from_db, '5.8.0', '<' ) ) {
self::update_notification_options();
}
update_option( 'activitypub_db_version', self::get_target_version() );
/*
* Add new update routines above this comment. ^
*
* Use 'unreleased' as the version number for new migrations and add tests for the callback directly.
* The release script will automatically replace it with the actual version number.
* Example:
*
* if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) {
* // Update routine.
* }
*/
/**
* Fires when the system has to be migrated.
*
* @param string $version_from_db The version from which to migrate.
* @param string $target_version The target version to migrate to.
*/
\do_action( 'activitypub_migrate', $version_from_db, ACTIVITYPUB_PLUGIN_VERSION );
\update_option( 'activitypub_db_version', ACTIVITYPUB_PLUGIN_VERSION );
self::unlock();
}
@ -144,22 +226,54 @@ class Migration {
* @param string $version_from_db The version from which to migrate.
*/
public static function async_migration( $version_from_db ) {
if ( version_compare( $version_from_db, '1.0.0', '<' ) ) {
if ( \version_compare( $version_from_db, '1.0.0', '<' ) ) {
self::migrate_from_0_17();
}
}
/**
* Updates the custom template to use shortcodes instead of the deprecated templates.
* Asynchronously runs upgrade routines.
*
* @return void
* @param callable $callback Callable upgrade routine. Must be a method of this class.
* @params mixed ...$args Optional. Parameters that get passed to the callback.
*/
public static function async_upgrade( $callback ) {
$args = \func_get_args();
// Bail if the existing lock is still valid.
if ( self::is_locked() ) {
\wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'activitypub_upgrade', $args );
return;
}
self::lock();
$callback = array_shift( $args ); // Remove $callback from arguments.
$next = \call_user_func_array( array( self::class, $callback ), $args );
self::unlock();
if ( ! empty( $next ) ) {
// Schedule the next run, adding the result to the arguments.
\wp_schedule_single_event(
\time() + 30,
'activitypub_upgrade',
\array_merge( array( $callback ), \array_values( $next ) )
);
}
}
/**
* Updates the custom template to use shortcodes instead of the deprecated templates.
*/
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.
/*
* 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.
@ -187,12 +301,10 @@ class Migration {
}
/**
* Updates the DB-schema of the followers-list
*
* @return void
* Updates the DB-schema of the followers-list.
*/
public static function migrate_from_0_17() {
// migrate followers
// Migrate followers.
foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
$followers = get_user_meta( $user_id, 'activitypub_followers', true );
@ -207,9 +319,7 @@ class Migration {
}
/**
* Clear the cache after updating to 1.3.0
*
* @return void
* Clear the cache after updating to 1.3.0.
*/
private static function migrate_from_1_2_0() {
$user_ids = \get_users(
@ -225,9 +335,7 @@ class Migration {
}
/**
* Unschedule Hooks after updating to 2.0.0
*
* @return void
* Unschedule Hooks after updating to 2.0.0.
*/
private static function migrate_from_2_0_0() {
wp_clear_scheduled_hook( 'activitypub_send_post_activity' );
@ -246,42 +354,591 @@ class Migration {
/**
* Add the ActivityPub capability to all users that can publish posts
* Delete old meta to store followers
*
* @return void
* Delete old meta to store followers.
*/
private static function migrate_from_2_2_0() {
// add the ActivityPub capability to all users that can publish posts
// Add the ActivityPub capability to all users that can publish posts.
self::add_activitypub_capability();
}
/**
* Set the defaults needed for the plugin to work
* Rename DB fields.
*/
private static function migrate_from_2_6_0() {
wp_cache_flush();
self::update_usermeta_key( 'activitypub_user_description', 'activitypub_description' );
self::update_options_key( 'activitypub_blog_user_description', 'activitypub_blog_description' );
self::update_options_key( 'activitypub_blog_user_identifier', 'activitypub_blog_identifier' );
}
/**
* * Update actor-mode settings.
* * Get the ID of the latest blog post and save it to the options table.
*/
private static function migrate_to_4_0_0() {
$latest_post_id = 0;
// Get the ID of the latest blog post and save it to the options table.
$latest_post = get_posts(
array(
'numberposts' => 1,
'orderby' => 'ID',
'order' => 'DESC',
'post_type' => 'any',
'post_status' => 'publish',
)
);
if ( $latest_post ) {
$latest_post_id = $latest_post[0]->ID;
}
\update_option( 'activitypub_last_post_with_permalink_as_id', $latest_post_id );
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
foreach ( $users as $user ) {
$followers = Followers::get_followers( $user->ID );
if ( $followers ) {
\update_user_option( $user->ID, 'activitypub_use_permalink_as_id', '1' );
}
}
$followers = Followers::get_followers( Actors::BLOG_USER_ID );
if ( $followers ) {
\update_option( 'activitypub_use_permalink_as_id_for_blog', '1' );
}
self::migrate_actor_mode();
}
/**
* Upate to 4.1.0
*
* * Add the ActivityPub capability to all users that can publish posts
* * Migrate the `activitypub_post_content_type` to only use `activitypub_custom_post_content`.
*/
public static function migrate_to_4_1_0() {
$content_type = \get_option( 'activitypub_post_content_type' );
switch ( $content_type ) {
case 'excerpt':
$template = "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
break;
case 'title':
$template = "[ap_title type=\"html\"]\n\n[ap_permalink type=\"html\"]";
break;
case 'content':
$template = "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]";
break;
case 'custom':
$template = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
break;
default:
$template = ACTIVITYPUB_CUSTOM_POST_CONTENT;
break;
}
\update_option( 'activitypub_custom_post_content', $template );
\delete_option( 'activitypub_post_content_type' );
$object_type = \get_option( 'activitypub_object_type', false );
if ( ! $object_type ) {
\update_option( 'activitypub_object_type', 'note' );
}
// Clean up empty visibility meta.
global $wpdb;
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"DELETE FROM $wpdb->postmeta
WHERE meta_key = 'activitypub_content_visibility'
AND (meta_value IS NULL OR meta_value = '')"
);
}
/**
* Updates post meta keys to be prefixed with an underscore.
*/
public static function migrate_to_4_7_1() {
global $wpdb;
$meta_keys = array(
'activitypub_actor_json',
'activitypub_canonical_url',
'activitypub_errors',
'activitypub_inbox',
'activitypub_user_id',
);
foreach ( $meta_keys as $meta_key ) {
// phpcs:ignore WordPress.DB
$wpdb->update( $wpdb->postmeta, array( 'meta_key' => '_' . $meta_key ), array( 'meta_key' => $meta_key ) );
}
}
/**
* Clears the post cache for Followers, we should have done this in 4.7.1 when we renamed those keys.
*/
public static function migrate_to_4_7_2() {
global $wpdb;
// phpcs:ignore WordPress.DB
$followers = $wpdb->get_col(
$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", Followers::POST_TYPE )
);
foreach ( $followers as $id ) {
clean_post_cache( $id );
}
}
/**
* Update comment counts for posts in batches.
*
* @return void
* @see Comment::pre_wp_update_comment_count_now()
* @param int $batch_size Optional. Number of posts to process per batch. Default 100.
* @param int $offset Optional. Number of posts to skip. Default 0.
*/
public static function update_comment_counts( $batch_size = 100, $offset = 0 ) {
global $wpdb;
// Bail if the existing lock is still valid.
if ( self::is_locked() ) {
\wp_schedule_single_event(
time() + ( 5 * MINUTE_IN_SECONDS ),
'activitypub_update_comment_counts',
array(
'batch_size' => $batch_size,
'offset' => $offset,
)
);
return;
}
self::lock();
Comment::register_comment_types();
$comment_types = Comment::get_comment_type_slugs();
$type_inclusion = "AND comment_type IN ('" . implode( "','", $comment_types ) . "')";
// Get and process this batch.
$post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT DISTINCT comment_post_ID FROM {$wpdb->comments} WHERE comment_approved = '1' {$type_inclusion} ORDER BY comment_post_ID LIMIT %d OFFSET %d",
$batch_size,
$offset
)
);
foreach ( $post_ids as $post_id ) {
\wp_update_comment_count_now( $post_id );
}
if ( count( $post_ids ) === $batch_size ) {
// Schedule next batch.
\wp_schedule_single_event(
time() + MINUTE_IN_SECONDS,
'activitypub_update_comment_counts',
array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
)
);
}
self::unlock();
}
/**
* Create outbox items for posts in batches.
*
* @param int $batch_size Optional. Number of posts to process per batch. Default 50.
* @param int $offset Optional. Number of posts to skip. Default 0.
* @return array|null Array with batch size and offset if there are more posts to process, null otherwise.
*/
public static function create_post_outbox_items( $batch_size = 50, $offset = 0 ) {
$posts = \get_posts(
array(
// our own `ap_outbox` will be excluded from `any` by virtue of its `exclude_from_search` arg.
'post_type' => 'any',
'posts_per_page' => $batch_size,
'offset' => $offset,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_status',
'value' => 'federated',
),
),
)
);
// Avoid multiple queries for post meta.
\update_postmeta_cache( \wp_list_pluck( $posts, 'ID' ) );
foreach ( $posts as $post ) {
$visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
self::add_to_outbox( $post, 'Create', $post->post_author, $visibility );
// Add Update activity when the post has been modified.
if ( $post->post_modified !== $post->post_date ) {
self::add_to_outbox( $post, 'Update', $post->post_author, $visibility );
}
}
if ( count( $posts ) === $batch_size ) {
return array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
);
}
return null;
}
/**
* Create outbox items for comments in batches.
*
* @param int $batch_size Optional. Number of posts to process per batch. Default 50.
* @param int $offset Optional. Number of posts to skip. Default 0.
* @return array|null Array with batch size and offset if there are more posts to process, null otherwise.
*/
public static function create_comment_outbox_items( $batch_size = 50, $offset = 0 ) {
$comments = \get_comments(
array(
'author__not_in' => array( 0 ), // Limit to comments by registered users.
'number' => $batch_size,
'offset' => $offset,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_status',
'value' => 'federated',
),
),
)
);
foreach ( $comments as $comment ) {
self::add_to_outbox( $comment, 'Create', $comment->user_id );
}
if ( count( $comments ) === $batch_size ) {
return array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
);
}
return null;
}
/**
* Update _activitypub_actor_json meta values to ensure they are properly slashed.
*
* @param int $batch_size Optional. Number of meta values to process per batch. Default 100.
* @param int $offset Optional. Number of meta values to skip. Default 0.
* @return array|null Array with batch size and offset if there are more meta values to process, null otherwise.
*/
public static function update_actor_json_slashing( $batch_size = 100, $offset = 0 ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$meta_values = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_actor_json' LIMIT %d OFFSET %d",
$batch_size,
$offset
)
);
foreach ( $meta_values as $meta ) {
$json = \json_decode( $meta->meta_value, true );
// If json_decode fails, try adding slashes.
if ( null === $json && \json_last_error() !== JSON_ERROR_NONE ) {
$escaped_value = \preg_replace( '#\\\\(?!["\\\\/bfnrtu])#', '\\\\\\\\', $meta->meta_value );
$json = \json_decode( $escaped_value, true );
// Update the meta if json_decode succeeds with slashes.
if ( null !== $json && \json_last_error() === JSON_ERROR_NONE ) {
\update_post_meta( $meta->post_id, '_activitypub_actor_json', \wp_slash( $escaped_value ) );
}
}
}
if ( \count( $meta_values ) === $batch_size ) {
return array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
);
}
return null;
}
/**
* Update comment author emails with webfinger addresses for ActivityPub comments.
*
* @param int $batch_size Optional. Number of comments to process per batch. Default 50.
* @param int $offset Optional. Number of comments to skip. Default 0.
* @return array|null Array with batch size and offset if there are more comments to process, null otherwise.
*/
public static function update_comment_author_emails( $batch_size = 50, $offset = 0 ) {
$comments = \get_comments(
array(
'number' => $batch_size,
'offset' => $offset,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'protocol',
'value' => 'activitypub',
),
),
)
);
foreach ( $comments as $comment ) {
$comment_author_url = $comment->comment_author_url;
if ( empty( $comment_author_url ) ) {
continue;
}
$webfinger = Webfinger::uri_to_acct( $comment_author_url );
if ( \is_wp_error( $webfinger ) ) {
continue;
}
\wp_update_comment(
array(
'comment_ID' => $comment->comment_ID,
'comment_author_email' => \str_replace( 'acct:', '', $webfinger ),
)
);
}
if ( count( $comments ) === $batch_size ) {
return array(
'batch_size' => $batch_size,
'offset' => $offset + $batch_size,
);
}
return null;
}
/**
* Set the defaults needed for the plugin to work.
*
* Add the ActivityPub capability to all users that can publish posts.
*/
public static function add_default_settings() {
self::add_activitypub_capability();
self::add_default_extra_field();
}
/**
* Add the ActivityPub capability to all users that can publish posts
* Add an activity to the outbox without federating it.
*
* @return void
* @param \WP_Post|\WP_Comment $comment The comment or post object.
* @param string $activity_type The type of activity.
* @param int $user_id The user ID.
* @param string $visibility Optional. The visibility of the content. Default 'public'.
*/
private static function add_to_outbox( $comment, $activity_type, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) {
$transformer = Factory::get_transformer( $comment );
if ( ! $transformer || \is_wp_error( $transformer ) ) {
return;
}
$activity = $transformer->to_activity( $activity_type );
if ( ! $activity || \is_wp_error( $activity ) ) {
return;
}
// If the user is disabled, fall back to the blog user when available.
if ( ! user_can_activitypub( $user_id ) ) {
if ( user_can_activitypub( Actors::BLOG_USER_ID ) ) {
$user_id = Actors::BLOG_USER_ID;
} else {
return;
}
}
$post_id = Outbox::add( $activity, $user_id, $visibility );
// Immediately set to publish, no federation needed.
\wp_publish_post( $post_id );
}
/**
* Add the ActivityPub capability to all users that can publish posts.
*/
private static function add_activitypub_capability() {
// get all WP_User objects that can publish posts
// Get all WP_User objects that can publish posts.
$users = \get_users(
array(
'capability__in' => array( 'publish_posts' ),
)
);
// add ActivityPub capability to all users that can publish posts
// Add ActivityPub capability to all users that can publish posts.
foreach ( $users as $user ) {
$user->add_cap( 'activitypub' );
}
}
/**
* Add a default extra field for the user.
*/
private static function add_default_extra_field() {
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
$title = \__( 'Powered by', 'activitypub' );
$content = 'WordPress';
// Add a default extra field for each user.
foreach ( $users as $user ) {
\wp_insert_post(
array(
'post_type' => Extra_Fields::USER_POST_TYPE,
'post_author' => $user->ID,
'post_status' => 'publish',
'post_title' => $title,
'post_content' => $content,
)
);
}
\wp_insert_post(
array(
'post_type' => Extra_Fields::BLOG_POST_TYPE,
'post_author' => 0,
'post_status' => 'publish',
'post_title' => $title,
'post_content' => $content,
)
);
}
/**
* Rename meta keys.
*
* @param string $old_key The old comment meta key.
* @param string $new_key The new comment meta key.
*/
private static function update_usermeta_key( $old_key, $new_key ) {
global $wpdb;
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->usermeta,
array( 'meta_key' => $new_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( 'meta_key' => $old_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( '%s' ),
array( '%s' )
);
}
/**
* Rename option keys.
*
* @param string $old_key The old option key.
* @param string $new_key The new option key.
*/
private static function update_options_key( $old_key, $new_key ) {
global $wpdb;
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->options,
array( 'option_name' => $new_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( 'option_name' => $old_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( '%s' ),
array( '%s' )
);
}
/**
* Migrate the actor mode settings.
*/
public static function migrate_actor_mode() {
$blog_profile = \get_option( 'activitypub_enable_blog_user', '0' );
$author_profiles = \get_option( 'activitypub_enable_users', '1' );
if (
'1' === $blog_profile &&
'1' === $author_profiles
) {
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE );
} elseif (
'1' === $blog_profile &&
'1' !== $author_profiles
) {
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_BLOG_MODE );
} elseif (
'1' !== $blog_profile &&
'1' === $author_profiles
) {
\update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
}
}
/**
* Deletes user extra fields where the author is the blog user.
*
* These extra fields were created when the Enable Mastodon Apps integration passed
* an author_url instead of a user_id to the mastodon_api_account filter. This caused
* Extra_Fields::default_actor_extra_fields() to run but fail to cache the fact it ran
* for non-existent users. The result is a number of user extra fields with no author.
*
* @ticket https://github.com/Automattic/wordpress-activitypub/pull/1554
*/
public static function delete_mastodon_api_orphaned_extra_fields() {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->delete(
$wpdb->posts,
array(
'post_type' => Extra_Fields::USER_POST_TYPE,
'post_author' => Actors::BLOG_USER_ID,
)
);
}
/**
* Update notification options.
*/
public static function update_notification_options() {
$new_dm = \get_option( 'activitypub_mailer_new_dm', '1' );
$new_follower = \get_option( 'activitypub_mailer_new_follower', '1' );
// Add the blog user notification options.
\add_option( 'activitypub_blog_user_mailer_new_dm', $new_dm );
\add_option( 'activitypub_blog_user_mailer_new_follower', $new_follower );
\add_option( 'activitypub_blog_user_mailer_new_mention', '1' );
// Add the actor notification options.
foreach ( Actors::get_collection() as $actor ) {
\update_user_option( $actor->get__id(), 'activitypub_mailer_new_dm', $new_dm );
\update_user_option( $actor->get__id(), 'activitypub_mailer_new_follower', $new_follower );
\update_user_option( $actor->get__id(), 'activitypub_mailer_new_mention', '1' );
}
// Delete the old notification options.
\delete_option( 'activitypub_mailer_new_dm' );
\delete_option( 'activitypub_mailer_new_follower' );
}
}

View File

@ -0,0 +1,313 @@
<?php
/**
* Move class file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Activity\Actor;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Model\Blog;
use Activitypub\Model\User;
/**
* ActivityPub (Account) Move Class
*
* @author Matthias Pfefferle
*/
class Move {
/**
* Initialize the Move class.
*/
public static function init() {
/**
* Filter to enable automatically moving Fediverse accounts when the domain changes.
*
* @param bool $domain_moves_enabled Whether domain moves are enabled.
*/
$domain_moves_enabled = apply_filters( 'activitypub_enable_primary_domain_moves', false );
if ( $domain_moves_enabled ) {
// Add the filter to change the domain.
\add_filter( 'update_option_home', array( self::class, 'change_domain' ), 10, 2 );
if ( get_option( 'activitypub_old_host' ) ) {
\add_action( 'activitypub_construct_model_actor', array( self::class, 'maybe_initiate_old_user' ) );
\add_action( 'activitypub_pre_send_to_inboxes', array( self::class, 'pre_send_to_inboxes' ) );
if ( ! is_user_type_disabled( 'blog' ) ) {
\add_filter( 'activitypub_pre_get_by_username', array( self::class, 'old_blog_username' ), 10, 2 );
}
}
}
}
/**
* Move an ActivityPub account from one location to another.
*
* @param string $from The current account URL.
* @param string $to The new account URL.
*
* @return int|bool|\WP_Error The ID of the outbox item or false or WP_Error on failure.
*/
public static function account( $from, $to ) {
if ( is_same_domain( $from ) && is_same_domain( $to ) ) {
return self::internally( $from, $to );
}
return self::externally( $from, $to );
}
/**
* Move an ActivityPub Actor from one location (internal) to another (external).
*
* This helps migrating local profiles to a new external profile:
*
* `Move::externally( 'https://example.com/?author=123', 'https://mastodon.example/users/foo' );`
*
* @param string $from The current account URL.
* @param string $to The new account URL.
*
* @return int|bool|\WP_Error The ID of the outbox item or false or WP_Error on failure.
*/
public static function externally( $from, $to ) {
$user = Actors::get_by_various( $from );
if ( \is_wp_error( $user ) ) {
return $user;
}
// Update the movedTo property.
if ( $user->get__id() > 0 ) {
\update_user_option( $user->get__id(), 'activitypub_moved_to', $to );
} else {
\update_option( 'activitypub_blog_user_moved_to', $to );
}
$response = Http::get_remote_object( $to );
if ( \is_wp_error( $response ) ) {
return $response;
}
$target_actor = new Actor();
$target_actor->from_array( $response );
// Check if the `Move` Activity is valid.
$also_known_as = $target_actor->get_also_known_as() ?? array();
if ( ! in_array( $from, $also_known_as, true ) ) {
return new \WP_Error( 'invalid_target', __( 'Invalid target', 'activitypub' ) );
}
$activity = new Activity();
$activity->set_type( 'Move' );
$activity->set_actor( $user->get_id() );
$activity->set_origin( $user->get_id() );
$activity->set_object( $user->get_id() );
$activity->set_target( $target_actor->get_id() );
// Add to outbox.
return add_to_outbox( $activity, null, $user->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC );
}
/**
* Internal Move.
*
* Move an ActivityPub Actor from one location (internal) to another (internal).
*
* This helps migrating abandoned profiles to `Move` to other profiles:
*
* `Move::internally( 'https://example.com/?author=123', 'https://example.com/?author=321' );`
*
* ... or to change Actor-IDs like:
*
* `Move::internally( 'https://example.com/author/foo', 'https://example.com/?author=123' );`
*
* @param string $from The current account URL.
* @param string $to The new account URL.
*
* @return int|bool|\WP_Error The ID of the outbox item or false or WP_Error on failure.
*/
public static function internally( $from, $to ) {
$user = Actors::get_by_various( $from );
if ( \is_wp_error( $user ) ) {
return $user;
}
// Add the old account URL to alsoKnownAs.
if ( $user->get__id() > 0 ) {
self::update_user_also_known_as( $user->get__id(), $from );
\update_user_option( $user->get__id(), 'activitypub_moved_to', $to );
} else {
self::update_blog_also_known_as( $from );
\update_option( 'activitypub_blog_user_moved_to', $to );
}
// check if `$from` is a URL or an ID.
if ( \filter_var( $from, FILTER_VALIDATE_URL ) ) {
$actor = $from;
} else {
$actor = $user->get_id();
}
$activity = new Activity();
$activity->set_type( 'Move' );
$activity->set_actor( $actor );
$activity->set_origin( $actor );
$activity->set_object( $actor );
$activity->set_target( $to );
return add_to_outbox( $activity, null, $user->get__id(), ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC );
}
/**
* Update the alsoKnownAs property of a user.
*
* @param int $user_id The user ID.
* @param string $from The current account URL.
*/
private static function update_user_also_known_as( $user_id, $from ) {
// phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$also_known_as = \get_user_option( 'activitypub_also_known_as', $user_id ) ?: array();
$also_known_as[] = $from;
\update_user_option( $user_id, 'activitypub_also_known_as', $also_known_as );
}
/**
* Update the alsoKnownAs property of the blog.
*
* @param string $from The current account URL.
*/
private static function update_blog_also_known_as( $from ) {
$also_known_as = \get_option( 'activitypub_blog_user_also_known_as', array() );
$also_known_as[] = $from;
\update_option( 'activitypub_blog_user_also_known_as', $also_known_as );
}
/**
* Change domain for all ActivityPub Actors.
*
* This method handles domain migration according to the ActivityPub Data Portability spec.
* It stores the old host and calls Move::internally for each available profile.
* It also caches the JSON representation of the old Actor for future lookups.
*
* @param string $from The old domain.
* @param string $to The new domain.
*
* @return array Array of results from Move::internally calls.
*/
public static function change_domain( $from, $to ) {
// Get all actors that need to be migrated.
$actors = Actors::get_all();
$results = array();
$to_host = \wp_parse_url( $to, \PHP_URL_HOST );
$from_host = \wp_parse_url( $from, \PHP_URL_HOST );
// Store the old host for future reference.
\update_option( 'activitypub_old_host', $from_host );
// Process each actor.
foreach ( $actors as $actor ) {
$actor_id = $actor->get_id();
// Replace the new host with the old host in the actor ID.
$old_actor_id = str_replace( $to_host, $from_host, $actor_id );
// Call Move::internally for this actor.
$result = self::internally( $old_actor_id, $actor_id );
if ( \is_wp_error( $result ) ) {
// Log the error and continue with the next actor.
Debug::write_log( 'Error moving actor: ' . $actor_id . ' - ' . $result->get_error_message() );
continue;
}
$json = str_replace( $to_host, $from_host, $actor->to_json() );
// Save the current actor data after migration.
if ( $actor instanceof Blog ) {
\update_option( 'activitypub_blog_user_old_host_data', $json, false );
} else {
\update_user_option( $actor->get__id(), 'activitypub_old_host_data', $json, false );
}
$results[] = array(
'actor' => $actor_id,
'result' => $result,
);
}
return $results;
}
/**
* Maybe initiate old user.
*
* This method checks if the current request domain matches the old host.
* If it does, it retrieves the cached data for the user and populates the instance.
*
* @param Blog|User $instance The Blog or User instance to populate.
*/
public static function maybe_initiate_old_user( $instance ) {
if ( ! Query::get_instance()->is_old_host_request() ) {
return;
}
if ( $instance instanceof Blog ) {
$cached_data = \get_option( 'activitypub_blog_user_old_host_data' );
} elseif ( $instance instanceof User ) {
$cached_data = \get_user_option( 'activitypub_old_host_data', $instance->get__id() );
}
if ( ! empty( $cached_data ) ) {
$instance->from_json( $cached_data );
}
}
/**
* Pre-send to inboxes.
*
* @param string $json The ActivityPub Activity JSON.
*/
public static function pre_send_to_inboxes( $json ) {
$json = json_decode( $json, true );
if ( 'Move' !== $json['type'] ) {
return;
}
if ( is_same_domain( $json['object'] ) ) {
return;
}
Query::get_instance()->set_old_host_request();
}
/**
* Filter to return the old blog username.
*
* @param null $pre The pre-existing value.
* @param string $username The username to check.
*
* @return Blog|null The old blog instance or null.
*/
public static function old_blog_username( $pre, $username ) {
$old_host = \get_option( 'activitypub_old_host' );
// Special case for Blog Actor on old host.
if ( $old_host === $username && Query::get_instance()->is_old_host_request() ) {
// Return a new Blog instance which will load the cached data in its constructor.
$pre = new Blog();
}
return $pre;
}
}

View File

@ -0,0 +1,77 @@
<?php
/**
* Notification file.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* Notification class.
*/
class Notification {
/**
* The type of the notification.
*
* @var string
*/
public $type;
/**
* The actor URL.
*
* @var string
*/
public $actor;
/**
* The Activity object.
*
* @var array
*/
public $object;
/**
* The WordPress User-Id.
*
* @var int
*/
public $target;
/**
* Notification constructor.
*
* @param string $type The type of the notification.
* @param string $actor The actor URL.
* @param array $activity The Activity object.
* @param int $target The WordPress User-Id.
*/
public function __construct( $type, $actor, $activity, $target ) {
$this->type = $type;
$this->actor = $actor;
$this->object = $activity;
$this->target = $target;
}
/**
* Send the notification.
*/
public function send() {
$type = \strtolower( $this->type );
/**
* Action to send ActivityPub notifications.
*
* @param Notification $instance The notification object.
*/
do_action( 'activitypub_notification', $this );
/**
* Type-specific action to send ActivityPub notifications.
*
* @param Notification $instance The notification object.
*/
do_action( "activitypub_notification_{$type}", $this );
}
}

View File

@ -0,0 +1,124 @@
<?php
/**
* Options file.
*
* @package ActivityPub
*/
namespace ActivityPub;
/**
* Options class.
*
* @package ActivityPub
*/
class Options {
/**
* Initialize the options.
*/
public static function init() {
\add_filter( 'pre_option_activitypub_actor_mode', array( self::class, 'pre_option_activitypub_actor_mode' ) );
\add_filter( 'pre_option_activitypub_authorized_fetch', array( self::class, 'pre_option_activitypub_authorized_fetch' ) );
\add_filter( 'pre_option_activitypub_shared_inbox', array( self::class, 'pre_option_activitypub_shared_inbox' ) );
\add_filter( 'pre_option_activitypub_vary_header', array( self::class, 'pre_option_activitypub_vary_header' ) );
\add_filter( 'pre_option_activitypub_allow_likes', array( self::class, 'maybe_disable_interactions' ) );
\add_filter( 'pre_option_activitypub_allow_replies', array( self::class, 'maybe_disable_interactions' ) );
}
/**
* Pre-get option filter for the Actor-Mode.
*
* @param string|false $pre The pre-get option value.
*
* @return string|false The actor mode or false if it should not be filtered.
*/
public static function pre_option_activitypub_actor_mode( $pre ) {
if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) && ACTIVITYPUB_SINGLE_USER_MODE ) {
return ACTIVITYPUB_BLOG_MODE;
}
if ( \defined( 'ACTIVITYPUB_DISABLE_USER' ) && ACTIVITYPUB_DISABLE_USER ) {
return ACTIVITYPUB_BLOG_MODE;
}
if ( \defined( 'ACTIVITYPUB_DISABLE_BLOG_USER' ) && ACTIVITYPUB_DISABLE_BLOG_USER ) {
return ACTIVITYPUB_ACTOR_MODE;
}
return $pre;
}
/**
* Pre-get option filter for the Authorized Fetch.
*
* @param string $pre The pre-get option value.
*
* @return string If the constant is defined, return the value, otherwise return the pre-get option value.
*/
public static function pre_option_activitypub_authorized_fetch( $pre ) {
if ( ! \defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) ) {
return $pre;
}
if ( ACTIVITYPUB_AUTHORIZED_FETCH ) {
return '1';
}
return '0';
}
/**
* Pre-get option filter for the Shared Inbox.
*
* @param string $pre The pre-get option value.
*
* @return string If the constant is defined, return the value, otherwise return the pre-get option value.
*/
public static function pre_option_activitypub_shared_inbox( $pre ) {
if ( ! \defined( 'ACTIVITYPUB_SHARED_INBOX_FEATURE' ) ) {
return $pre;
}
if ( ACTIVITYPUB_SHARED_INBOX_FEATURE ) {
return '1';
}
return '0';
}
/**
* Pre-get option filter for the Vary Header.
*
* @param string $pre The pre-get option value.
*
* @return string If the constant is defined, return the value, otherwise return the pre-get option value.
*/
public static function pre_option_activitypub_vary_header( $pre ) {
if ( ! \defined( 'ACTIVITYPUB_SEND_VARY_HEADER' ) ) {
return $pre;
}
if ( ACTIVITYPUB_SEND_VARY_HEADER ) {
return '1';
}
return '0';
}
/**
* Disallow interactions if the constant is set.
*
* @param bool $pre_option The value of the option.
* @return bool|string The value of the option.
*/
public static function maybe_disable_interactions( $pre_option ) {
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return '0';
}
return $pre_option;
}
}

View File

@ -0,0 +1,351 @@
<?php
/**
* Query class.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use Activitypub\Transformer\Factory;
/**
* Singleton class to handle and store the ActivityPub query.
*/
class Query {
/**
* The singleton instance.
*
* @var Query
*/
private static $instance;
/**
* The ActivityPub object.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object
*
* @var object
*/
private $activitypub_object;
/**
* The ActivityPub object ID.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-id
*
* @var string
*/
private $activitypub_object_id;
/**
* Whether the current request is an ActivityPub request.
*
* @var bool
*/
private $is_activitypub_request;
/**
* Whether the current request is from the old host.
*
* @var bool
*/
private $is_old_host_request;
/**
* The constructor.
*/
private function __construct() {
// Do nothing.
}
/**
* The destructor.
*/
public function __destruct() {
self::$instance = null;
}
/**
* Get the singleton instance.
*
* @return Query The singleton instance.
*/
public static function get_instance() {
if ( ! isset( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Get the ActivityPub object.
*
* @return object The ActivityPub object.
*/
public function get_activitypub_object() {
if ( $this->activitypub_object ) {
return $this->activitypub_object;
}
if ( $this->prepare_activitypub_data() ) {
return $this->activitypub_object;
}
$queried_object = $this->get_queried_object();
$transformer = Factory::get_transformer( $queried_object );
if ( $transformer && ! \is_wp_error( $transformer ) ) {
$this->activitypub_object = $transformer->to_object();
}
return $this->activitypub_object;
}
/**
* Get the ActivityPub object ID.
*
* @return string The ActivityPub object ID.
*/
public function get_activitypub_object_id() {
if ( $this->activitypub_object_id ) {
return $this->activitypub_object_id;
}
if ( $this->prepare_activitypub_data() ) {
return $this->activitypub_object_id;
}
$queried_object = $this->get_queried_object();
$transformer = Factory::get_transformer( $queried_object );
if ( $transformer && ! \is_wp_error( $transformer ) ) {
$this->activitypub_object_id = $transformer->to_id();
}
return $this->activitypub_object_id;
}
/**
* Prepare and set both ActivityPub object and ID for Outbox activities and virtual objects.
*
* @return bool True if an object was found and set, false otherwise.
*/
private function prepare_activitypub_data() {
$queried_object = $this->get_queried_object();
// Check for Outbox Activity.
if (
$queried_object instanceof \WP_Post &&
Outbox::POST_TYPE === $queried_object->post_type
) {
$activitypub_object = Outbox::maybe_get_activity( $queried_object );
// Check if the Outbox Activity is public.
if ( ! \is_wp_error( $activitypub_object ) ) {
$this->activitypub_object = $activitypub_object;
$this->activitypub_object_id = $this->activitypub_object->get_id();
return true;
}
}
if ( ! $queried_object ) {
// If the object is not a valid ActivityPub object, try to get a virtual object.
$activitypub_object = $this->maybe_get_virtual_object();
if ( $activitypub_object ) {
$this->activitypub_object = $activitypub_object;
$this->activitypub_object_id = $this->activitypub_object->get_id();
return true;
}
}
return false;
}
/**
* Get the queried object.
*
* This adds support for Comments by `?c=123` IDs and Users by `?author=123` and `@username` IDs.
*
* @return \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null The queried object.
*/
public function get_queried_object() {
$queried_object = \get_queried_object();
// Check Comment by ID.
if ( ! $queried_object ) {
$comment_id = \get_query_var( 'c' );
if ( $comment_id ) {
$queried_object = \get_comment( $comment_id );
}
}
// Check Post by ID (works for custom post types).
if ( ! $queried_object ) {
$post_id = \get_query_var( 'p' );
if ( $post_id ) {
$queried_object = \get_post( $post_id );
}
}
// Try to get Author by ID.
if ( ! $queried_object ) {
$url = $this->get_request_url();
$author_id = url_to_authorid( $url );
if ( $author_id ) {
$queried_object = \get_user_by( 'id', $author_id );
}
}
/**
* Filters the queried object.
*
* @param \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null $queried_object The queried object.
*/
return apply_filters( 'activitypub_queried_object', $queried_object );
}
/**
* Get the virtual object.
*
* Virtual objects are objects that are not stored in the database, but are created on the fly.
* The plugins currently supports two virtual objects: The Blog-Actor and the Application-Actor.
*
* @see \Activitypub\Model\Blog
* @see \Activitypub\Model\Application
*
* @return object|null The virtual object.
*/
protected function maybe_get_virtual_object() {
$url = $this->get_request_url();
if ( ! $url ) {
return null;
}
$author_id = url_to_authorid( $url );
if ( ! is_numeric( $author_id ) ) {
$author_id = $url;
}
$user = Actors::get_by_various( $author_id );
if ( \is_wp_error( $user ) || ! $user ) {
return null;
}
return $user;
}
/**
* Get the request URL.
*
* @return string|null The request URL.
*/
protected function get_request_url() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return null;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$url = \wp_unslash( $_SERVER['REQUEST_URI'] );
$url = \WP_Http::make_absolute_url( $url, \home_url() );
$url = \sanitize_url( $url );
return $url;
}
/**
* Check if the current request is an ActivityPub request.
*
* @return bool True if the request is an ActivityPub request, false otherwise.
*/
public function is_activitypub_request() {
if ( isset( $this->is_activitypub_request ) ) {
return $this->is_activitypub_request;
}
global $wp_query;
// One can trigger an ActivityPub request by adding `?activitypub` to the URL.
if (
isset( $wp_query->query_vars['activitypub'] ) ||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
isset( $_GET['activitypub'] )
) {
\defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
$this->is_activitypub_request = true;
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 ) ) {
\defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
$this->is_activitypub_request = true;
return true;
}
}
$this->is_activitypub_request = false;
return false;
}
/**
* Check if the current request is from the old host.
*
* @return bool True if the request is from the old host, false otherwise.
*/
public function is_old_host_request() {
if ( isset( $this->is_old_host_request ) ) {
return $this->is_old_host_request;
}
$old_host = \get_option( 'activitypub_old_host' );
if ( ! $old_host ) {
$this->is_old_host_request = false;
return false;
}
$request_host = isset( $_SERVER['HTTP_HOST'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
$referer_host = isset( $_SERVER['HTTP_REFERER'] ) ? \wp_parse_url( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : '';
// Check if the domain matches either the request domain or referer.
$check = $old_host === $request_host || $old_host === $referer_host;
$this->is_old_host_request = $check;
return $check;
}
/**
* Fake an old host request.
*
* @param bool $state Optional. The state to set. Default true.
*/
public function set_old_host_request( $state = true ) {
$this->is_old_host_request = $state;
}
}

View File

@ -0,0 +1,122 @@
<?php
/**
* Sanitization file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Model\Blog;
/**
* Sanitization class.
*/
class Sanitize {
/**
* Sanitize a list of URLs.
*
* @param string|array $value The value to sanitize.
* @return array The sanitized list of URLs.
*/
public static function url_list( $value ) {
if ( ! \is_array( $value ) ) {
$value = \explode( PHP_EOL, $value );
}
$value = \array_filter( $value );
$value = \array_map( 'trim', $value );
$value = \array_map( 'sanitize_url', $value );
$value = \array_unique( $value );
return \array_values( $value );
}
/**
* Sanitize a list of hosts.
*
* @param string $value The value to sanitize.
* @return string The sanitized list of hosts.
*/
public static function host_list( $value ) {
$value = \explode( PHP_EOL, $value );
$value = \array_map(
function ( $host ) {
$host = \trim( $host );
$host = \strtolower( $host );
$host = \set_url_scheme( $host );
$host = \sanitize_url( $host, array( 'http', 'https' ) );
// Remove protocol.
if ( \str_contains( $host, 'http' ) ) {
$host = \wp_parse_url( $host, PHP_URL_HOST );
}
return \filter_var( $host, FILTER_VALIDATE_DOMAIN );
},
$value
);
return \implode( PHP_EOL, \array_filter( $value ) );
}
/**
* Sanitize a blog identifier.
*
* @param string $value The value to sanitize.
* @return string The sanitized blog identifier.
*/
public static function blog_identifier( $value ) {
// Hack to allow dots in the username.
$parts = \explode( '.', $value );
$sanitized = \array_map( 'sanitize_title', $parts );
$sanitized = \implode( '.', $sanitized );
// Check for login or nicename.
$user = new \WP_User_Query(
array(
'search' => $sanitized,
'search_columns' => array( 'user_login', 'user_nicename' ),
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
)
);
if ( $user->get_results() ) {
\add_settings_error(
'activitypub_blog_identifier',
'activitypub_blog_identifier',
\esc_html__( 'You cannot use an existing author&#8217;s name for the blog profile ID.', 'activitypub' )
);
return Blog::get_default_username();
}
return $sanitized;
}
/**
* Get the sanitized value of a constant.
*
* @param mixed $value The constant value.
*
* @return string The sanitized value.
*/
public static function constant_value( $value ) {
if ( is_bool( $value ) ) {
return $value ? 'true' : 'false';
}
if ( is_string( $value ) ) {
return esc_attr( $value );
}
if ( is_array( $value ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
return print_r( $value, true );
}
return $value;
}
}

View File

@ -1,90 +1,80 @@
<?php
/**
* Scheduler class file.
*
* @package Activitypub
*/
namespace Activitypub;
use Activitypub\Transformer\Post;
use Activitypub\Collection\Users;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\Scheduler\Post;
use Activitypub\Scheduler\Actor;
use Activitypub\Scheduler\Comment;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Followers;
use function Activitypub\was_comment_sent;
use function Activitypub\is_user_type_disabled;
use function Activitypub\should_comment_be_federated;
use function Activitypub\get_remote_metadata_by_actor;
use Activitypub\Transformer\Factory;
/**
* ActivityPub Scheduler Class
* Scheduler class.
*
* @author Matthias Pfefferle
*/
class Scheduler {
/**
* Initialize the class, registering WordPress hooks
* Allowed batch callbacks.
*
* @var array
*/
private static $batch_callbacks = array();
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
// Post transitions
\add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 );
\add_action(
'edit_attachment',
function ( $post_id ) {
self::schedule_post_activity( 'publish', 'publish', $post_id );
}
);
\add_action(
'add_attachment',
function ( $post_id ) {
self::schedule_post_activity( 'publish', '', $post_id );
}
);
\add_action(
'delete_attachment',
function ( $post_id ) {
self::schedule_post_activity( 'trash', '', $post_id );
}
self::register_schedulers();
self::$batch_callbacks = array(
Dispatcher::$callback,
array( Dispatcher::class, 'retry_send_to_followers' ),
);
if ( ! ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS ) {
// Comment transitions
\add_action( 'transition_comment_status', array( self::class, 'schedule_comment_activity' ), 20, 3 );
\add_action(
'edit_comment',
function ( $comment_id ) {
self::schedule_comment_activity( 'approved', 'approved', $comment_id );
}
);
\add_action(
'wp_insert_comment',
function ( $comment_id ) {
self::schedule_comment_activity( 'approved', '', $comment_id );
}
);
}
// Follower Cleanups
// Follower Cleanups.
\add_action( 'activitypub_update_followers', array( self::class, 'update_followers' ) );
\add_action( 'activitypub_cleanup_followers', array( self::class, 'cleanup_followers' ) );
// profile updates for blog options
if ( ! is_user_type_disabled( 'blog' ) ) {
\add_action( 'update_option_site_icon', array( self::class, 'blog_user_update' ) );
\add_action( 'update_option_blogdescription', array( self::class, 'blog_user_update' ) );
\add_action( 'update_option_blogname', array( self::class, 'blog_user_update' ) );
\add_filter( 'pre_set_theme_mod_custom_logo', array( self::class, 'blog_user_update' ) );
\add_filter( 'pre_set_theme_mod_header_image', array( self::class, 'blog_user_update' ) );
}
// Event callbacks.
\add_action( 'activitypub_async_batch', array( self::class, 'async_batch' ), 10, 99 );
\add_action( 'activitypub_reprocess_outbox', array( self::class, 'reprocess_outbox' ) );
\add_action( 'activitypub_outbox_purge', array( self::class, 'purge_outbox' ) );
// profile updates for user options
if ( ! is_user_type_disabled( 'user' ) ) {
\add_action( 'wp_update_user', array( self::class, 'user_update' ) );
\add_action( 'updated_user_meta', array( self::class, 'user_meta_update' ), 10, 3 );
// @todo figure out a feasible way of updating the header image since it's not unique to any user.
}
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_outbox_activity_for_federation' ) );
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_announce_activity' ), 10, 4 );
\add_action( 'update_option_activitypub_outbox_purge_days', array( self::class, 'handle_outbox_purge_days_update' ), 10, 2 );
}
/**
* Register handlers.
*/
public static function register_schedulers() {
Post::init();
Actor::init();
Comment::init();
/**
* Register additional schedulers.
*
* @since 5.0.0
*/
do_action( 'activitypub_register_schedulers' );
}
/**
* Schedule all ActivityPub schedules.
*
* @return void
*/
public static function register_schedules() {
if ( ! \wp_next_scheduled( 'activitypub_update_followers' ) ) {
@ -94,119 +84,30 @@ class Scheduler {
if ( ! \wp_next_scheduled( 'activitypub_cleanup_followers' ) ) {
\wp_schedule_event( time(), 'daily', 'activitypub_cleanup_followers' );
}
if ( ! \wp_next_scheduled( 'activitypub_reprocess_outbox' ) ) {
\wp_schedule_event( time(), 'hourly', 'activitypub_reprocess_outbox' );
}
if ( ! wp_next_scheduled( 'activitypub_outbox_purge' ) ) {
wp_schedule_event( time(), 'daily', 'activitypub_outbox_purge' );
}
}
/**
* Unscedule all ActivityPub schedules.
* Un-schedule 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 ) {
$post = get_post( $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 ( empty( $type ) ) {
return;
}
$hook = 'activitypub_send_post';
$args = array( $post->ID, $type );
if ( false === wp_next_scheduled( $hook, $args ) ) {
set_wp_object_state( $post, 'federate' );
\wp_schedule_single_event( \time(), $hook, $args );
}
wp_unschedule_hook( 'activitypub_reprocess_outbox' );
wp_unschedule_hook( 'activitypub_outbox_purge' );
}
/**
* Schedule Comment Activities
*
* transition_comment_status()
*
* @param string $new_status New comment status.
* @param string $old_status Old comment status.
* @param WP_Comment $comment Comment object.
*/
public static function schedule_comment_activity( $new_status, $old_status, $comment ) {
$comment = get_comment( $comment );
// federate only comments that are written by a registered user.
if ( ! $comment->user_id ) {
return;
}
$type = false;
if (
'approved' === $new_status &&
'approved' !== $old_status
) {
$type = 'Create';
} elseif ( 'approved' === $new_status ) {
$type = 'Update';
\update_comment_meta( $comment->comment_ID, 'activitypub_comment_modified', time(), true );
} elseif (
'trash' === $new_status ||
'spam' === $new_status
) {
$type = 'Delete';
}
if ( empty( $type ) ) {
return;
}
// check if comment should be federated or not
if ( ! should_comment_be_federated( $comment ) ) {
return;
}
$hook = 'activitypub_send_comment';
$args = array( $comment->comment_ID, $type );
if ( false === wp_next_scheduled( $hook, $args ) ) {
set_wp_object_state( $comment, 'federate' );
\wp_schedule_single_event( \time(), $hook, $args );
}
}
/**
* Update followers
*
* @return void
* Update followers.
*/
public static function update_followers() {
$number = 5;
@ -215,6 +116,11 @@ class Scheduler {
$number = 50;
}
/**
* Filter the number of followers to update.
*
* @param int $number The number of followers to update.
*/
$number = apply_filters( 'activitypub_update_followers_number', $number );
$followers = Followers::get_outdated_followers( $number );
@ -231,9 +137,7 @@ class Scheduler {
}
/**
* Cleanup followers
*
* @return void
* Cleanup followers.
*/
public static function cleanup_followers() {
$number = 5;
@ -242,6 +146,11 @@ class Scheduler {
$number = 50;
}
/**
* Filter the number of followers to clean up.
*
* @param int $number The number of followers to clean up.
*/
$number = apply_filters( 'activitypub_update_followers_number', $number );
$followers = Followers::get_faulty_followers( $number );
@ -253,6 +162,11 @@ class Scheduler {
} elseif ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
if ( $follower->count_errors() >= 5 ) {
$follower->delete();
\wp_schedule_single_event(
\time(),
'activitypub_delete_actor_interactions',
array( $follower->get_id() )
);
} else {
Followers::add_error( $follower->get__id(), $meta );
}
@ -263,66 +177,268 @@ class Scheduler {
}
/**
* Send a profile update when relevant user meta is updated.
* Schedule the outbox item for federation.
*
* @param int $meta_id Meta ID being updated.
* @param int $user_id User ID being updated.
* @param string $meta_key Meta key being updated.
*
* @return void
* @param int $id The ID of the outbox item.
* @param int $offset The offset to add to the scheduled time.
*/
public static function user_meta_update( $meta_id, $user_id, $meta_key ) {
// don't bother if the user can't publish
if ( ! \user_can( $user_id, 'activitypub' ) ) {
return;
}
// the user meta fields that affect a profile.
$fields = array(
'activitypub_user_description',
'description',
'user_url',
'display_name',
);
if ( in_array( $meta_key, $fields, true ) ) {
self::schedule_profile_update( $user_id );
public static function schedule_outbox_activity_for_federation( $id, $offset = 0 ) {
$hook = 'activitypub_process_outbox';
$args = array( $id );
if ( false === wp_next_scheduled( $hook, $args ) ) {
\wp_schedule_single_event(
\time() + $offset,
$hook,
$args
);
}
}
/**
* Send a profile update when a user is updated.
*
* @param int $user_id User ID being updated.
*
* @return void
* Reprocess the outbox.
*/
public static function user_update( $user_id ) {
// don't bother if the user can't publish
if ( ! \user_can( $user_id, 'activitypub' ) ) {
public static function reprocess_outbox() {
// Bail if there is a pending batch.
if ( self::next_scheduled_hook( 'activitypub_async_batch' ) ) {
return;
}
self::schedule_profile_update( $user_id );
}
// Bail if there is a batch in progress.
$key = \md5( \serialize( Dispatcher::$callback ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
if ( self::is_locked( $key ) ) {
return;
}
/**
* Theme mods only have a dynamic filter so we fudge it like this.
* @param mixed $value
* @return mixed
*/
public static function blog_user_update( $value = null ) {
self::schedule_profile_update( 0 );
return $value;
}
/**
* Send a profile update to all followers. Gets hooked into all relevant options/meta etc.
* @param int $user_id The user ID to update (Could be 0 for Blog-User).
*/
public static function schedule_profile_update( $user_id ) {
\wp_schedule_single_event(
\time(),
'activitypub_send_update_profile_activity',
array( $user_id )
$ids = \get_posts(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => 'pending',
'posts_per_page' => 10,
'fields' => 'ids',
)
);
foreach ( $ids as $id ) {
self::schedule_outbox_activity_for_federation( $id );
}
}
/**
* Purge outbox items based on a schedule.
*/
public static function purge_outbox() {
$total_posts = (int) wp_count_posts( Outbox::POST_TYPE )->publish;
if ( $total_posts <= 20 ) {
return;
}
$days = (int) get_option( 'activitypub_outbox_purge_days', 180 );
$timezone = new \DateTimeZone( 'UTC' );
$date = new \DateTime( 'now', $timezone );
$date->sub( \DateInterval::createFromDateString( "$days days" ) );
$post_ids = get_posts(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'numberposts' => -1,
'date_query' => array(
array(
'before' => $date->format( 'Y-m-d' ),
),
),
)
);
foreach ( $post_ids as $post_id ) {
\wp_delete_post( $post_id, true );
}
}
/**
* Update schedules when outbox purge days settings change.
*
* @param int $old_value The old value.
* @param int $value The new value.
*/
public static function handle_outbox_purge_days_update( $old_value, $value ) {
if ( 0 === (int) $value ) {
wp_clear_scheduled_hook( 'activitypub_outbox_purge' );
} elseif ( ! wp_next_scheduled( 'activitypub_outbox_purge' ) ) {
wp_schedule_event( time(), 'daily', 'activitypub_outbox_purge' );
}
}
/**
* Asynchronously runs batch processing routines.
*
* The batching part is optional and only comes into play if the callback returns anything.
* Beyond that it's a helper to run a callback asynchronously with locking to prevent simultaneous processing.
*
* @param callable $callback Callable processing routine.
* @params mixed ...$args Optional. Parameters that get passed to the callback.
*/
public static function async_batch( $callback ) {
if ( ! in_array( $callback, self::$batch_callbacks, true ) || ! \is_callable( $callback ) ) {
_doing_it_wrong( __METHOD__, 'The first argument must be a valid callback.', '5.2.0' );
return;
}
$args = \func_get_args(); // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue
$key = \md5( \serialize( $callback ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
// Bail if the existing lock is still valid.
if ( self::is_locked( $key ) ) {
\wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'activitypub_async_batch', $args );
return;
}
self::lock( $key );
$callback = array_shift( $args ); // Remove $callback from arguments.
$next = \call_user_func_array( $callback, $args );
self::unlock( $key );
if ( ! empty( $next ) ) {
// Schedule the next run, adding the result to the arguments.
\wp_schedule_single_event(
\time() + 30,
'activitypub_async_batch',
\array_merge( array( $callback ), \array_values( $next ) )
);
}
}
/**
* Locks the async batch process for individual callbacks to prevent simultaneous processing.
*
* @param string $key Serialized callback name.
* @return bool|int True if the lock was successful, timestamp of existing lock otherwise.
*/
public static function lock( $key ) {
global $wpdb;
// Try to lock.
$lock_result = (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'no') /* LOCK */", 'activitypub_async_batch_' . $key, \time() ) ); // phpcs:ignore WordPress.DB
if ( ! $lock_result ) {
$lock_result = \get_option( 'activitypub_async_batch_' . $key );
}
return $lock_result;
}
/**
* Unlocks processing for the async batch callback.
*
* @param string $key Serialized callback name.
*/
public static function unlock( $key ) {
\delete_option( 'activitypub_async_batch_' . $key );
}
/**
* Whether the async batch callback is locked.
*
* @param string $key Serialized callback name.
* @return boolean
*/
public static function is_locked( $key ) {
$lock = \get_option( 'activitypub_async_batch_' . $key );
if ( ! $lock ) {
return false;
}
$lock = (int) $lock;
if ( $lock < \time() - 1800 ) {
self::unlock( $key );
return false;
}
return true;
}
/**
* Get the next scheduled hook.
*
* @param string $hook The hook name.
* @return int|bool The timestamp of the next scheduled hook, or false if none found.
*/
private static function next_scheduled_hook( $hook ) {
$crons = _get_cron_array();
if ( empty( $crons ) ) {
return false;
}
// Get next event.
$next = false;
foreach ( $crons as $timestamp => $cron ) {
if ( isset( $cron[ $hook ] ) ) {
$next = $timestamp;
break;
}
}
return $next;
}
/**
* Send announces.
*
* @param int $outbox_activity_id The outbox activity ID.
* @param \Activitypub\Activity\Activity $activity The activity object.
* @param int $actor_id The actor ID.
* @param int $content_visibility The content visibility.
*/
public static function schedule_announce_activity( $outbox_activity_id, $activity, $actor_id, $content_visibility ) {
// Only if we're in both Blog and User modes.
if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE !== \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
return;
}
// Only if this isn't the Blog Actor.
if ( Actors::BLOG_USER_ID === $actor_id ) {
return;
}
// Only if the content is public or quiet public.
if ( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC !== $content_visibility ) {
return;
}
// Only if the activity is a Create.
if ( 'Create' !== $activity->get_type() ) {
return;
}
if ( ! is_object( $activity->get_object() ) ) {
return;
}
// Check if the object is an article, image, audio, video, event, or document and ignore profile updates and other activities.
if ( ! in_array( $activity->get_object()->get_type(), Base_Object::TYPES, true ) ) {
return;
}
$announce = new Activity();
$announce->set_type( 'Announce' );
$announce->set_actor( Actors::get_by_id( Actors::BLOG_USER_ID )->get_id() );
$announce->set_object( $activity );
$outbox_activity_id = Outbox::add( $announce, Actors::BLOG_USER_ID );
if ( ! $outbox_activity_id ) {
return;
}
// Schedule the outbox item for federation.
self::schedule_outbox_activity_for_federation( $outbox_activity_id, 120 );
}
}

View File

@ -1,11 +1,18 @@
<?php
/**
* Shortcodes class file.
*
* @package Activitypub
*/
namespace Activitypub;
use function Activitypub\esc_hashtag;
/**
* Shortcodes class.
*/
class Shortcodes {
/**
* Register the shortcodes
* Register the shortcodes.
*/
public static function register() {
foreach ( get_class_methods( self::class ) as $shortcode ) {
@ -16,7 +23,7 @@ class Shortcodes {
}
/**
* Unregister the shortcodes
* Unregister the shortcodes.
*/
public static function unregister() {
foreach ( get_class_methods( self::class ) as $shortcode ) {
@ -27,15 +34,11 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_hashtags' shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_hashtags' shortcode.
*
* @return string The post tags as hashtags.
*/
public static function hashtags( $atts, $content, $tag ) {
public static function hashtags() {
$item = self::get_item();
if ( ! $item ) {
@ -51,6 +54,11 @@ class Shortcodes {
$hash_tags = array();
foreach ( $tags as $tag ) {
// Tag can be empty.
if ( ! $tag ) {
continue;
}
$hash_tags[] = \sprintf(
'<a rel="tag" class="hashtag u-tag u-category" href="%s">%s</a>',
\esc_url( \get_tag_link( $tag ) ),
@ -77,7 +85,23 @@ class Shortcodes {
return '';
}
return \wp_strip_all_tags( \get_the_title( $item->ID ), true );
$title = \wp_strip_all_tags( \get_the_title( $item->ID ), true );
if ( ! $title ) {
return '';
}
$atts = shortcode_atts(
array( 'type' => 'plain' ),
$atts,
$tag
);
if ( 'html' !== $atts['type'] ) {
return $title;
}
return sprintf( '<h2>%s</h2>', $title );
}
/**
@ -108,87 +132,14 @@ class Shortcodes {
$excerpt_length = ACTIVITYPUB_EXCERPT_LENGTH;
}
$excerpt = \get_post_field( 'post_excerpt', $item );
if ( 'attachment' === $item->post_type ) {
// get title of attachment with fallback to alt text.
$content = wp_get_attachment_caption( $item->ID );
if ( empty( $content ) ) {
$content = get_post_meta( $item->ID, '_wp_attachment_image_alt', true );
}
} elseif ( '' === $excerpt ) {
$content = \get_post_field( 'post_content', $item );
// An empty string will make wp_trim_excerpt do stuff we do not want.
if ( '' !== $content ) {
$excerpt = \strip_shortcodes( $content );
/** This filter is documented in wp-includes/post-template.php */
$excerpt = \apply_filters( 'the_content', $excerpt );
$excerpt = \str_replace( ']]>', ']]&gt;', $excerpt );
}
}
// Strip out any remaining tags.
$excerpt = \wp_strip_all_tags( $excerpt );
$excerpt_more = \apply_filters( 'activitypub_excerpt_more', ' [&hellip;]' );
$excerpt_more_len = strlen( $excerpt_more );
// We now have a excerpt, but we need to check it's length, it may be longer than we want for two reasons:
//
// * The user has entered a manual excerpt which is longer that what we want.
// * No manual excerpt exists so we've used the content which might be longer than we want.
//
// Either way, let's trim it up if we need too. Also, don't forget to take into account the more indicator
// as part of the total length.
//
// Setup a variable to hold the current excerpts length.
$current_excerpt_length = strlen( $excerpt );
// Setup a variable to keep track of our target length.
$target_excerpt_length = $excerpt_length - $excerpt_more_len;
// Setup a variable to keep track of the current max length.
$current_excerpt_max = $target_excerpt_length;
// This is a loop since we can't calculate word break the string after 'the_excpert' filter has run (we would break
// all kinds of html tags), so we have to cut the excerpt down a bit at a time until we hit our target length.
while ( $current_excerpt_length > $target_excerpt_length && $current_excerpt_max > 0 ) {
// Trim the excerpt based on wordwrap() positioning.
// Note: we're using <br> as the linebreak just in case there are any newlines existing in the excerpt from the user.
// There won't be any <br> left after we've run wp_strip_all_tags() in the code above, so they're
// safe to use here. It won't be included in the final excerpt as the substr() will trim it off.
$excerpt = substr( $excerpt, 0, strpos( wordwrap( $excerpt, $current_excerpt_max, '<br>' ), '<br>' ) );
// If something went wrong, or we're in a language that wordwrap() doesn't understand,
// just chop it off and don't worry about breaking in the middle of a word.
if ( strlen( $excerpt ) > $excerpt_length - $excerpt_more_len ) {
$excerpt = substr( $excerpt, 0, $current_excerpt_max );
}
// Add in the more indicator.
$excerpt = $excerpt . $excerpt_more;
// Run it through the excerpt filter which will add some html tags back in.
$excerpt_filtered = apply_filters( 'the_excerpt', $excerpt );
// Now set the current excerpt length to this new filtered length.
$current_excerpt_length = strlen( $excerpt_filtered );
// Check to see if we're over the target length.
if ( $current_excerpt_length > $target_excerpt_length ) {
// If so, remove 20 characters from the current max and run the loop again.
$current_excerpt_max = $current_excerpt_max - 20;
}
}
$excerpt = generate_post_summary( $item, $excerpt_length );
/** This filter is documented in wp-includes/post-template.php */
return \apply_filters( 'the_excerpt', $excerpt );
}
/**
* Generates output for the 'ap_content' Shortcode
* Generates output for the 'ap_content' Shortcode.
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
@ -203,7 +154,7 @@ class Shortcodes {
return '';
}
// prevent inception
// Prevent inception.
remove_shortcode( 'ap_content' );
$atts = shortcode_atts(
@ -215,35 +166,40 @@ class Shortcodes {
$content = '';
if ( 'attachment' === $item->post_type ) {
// get title of attachment with fallback to alt text.
// Get title of attachment with fallback to alt text.
$content = wp_get_attachment_caption( $item->ID );
if ( empty( $content ) ) {
$content = get_post_meta( $item->ID, '_wp_attachment_image_alt', true );
}
} else {
$content = \get_post_field( 'post_content', $item );
if ( 'yes' === $atts['apply_filters'] ) {
$content = \apply_filters( 'the_content', $content );
} else {
$content = do_blocks( $content );
$content = wptexturize( $content );
$content = wp_filter_content_tags( $content );
}
// 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 ) );
}
if ( empty( $content ) ) {
$content = \get_post_field( 'post_content', $item );
}
if ( 'yes' === $atts['apply_filters'] ) {
/** This filter is documented in wp-includes/post-template.php */
$content = \apply_filters( 'the_content', $content );
} else {
if ( site_supports_blocks() ) {
$content = \do_blocks( $content );
}
$content = \wptexturize( $content );
$content = \wp_filter_content_tags( $content );
}
// 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 The Shortcode attributes.
* @param string $content The ActivityPub post-content.
@ -266,7 +222,7 @@ class Shortcodes {
$tag
);
if ( 'url' === $atts['type'] ) {
if ( 'html' !== $atts['type'] ) {
return \esc_url( \get_permalink( $item->ID ) );
}
@ -277,7 +233,7 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_shortlink' Shortcode
* Generates output for the 'ap_shortlink' Shortcode.
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
@ -300,7 +256,7 @@ class Shortcodes {
$tag
);
if ( 'url' === $atts['type'] ) {
if ( 'html' !== $atts['type'] ) {
return \esc_url( \wp_get_shortlink( $item->ID ) );
}
@ -311,7 +267,7 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_image' Shortcode
* Generates output for the 'ap_image' Shortcode.
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
@ -354,15 +310,11 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_hashcats' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_hashcats' Shortcode.
*
* @return string The post categories as hashtags.
*/
public static function hashcats( $atts, $content, $tag ) {
public static function hashcats() {
$item = self::get_item();
if ( ! $item ) {
@ -389,15 +341,11 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_author' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_author' Shortcode.
*
* @return string The author name.
*/
public static function author( $atts, $content, $tag ) {
public static function author() {
$item = self::get_item();
if ( ! $item ) {
@ -405,7 +353,7 @@ class Shortcodes {
}
$author_id = \get_post_field( 'post_author', $item->ID );
$name = \get_the_author_meta( 'display_name', $author_id );
$name = \get_the_author_meta( 'display_name', $author_id );
if ( ! $name ) {
return '';
@ -415,15 +363,11 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_authorurl' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_authorurl' Shortcode.
*
* @return string The author URL.
*/
public static function authorurl( $atts, $content, $tag ) {
public static function authorurl() {
$item = self::get_item();
if ( ! $item ) {
@ -431,7 +375,7 @@ class Shortcodes {
}
$author_id = \get_post_field( 'post_author', $item->ID );
$url = \get_the_author_meta( 'user_url', $author_id );
$url = \get_the_author_meta( 'user_url', $author_id );
if ( ! $url ) {
return '';
@ -441,63 +385,46 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_blogurl' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_blogurl' Shortcode.
*
* @return string The site URL.
*/
public static function blogurl( $atts, $content, $tag ) {
public static function blogurl() {
return \esc_url( \get_bloginfo( 'url' ) );
}
/**
* Generates output for the 'ap_blogname' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_blogname' Shortcode.
*
* @return string
*/
public static function blogname( $atts, $content, $tag ) {
public static function blogname() {
return \wp_strip_all_tags( \get_bloginfo( 'name' ) );
}
/**
* Generates output for the 'ap_blogdesc' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_blogdesc' Shortcode.
*
* @return string The site description.
*/
public static function blogdesc( $atts, $content, $tag ) {
public static function blogdesc() {
return \wp_strip_all_tags( \get_bloginfo( 'description' ) );
}
/**
* Generates output for the 'ap_date' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_date' Shortcode.
*
* @return string The post date.
*/
public static function date( $atts, $content, $tag ) {
public static function date() {
$item = self::get_item();
if ( ! $item ) {
return '';
}
$datetime = \get_post_datetime( $item );
$datetime = \get_post_datetime( $item );
$dateformat = \get_option( 'date_format' );
$timeformat = \get_option( 'time_format' );
$date = $datetime->format( $dateformat );
@ -509,23 +436,18 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_time' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_time' Shortcode.
*
* @return string The post time.
*/
public static function time( $atts, $content, $tag ) {
public static function time() {
$item = self::get_item();
if ( ! $item ) {
return '';
}
$datetime = \get_post_datetime( $item );
$dateformat = \get_option( 'date_format' );
$datetime = \get_post_datetime( $item );
$timeformat = \get_option( 'time_format' );
$date = $datetime->format( $timeformat );
@ -538,22 +460,18 @@ class Shortcodes {
}
/**
* Generates output for the 'ap_datetime' Shortcode
*
* @param array $atts The Shortcode attributes.
* @param string $content The ActivityPub post-content.
* @param string $tag The tag/name of the Shortcode.
* Generates output for the 'ap_datetime' Shortcode.
*
* @return string The post date/time.
*/
public static function datetime( $atts, $content, $tag ) {
public static function datetime() {
$item = self::get_item();
if ( ! $item ) {
return '';
}
$datetime = \get_post_datetime( $item );
$datetime = \get_post_datetime( $item );
$dateformat = \get_option( 'date_format' );
$timeformat = \get_option( 'time_format' );
@ -572,7 +490,7 @@ class Shortcodes {
* Checks if item (WP_Post) is "public", a supported post type
* and not password protected.
*
* @return null|WP_Post The WordPress item.
* @return null|\WP_Post The WordPress item.
*/
protected static function get_item() {
$post = \get_post();

View File

@ -1,14 +1,20 @@
<?php
/**
* Signature class file.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_Error;
use DateTime;
use DateTimeZone;
use WP_REST_Request;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
/**
* ActivityPub Signature Class
* ActivityPub Signature Class.
*
* @author Matthias Pfefferle
* @author Django Doucet
@ -19,7 +25,7 @@ class Signature {
* Return the public key for a given user.
*
* @param int $user_id The WordPress User ID.
* @param bool $force Force the generation of a new key pair.
* @param bool $force Optional. Force the generation of a new key pair. Default false.
*
* @return mixed The public key.
*/
@ -37,7 +43,7 @@ class Signature {
* Return the private key for a given user.
*
* @param int $user_id The WordPress User ID.
* @param bool $force Force the generation of a new key pair.
* @param bool $force Optional. Force the generation of a new key pair. Default false.
*
* @return mixed The private key.
*/
@ -60,7 +66,7 @@ class Signature {
*/
public static function get_keypair_for( $user_id ) {
$option_key = self::get_signature_options_key_for( $user_id );
$key_pair = \get_option( $option_key );
$key_pair = \get_option( $option_key );
if ( ! $key_pair ) {
$key_pair = self::generate_key_pair_for( $user_id );
@ -78,7 +84,7 @@ class Signature {
*/
protected static function generate_key_pair_for( $user_id ) {
$option_key = self::get_signature_options_key_for( $user_id );
$key_pair = self::check_legacy_key_pair_for( $user_id );
$key_pair = self::check_legacy_key_pair_for( $user_id );
if ( $key_pair ) {
\add_option( $option_key, $key_pair );
@ -87,19 +93,21 @@ class Signature {
}
$config = array(
'digest_alg' => 'sha512',
'digest_alg' => 'sha512',
'private_key_bits' => 2048,
'private_key_type' => \OPENSSL_KEYTYPE_RSA,
);
$key = \openssl_pkey_new( $config );
$key = \openssl_pkey_new( $config );
$priv_key = null;
$detail = array();
if ( $key ) {
\openssl_pkey_export( $key, $priv_key );
\openssl_pkey_export( $key, $priv_key );
$detail = \openssl_pkey_get_details( $key );
}
$detail = \openssl_pkey_get_details( $key );
// check if keys are valid
// Check if keys are valid.
if (
empty( $priv_key ) || ! is_string( $priv_key ) ||
! isset( $detail['key'] ) || ! is_string( $detail['key'] )
@ -115,7 +123,7 @@ class Signature {
'public_key' => $detail['key'],
);
// persist keys
// Persist keys.
\add_option( $option_key, $key_pair );
return $key_pair;
@ -133,7 +141,7 @@ class Signature {
if ( $user_id > 0 ) {
$user = \get_userdata( $user_id );
// sanatize username because it could include spaces and special chars
// Sanitize username because it could include spaces and special chars.
$id = sanitize_title( $user->user_login );
}
@ -150,15 +158,15 @@ class Signature {
protected static function check_legacy_key_pair_for( $user_id ) {
switch ( $user_id ) {
case 0:
$public_key = \get_option( 'activitypub_blog_user_public_key' );
$public_key = \get_option( 'activitypub_blog_user_public_key' );
$private_key = \get_option( 'activitypub_blog_user_private_key' );
break;
case -1:
$public_key = \get_option( 'activitypub_application_user_public_key' );
$public_key = \get_option( 'activitypub_application_user_public_key' );
$private_key = \get_option( 'activitypub_application_user_private_key' );
break;
default:
$public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
$public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
$private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true );
break;
}
@ -174,18 +182,18 @@ class Signature {
}
/**
* Generates the Signature for a HTTP Request
* Generates the Signature for an 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.
* @param string $digest Optional. The digest of the request body. Default null.
*
* @return string The signature.
*/
public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) {
$user = Users::get_by_id( $user_id );
$user = Actors::get_by_id( $user_id );
$key = self::get_private_key_for( $user->get__id() );
$url_parts = \wp_parse_url( $url );
@ -193,12 +201,12 @@ class Signature {
$host = $url_parts['host'];
$path = '/';
// add path
// Add path.
if ( ! empty( $url_parts['path'] ) ) {
$path = $url_parts['path'];
}
// add query
// Add query.
if ( ! empty( $url_parts['query'] ) ) {
$path .= '?' . $url_parts['query'];
}
@ -213,9 +221,9 @@ class Signature {
$signature = null;
\openssl_sign( $signed_string, $signature, $key, \OPENSSL_ALGO_SHA256 );
$signature = \base64_encode( $signature ); // phpcs:ignore
$signature = \base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$key_id = $user->get_url() . '#main-key';
$key_id = $user->get_id() . '#main-key';
if ( ! empty( $digest ) ) {
return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature );
@ -229,18 +237,18 @@ class Signature {
*
* @param WP_REST_Request|array $request The request object or $_SERVER array.
*
* @return mixed A boolean or WP_Error.
* @return bool|WP_Error 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 ( 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(), '/' );
}
// fix route for subdirectory installs
// Fix route for subdirectory installs.
$path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
if ( \is_string( $path ) ) {
@ -251,32 +259,23 @@ class Signature {
$route = '/' . $path . $route;
}
$headers = $request->get_headers();
$headers = $request->get_headers();
$headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' ' . $route;
} else {
$request = self::format_server_request( $request );
$headers = $request['headers']; // $_SERVER array
$request = self::format_server_request( $request );
$headers = $request['headers']; // $_SERVER array
$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', 'activitypub' ), array( 'status' => 401 ) );
}
if ( array_key_exists( 'signature', $headers ) ) {
$signature_block = self::parse_signature_header( $headers['signature'][0] );
} elseif ( array_key_exists( 'authorization', $headers ) ) {
$signature_block = self::parse_signature_header( $headers['authorization'][0] );
}
if ( ! isset( $signature_block ) || ! $signature_block ) {
} else {
return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) );
}
$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 ) {
@ -313,7 +312,6 @@ class Signature {
}
$verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0;
if ( ! $verified ) {
return new WP_Error( 'activitypub_signature', __( 'Invalid signature', 'activitypub' ), array( 'status' => 401 ) );
}
@ -321,14 +319,14 @@ class Signature {
}
/**
* Get public key from key_id
* Get public key from key_id.
*
* @param string $key_id The URL to the public key.
*
* @return WP_Error|string The public key or WP_Error.
* @return resource|WP_Error The public key resource or WP_Error.
*/
public static function get_remote_key( $key_id ) { // phpcs:ignore
$actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) ); // phpcs:ignore
public static function get_remote_key( $key_id ) {
$actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) );
if ( \is_wp_error( $actor ) ) {
return new WP_Error(
'activitypub_no_remote_profile_found',
@ -336,9 +334,14 @@ class Signature {
array( 'status' => 401 )
);
}
if ( isset( $actor['publicKey']['publicKeyPem'] ) ) {
return \rtrim( $actor['publicKey']['publicKeyPem'] ); // phpcs:ignore
$key_resource = \openssl_pkey_get_public( \rtrim( $actor['publicKey']['publicKeyPem'] ) );
if ( $key_resource ) {
return $key_resource;
}
}
return new WP_Error(
'activitypub_no_remote_key_found',
__( 'No Public-Key found', 'activitypub' ),
@ -347,9 +350,9 @@ class Signature {
}
/**
* Gets the signature algorithm from the signature header
* Gets the signature algorithm from the signature header.
*
* @param array $signature_block
* @param array $signature_block The signature block.
*
* @return string The signature algorithm.
*/
@ -357,7 +360,7 @@ class Signature {
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
return 'sha512'; // hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12.
default:
return 'sha256';
}
@ -366,23 +369,23 @@ class Signature {
}
/**
* Parses the Signature header
* Parses the Signature header.
*
* @param string $signature The signature header.
*
* @return array signature parts
* @return array Signature parts.
*/
public static function parse_signature_header( $signature ) {
$parsed_header = array();
$matches = array();
$parsed_header = array();
$matches = array();
if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
$parsed_header['keyId'] = trim( $matches[1] );
}
if ( \preg_match( '/created=([0-9]*)/ism', $signature, $matches ) ) {
if ( \preg_match( '/created=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
$parsed_header['(created)'] = trim( $matches[1] );
}
if ( \preg_match( '/expires=([0-9]*)/ism', $signature, $matches ) ) {
if ( \preg_match( '/expires=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
$parsed_header['(expires)'] = trim( $matches[1] );
}
if ( \preg_match( '/algorithm="(.*?)"/ism', $signature, $matches ) ) {
@ -392,10 +395,10 @@ class Signature {
$parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
}
if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
$parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore
$parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
}
if ( ( $parsed_header['signature'] ) && ( $parsed_header['algorithm'] ) && ( ! $parsed_header['headers'] ) ) {
if ( empty( $parsed_header['headers'] ) ) {
$parsed_header['headers'] = array( 'date' );
}
@ -403,16 +406,17 @@ class Signature {
}
/**
* Gets the header data from the included pseudo headers
* Gets the header data from the included pseudo headers.
*
* @param array $signed_headers The signed headers.
* @param array $signature_block (pseudo-headers)
* @param array $headers (http headers)
* @param array $signature_block The signature block.
* @param array $headers The 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 ) {
@ -431,52 +435,69 @@ class Signature {
}
if ( '(created)' === $header ) {
if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
// created in future
// Created in the future.
return false;
}
if ( ! array_key_exists( '(created)', $headers ) ) {
$signed_data .= $header . ': ' . $signature_block['(created)'] . "\n";
continue;
}
}
if ( '(expires)' === $header ) {
if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
// expired in past
// Expired in the past.
return false;
}
if ( ! array_key_exists( '(expires)', $headers ) ) {
$signed_data .= $header . ': ' . $signature_block['(expires)'] . "\n";
continue;
}
}
if ( 'date' === $header ) {
// allow a bit of leeway for misconfigured clocks.
if ( empty( $headers[ $header ][0] ) ) {
continue;
}
// 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 );
$dplus = time() + ( 3 * HOUR_IN_SECONDS );
$dminus = time() - ( 3 * HOUR_IN_SECONDS );
if ( $c > $dplus || $c < $dminus ) {
// time out of range
// Time out of range.
return false;
}
}
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
if ( ! empty( $headers[ $header ][0] ) ) {
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
}
}
return \rtrim( $signed_data, "\n" );
}
/**
* Generates the digest for a HTTP Request
* Generates the digest for an 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
$digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return "SHA-256=$digest";
}
/**
* Formats the $_SERVER to resemble the WP_REST_REQUEST array,
* for use with verify_http_signature()
* for use with verify_http_signature().
*
* @param array $_SERVER The $_SERVER array.
* @param array $server The $_SERVER array.
*
* @return array $request The formatted request array.
*/
@ -487,7 +508,7 @@ class Signature {
if ( 'REQUEST_URI' === $req_param ) {
$request['headers']['route'][] = $param_val;
} else {
$header_key = str_replace(
$header_key = str_replace(
'http_',
'',
$req_param

View File

@ -1,11 +1,17 @@
<?php
/**
* WebFinger class file.
*
* @package Activitypub
*/
namespace Activitypub;
use WP_Error;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
/**
* ActivityPub WebFinger Class
* ActivityPub WebFinger Class.
*
* @author Matthias Pfefferle
*
@ -13,14 +19,14 @@ use Activitypub\Collection\Users;
*/
class Webfinger {
/**
* Returns a users WebFinger "resource"
* Returns a users WebFinger "resource".
*
* @param int $user_id The WordPress user id
* @param int $user_id The WordPress user id.
*
* @return string The user-resource
* @return string The user-resource.
*/
public static function get_user_resource( $user_id ) {
$user = Users::get_by_id( $user_id );
$user = Actors::get_by_id( $user_id );
if ( ! $user || is_wp_error( $user ) ) {
return '';
}
@ -29,11 +35,11 @@ class Webfinger {
}
/**
* Resolve a WebFinger resource
* Resolve a WebFinger resource.
*
* @param string $uri The WebFinger Resource
* @param string $uri The WebFinger Resource.
*
* @return string|WP_Error The URL or WP_Error
* @return string|WP_Error The URL or WP_Error.
*/
public static function resolve( $uri ) {
$data = self::get_data( $uri );
@ -46,13 +52,17 @@ class Webfinger {
return new WP_Error(
'webfinger_missing_links',
__( 'No valid Link elements found.', 'activitypub' ),
$data
array(
'status' => 400,
'data' => $data,
)
);
}
foreach ( $data['links'] as $link ) {
if (
'self' === $link['rel'] &&
isset( $link['type'] ) &&
(
'application/activity+json' === $link['type'] ||
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' === $link['type']
@ -65,16 +75,21 @@ class Webfinger {
return new WP_Error(
'webfinger_url_no_activitypub',
__( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
$data
array(
'status' => 400,
'data' => $data,
)
);
}
/**
* Transform a URI to an acct <identifier>@<host>
* Transform a URI to an acct <identifier>@<host>.
*
* @param string $uri The URI (acct:, mailto:, http:, https:)
* @see https://swicg.github.io/activitypub-webfinger/#reverse-discovery
*
* @return string|WP_Error Error or acct URI
* @param string $uri The URI (acct:, mailto:, http:, https:).
*
* @return string|WP_Error Error or acct URI.
*/
public static function uri_to_acct( $uri ) {
$data = self::get_data( $uri );
@ -83,7 +98,7 @@ class Webfinger {
return $data;
}
// check if subject is an acct URI
// Check if subject is an acct URI.
if (
isset( $data['subject'] ) &&
\str_starts_with( $data['subject'], 'acct:' )
@ -91,7 +106,7 @@ class Webfinger {
return $data['subject'];
}
// search for an acct URI in the aliases
// Search for an acct URI in the aliases.
if ( isset( $data['aliases'] ) ) {
foreach ( $data['aliases'] as $alias ) {
if ( \str_starts_with( $alias, 'acct:' ) ) {
@ -103,7 +118,10 @@ class Webfinger {
return new WP_Error(
'webfinger_url_no_acct',
__( 'No acct URI found.', 'activitypub' ),
$data
array(
'status' => 400,
'data' => $data,
)
);
}
@ -111,21 +129,31 @@ class Webfinger {
* 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:)
* @param string $url The URI (acct:, mailto:, http:, https:).
*
* @return WP_Error|array Error reaction or array with
* identifier and host as values
* @return WP_Error|array Error reaction or array with identifier and host as values.
*/
public static function get_identifier_and_host( $url ) {
// remove leading @
if ( ! $url ) {
return new WP_Error(
'webfinger_invalid_identifier',
__( 'Invalid Identifier', 'activitypub' ),
array(
'status' => 400,
'data' => $url,
)
);
}
// Remove leading @.
$url = ltrim( $url, '@' );
if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
$identifier = 'acct:' . $url;
$scheme = 'acct';
$scheme = 'acct';
} else {
$identifier = $url;
$scheme = $match[1];
$scheme = $match[1];
}
$host = null;
@ -144,19 +172,25 @@ class Webfinger {
}
if ( empty( $host ) ) {
return new WP_Error( 'webfinger_invalid_identifier', __( 'Invalid Identifier', 'activitypub' ) );
return new WP_Error(
'webfinger_invalid_identifier',
__( 'Invalid Identifier', 'activitypub' ),
array(
'status' => 400,
'data' => $url,
)
);
}
return array( $identifier, $host );
}
/**
* Get the WebFinger data for a given URI
* Get the WebFinger data for a given URI.
*
* @param string $uri The Identifier: <identifier>@<host> or URI
* @param string $uri The Identifier: <identifier>@<host> or URI.
*
* @return WP_Error|array Error reaction or array with
* identifier and host as values
* @return WP_Error|array Error reaction or array with identifier and host as values.
*/
public static function get_data( $uri ) {
$identifier_and_host = self::get_identifier_and_host( $uri );
@ -174,7 +208,11 @@ class Webfinger {
return $data;
}
$webfinger_url = 'https://' . $host . '/.well-known/webfinger?resource=' . rawurlencode( $identifier );
$webfinger_url = sprintf(
'https://%s/.well-known/webfinger?resource=%s',
$host,
rawurlencode( $identifier )
);
$response = wp_safe_remote_get(
$webfinger_url,
@ -187,7 +225,10 @@ class Webfinger {
return new WP_Error(
'webfinger_url_not_accessible',
__( 'The WebFinger Resource is not accessible.', 'activitypub' ),
$webfinger_url
array(
'status' => 400,
'data' => $webfinger_url,
)
);
}
@ -200,7 +241,9 @@ class Webfinger {
}
/**
* Get the Remote-Follow endpoint for a given URI
* Get the Remote-Follow endpoint for a given URI.
*
* @param string $uri The WebFinger Resource URI.
*
* @return string|WP_Error Error or the Remote-Follow endpoint URI.
*/
@ -215,7 +258,10 @@ class Webfinger {
return new WP_Error(
'webfinger_missing_links',
__( 'No valid Link elements found.', 'activitypub' ),
$data
array(
'status' => 400,
'data' => $data,
)
);
}
@ -228,16 +274,19 @@ class Webfinger {
return new WP_Error(
'webfinger_missing_remote_follow_endpoint',
__( 'No valid Remote-Follow endpoint found.', 'activitypub' ),
$data
array(
'status' => 400,
'data' => $data,
)
);
}
/**
* Generate a cache key for a given URI
* Generate a cache key for a given URI.
*
* @param string $uri A WebFinger Resource URI
* @param string $uri A WebFinger Resource URI.
*
* @return string The cache key
* @return string The cache key.
*/
public static function generate_cache_key( $uri ) {
$uri = ltrim( $uri, '@' );

View File

@ -0,0 +1,378 @@
<?php
/**
* Actors collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use WP_Error;
use WP_User_Query;
use Activitypub\Model\User;
use Activitypub\Model\Blog;
use Activitypub\Model\Application;
use function Activitypub\object_to_uri;
use function Activitypub\normalize_url;
use function Activitypub\normalize_host;
use function Activitypub\url_to_authorid;
use function Activitypub\is_user_type_disabled;
use function Activitypub\user_can_activitypub;
/**
* Actors collection.
*/
class Actors {
/**
* 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 Actor by ID.
*
* @param int $user_id The User-ID.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
*/
public static function get_by_id( $user_id ) {
if ( is_numeric( $user_id ) ) {
$user_id = (int) $user_id;
}
if ( ! user_can_activitypub( $user_id ) ) {
return new WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
switch ( $user_id ) {
case self::BLOG_USER_ID:
return new Blog();
case self::APPLICATION_USER_ID:
return new Application();
default:
return User::from_wp_user( $user_id );
}
}
/**
* Get the Actor by username.
*
* @param string $username Name of the Actor.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
*/
public static function get_by_username( $username ) {
/**
* Filter the username before we do anything else.
*
* @param null $pre The pre-existing value.
* @param string $username The username.
*/
$pre = apply_filters( 'activitypub_pre_get_by_username', null, $username );
if ( null !== $pre ) {
return $pre;
}
// Check for blog user.
if ( Blog::get_default_username() === $username ) {
return new Blog();
}
if ( get_option( 'activitypub_blog_identifier' ) === $username ) {
return new Blog();
}
// Check for application user.
if ( 'application' === $username ) {
return new Application();
}
// Check for 'activitypub_username' meta.
$user = new WP_User_Query(
array(
'count_total' => false,
'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 ) {
$actor = self::get_by_id( $user->results[0] );
if ( ! \is_wp_error( $actor ) ) {
return $actor;
}
}
$username = str_replace( array( '*', '%' ), '', $username );
// Check for login or nicename.
$user = new WP_User_Query(
array(
'count_total' => false,
'search' => $username,
'search_columns' => array( 'user_login', 'user_nicename' ),
'number' => 1,
'hide_empty' => true,
'fields' => 'ID',
)
);
if ( $user->results ) {
$actor = self::get_by_id( $user->results[0] );
if ( ! \is_wp_error( $actor ) ) {
return $actor;
}
}
return new WP_Error(
'activitypub_user_not_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
}
/**
* Get the Actor by resource.
*
* @param string $uri The Actor resource.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
*/
public static function get_by_resource( $uri ) {
$uri = object_to_uri( $uri );
if ( ! $uri ) {
return new WP_Error(
'activitypub_no_uri',
\__( 'No URI provided', 'activitypub' ),
array( 'status' => 404 )
);
}
$scheme = 'acct';
$match = array();
// Try to extract the scheme and the host.
if ( preg_match( '/^([a-zA-Z^:]+):(.*)$/i', $uri, $match ) ) {
// Extract the scheme.
$scheme = \esc_attr( $match[1] );
}
// @todo: handle old domain URIs here before we serve a new domain below when we shouldn't.
// Although maybe passing through to ::get_by_username() is enough?
switch ( $scheme ) {
// Check for http(s) URIs.
case 'http':
case 'https':
$resource_path = \wp_parse_url( $uri, PHP_URL_PATH );
if ( $resource_path ) {
$blog_path = \wp_parse_url( \home_url(), PHP_URL_PATH );
if ( $blog_path ) {
$resource_path = \str_replace( $blog_path, '', $resource_path );
}
$resource_path = \trim( $resource_path, '/' );
// Check for http(s)://blog.example.com/@username.
if ( str_starts_with( $resource_path, '@' ) ) {
$identifier = \str_replace( '@', '', $resource_path );
$identifier = \trim( $identifier, '/' );
return self::get_by_username( $identifier );
}
}
// Check for http(s)://blog.example.com/author/username.
$user_id = url_to_authorid( $uri );
if ( \is_int( $user_id ) ) {
return self::get_by_id( $user_id );
}
// Check for http(s)://blog.example.com/.
$normalized_uri = normalize_url( $uri );
if (
normalize_url( site_url() ) === $normalized_uri ||
normalize_url( home_url() ) === $normalized_uri
) {
return self::get_by_id( self::BLOG_USER_ID );
}
return new WP_Error(
'activitypub_no_user_found',
\__( 'Actor not found', 'activitypub' ),
array( 'status' => 404 )
);
// Check for acct URIs.
case 'acct':
$uri = \str_replace( 'acct:', '', $uri );
$identifier = \substr( $uri, 0, \strrpos( $uri, '@' ) );
$host = normalize_host( \substr( \strrchr( $uri, '@' ), 1 ) );
$blog_host = normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );
if ( $blog_host !== $host && get_option( 'activitypub_old_host' ) !== $host ) {
return new WP_Error(
'activitypub_wrong_host',
\__( 'Resource host does not match blog host', 'activitypub' ),
array( 'status' => 404 )
);
}
// Prepare wildcards https://github.com/mastodon/mastodon/issues/22213.
if ( in_array( $identifier, array( '_', '*', '' ), true ) ) {
return self::get_by_id( self::BLOG_USER_ID );
}
return self::get_by_username( $identifier );
default:
return new WP_Error(
'activitypub_wrong_scheme',
\__( 'Wrong scheme', 'activitypub' ),
array( 'status' => 404 )
);
}
}
/**
* Get the Actor by resource.
*
* @param string $id The Actor resource.
*
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
*/
public static function get_by_various( $id ) {
$user = null;
if ( is_numeric( $id ) ) {
$user = self::get_by_id( $id );
} elseif (
// Is URL.
filter_var( $id, FILTER_VALIDATE_URL ) ||
// Is acct.
str_starts_with( $id, 'acct:' ) ||
// Is email.
filter_var( $id, FILTER_VALIDATE_EMAIL )
) {
$user = self::get_by_resource( $id );
} else {
$user = self::get_by_username( $id );
}
return $user;
}
/**
* Get the Actor collection.
*
* @return array The Actor collection.
*/
public static function get_collection() {
if ( is_user_type_disabled( 'user' ) ) {
return array();
}
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
$return = array();
foreach ( $users as $user ) {
$actor = User::from_wp_user( $user->ID );
if ( \is_wp_error( $actor ) ) {
continue;
}
$return[] = $actor;
}
return $return;
}
/**
* Get all active Actors including the Blog Actor.
*
* @return array The actor collection.
*/
public static function get_all() {
$return = array();
if ( ! is_user_type_disabled( 'user' ) ) {
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
foreach ( $users as $user ) {
$actor = User::from_wp_user( $user->ID );
if ( \is_wp_error( $actor ) ) {
continue;
}
$return[] = $actor;
}
}
// Also include the blog actor if active.
if ( ! is_user_type_disabled( 'blog' ) ) {
$blog_actor = self::get_by_id( self::BLOG_USER_ID );
if ( ! \is_wp_error( $blog_actor ) ) {
$return[] = $blog_actor;
}
}
return $return;
}
/**
* Returns the actor type based on the user ID.
*
* @param int $user_id The user ID to check.
* @return string The user type.
*/
public static function get_type_by_id( $user_id ) {
$user_id = (int) $user_id;
if ( self::APPLICATION_USER_ID === $user_id ) {
return 'application';
}
if ( self::BLOG_USER_ID === $user_id ) {
return 'blog';
}
return 'user';
}
}

View File

@ -0,0 +1,300 @@
<?php
/**
* Extra Fields collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Link;
use function Activitypub\site_supports_blocks;
/**
* Extra Fields collection.
*/
class Extra_Fields {
const USER_POST_TYPE = 'ap_extrafield';
const BLOG_POST_TYPE = 'ap_extrafield_blog';
/**
* Get the extra fields for a user.
*
* @param int $user_id The user ID.
*
* @return \WP_Post[] The extra fields.
*/
public static function get_actor_fields( $user_id ) {
$is_blog = self::is_blog( $user_id );
$post_type = $is_blog ? self::BLOG_POST_TYPE : self::USER_POST_TYPE;
$args = array(
'post_type' => $post_type,
'nopaging' => true,
'orderby' => 'menu_order',
'order' => 'ASC',
);
if ( ! $is_blog ) {
$args['author'] = $user_id;
}
$query = new \WP_Query( $args );
$fields = $query->posts ?? array();
/**
* Filters the extra fields for an ActivityPub actor.
*
* This filter allows developers to modify or add custom fields to an actor's
* profile.
*
* @param \WP_Post[] $fields Array of WP_Post objects representing the extra fields.
* @param int $user_id The ID of the user whose fields are being retrieved.
*/
return apply_filters( 'activitypub_get_actor_extra_fields', $fields, $user_id );
}
/**
* Get formatted content for an extra field.
*
* @param \WP_Post $post The post.
*
* @return string The formatted content.
*/
public static function get_formatted_content( $post ) {
$content = \get_the_content( null, false, $post );
$content = Link::the_content( $content );
if ( site_supports_blocks() ) {
$content = \do_blocks( $content );
}
$content = \wptexturize( $content );
$content = \wp_filter_content_tags( $content );
// 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 ) );
/**
* Filters the content of an extra field.
*
* @param string $content The content.
* @param \WP_Post $post The post.
*/
return \apply_filters( 'activitypub_extra_field_content', $content, $post );
}
/**
* Transforms the Extra Fields (Custom Post Types) to ActivityPub Actor-Attachments.
*
* @param \WP_Post[] $fields The extra fields.
*
* @return array ActivityPub attachments.
*/
public static function fields_to_attachments( $fields ) {
$attachments = array();
\add_filter( 'activitypub_link_rel', array( self::class, 'add_rel_me' ) );
foreach ( $fields as $post ) {
$content = self::get_formatted_content( $post );
$attachments[] = array(
'type' => 'PropertyValue',
'name' => \get_the_title( $post ),
'value' => \html_entity_decode(
$content,
\ENT_QUOTES,
'UTF-8'
),
);
$attachment = false;
// Add support for FEP-fb2a, for more information see FEDERATION.md.
$link_content = \trim( \strip_tags( $content, '<a>' ) );
if (
\stripos( $link_content, '<a' ) === 0 &&
\stripos( $link_content, '<a', 3 ) === false &&
\stripos( $link_content, '</a>', \strlen( $link_content ) - 4 ) !== false &&
\class_exists( '\WP_HTML_Tag_Processor' )
) {
$tags = new \WP_HTML_Tag_Processor( $link_content );
$tags->next_tag( 'A' );
if ( 'A' === $tags->get_tag() ) {
$attachment = array(
'type' => 'Link',
'name' => \get_the_title( $post ),
'href' => \esc_url( $tags->get_attribute( 'href' ) ),
);
$rel = $tags->get_attribute( 'rel' );
if ( $rel && \is_string( $rel ) ) {
$attachment['rel'] = \explode( ' ', $rel );
}
}
}
if ( ! $attachment ) {
$attachment = array(
'type' => 'Note',
'name' => \get_the_title( $post ),
'content' => \html_entity_decode(
$content,
\ENT_QUOTES,
'UTF-8'
),
);
}
$attachments[] = $attachment;
}
\remove_filter( 'activitypub_link_rel', array( self::class, 'add_rel_me' ) );
return $attachments;
}
/**
* Check if a post type is an extra fields post type.
*
* @param string $post_type The post type.
*
* @return bool True if the post type is an extra fields post type, otherwise false.
*/
public static function is_extra_fields_post_type( $post_type ) {
return \in_array( $post_type, array( self::USER_POST_TYPE, self::BLOG_POST_TYPE ), true );
}
/**
* Check if a post type is the `ap_extrafield` post type.
*
* @param string $post_type The post type.
*
* @return bool True if the post type is `ap_extrafield`, otherwise false.
*/
public static function is_extra_field_post_type( $post_type ) {
return self::USER_POST_TYPE === $post_type;
}
/**
* Check if a post type is the `ap_extrafield_blog` post type.
*
* @param string $post_type The post type.
*
* @return bool True if the post type is `ap_extrafield_blog`, otherwise false.
*/
public static function is_extra_field_blog_post_type( $post_type ) {
return self::BLOG_POST_TYPE === $post_type;
}
/**
* Add default extra fields to an actor.
*
* @param array $extra_fields The extra fields.
* @param int $user_id The User-ID.
*
* @return array The extra fields.
*/
public static function default_actor_extra_fields( $extra_fields, $user_id ) {
// We'll only take action when there are none yet.
if ( ! empty( $extra_fields ) ) {
return $extra_fields;
}
$is_blog = self::is_blog( $user_id );
$already_migrated = $is_blog
? \get_option( 'activitypub_default_extra_fields' )
: \get_user_meta( $user_id, 'activitypub_default_extra_fields', true );
if ( $already_migrated ) {
return $extra_fields;
}
\add_filter(
'activitypub_link_rel',
function ( $rel ) {
$rel .= ' me';
return $rel;
}
);
$defaults = array(
\__( 'Blog', 'activitypub' ) => \home_url( '/' ),
);
if ( ! $is_blog ) {
$author_url = \get_the_author_meta( 'user_url', $user_id );
$author_posts_url = \get_author_posts_url( $user_id );
$defaults[ \__( 'Profile', 'activitypub' ) ] = $author_posts_url;
if ( $author_url !== $author_posts_url ) {
$defaults[ \__( 'Homepage', 'activitypub' ) ] = $author_url;
}
}
$post_type = $is_blog ? self::BLOG_POST_TYPE : self::USER_POST_TYPE;
$menu_order = 10;
foreach ( $defaults as $title => $url ) {
if ( ! $url ) {
continue;
}
$extra_field = array(
'post_type' => $post_type,
'post_title' => $title,
'post_status' => 'publish',
'post_author' => $user_id,
'post_content' => self::make_paragraph_block( Link::the_content( $url ) ),
'comment_status' => 'closed',
'menu_order' => $menu_order,
);
$menu_order += 10;
$extra_field_id = wp_insert_post( $extra_field );
$extra_fields[] = get_post( $extra_field_id );
}
$is_blog
? \update_option( 'activitypub_default_extra_fields', true )
: \update_user_meta( $user_id, 'activitypub_default_extra_fields', true );
return $extra_fields;
}
/**
* Create a paragraph block.
*
* @param string $content The content.
*
* @return string The paragraph block.
*/
public static function make_paragraph_block( $content ) {
if ( ! site_supports_blocks() ) {
return $content;
}
return '<!-- wp:paragraph --><p>' . $content . '</p><!-- /wp:paragraph -->';
}
/**
* Add the 'me' rel to the link.
*
* @param string $rel The rel attribute.
* @return string The modified rel attribute.
*/
public static function add_rel_me( $rel ) {
return $rel . ' me';
}
/**
* Checks if the user is the blog user.
*
* @param int $user_id The user ID.
* @return bool True if the user is the blog user, otherwise false.
*/
private static function is_blog( $user_id ) {
return Actors::BLOG_USER_ID === $user_id;
}
}

View File

@ -1,32 +1,36 @@
<?php
/**
* Followers collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Model\Follower;
use WP_Error;
use WP_Query;
use Activitypub\Http;
use Activitypub\Webfinger;
use Activitypub\Model\Follower;
use function Activitypub\is_tombstone;
use function Activitypub\get_remote_metadata_by_actor;
/**
* ActivityPub Followers Collection
* ActivityPub Followers Collection.
*
* @author Matt Wiebe
* @author Matthias Pfefferle
*/
class Followers {
const POST_TYPE = 'ap_follower';
const POST_TYPE = 'ap_follower';
const CACHE_KEY_INBOXES = 'follower_inboxes_%s';
/**
* Add new Follower
* Add new Follower.
*
* @param int $user_id The ID of the WordPress User
* @param string $actor The Actor URL
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return array|WP_Error The Follower (WP_Post array) or an WP_Error
* @return Follower|WP_Error The Follower (WP_Post array) or an WP_Error.
*/
public static function add_follower( $user_id, $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
@ -48,11 +52,11 @@ class Followers {
return $id;
}
$post_meta = get_post_meta( $id, 'activitypub_user_id' );
$post_meta = get_post_meta( $id, '_activitypub_user_id', false );
// phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
if ( is_array( $post_meta ) && ! in_array( $user_id, $post_meta ) ) {
add_post_meta( $id, 'activitypub_user_id', $user_id );
add_post_meta( $id, '_activitypub_user_id', $user_id );
wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
}
@ -60,12 +64,12 @@ class Followers {
}
/**
* Remove a Follower
* Remove a Follower.
*
* @param int $user_id The ID of the WordPress User
* @param string $actor The Actor URL
* @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.
* @return bool True on success, false on failure.
*/
public static function remove_follower( $user_id, $actor ) {
wp_cache_delete( sprintf( self::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
@ -76,24 +80,33 @@ class Followers {
return false;
}
return delete_post_meta( $follower->get__id(), 'activitypub_user_id', $user_id );
/**
* Fires before a Follower is removed.
*
* @param Follower $follower The Follower object.
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*/
do_action( 'activitypub_followers_pre_remove_follower', $follower, $user_id, $actor );
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
* @param int $user_id The ID of the WordPress User.
* @param string $actor The Actor URL.
*
* @return \Activitypub\Model\Follower|null The Follower object or null
* @return Follower|false|null The Follower object or null
*/
public static function get_follower( $user_id, $actor ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$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",
"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 ),
@ -111,16 +124,16 @@ class Followers {
}
/**
* Get a Follower by Actor indepenent from the User.
* Get a Follower by Actor independent of the User.
*
* @param string $actor The Actor URL.
*
* @return \Activitypub\Model\Follower|null The Follower object or null
* @return Follower|false|null The Follower object or false on failure.
*/
public static function get_follower_by_actor( $actor ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s",
@ -137,13 +150,13 @@ class Followers {
}
/**
* Get the Followers of a given user
* 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.
* @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 Follower[] 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 );
@ -153,14 +166,17 @@ class 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.
* @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.
* @return array {
* Data about the followers.
*
* @type Follower[] $followers List of `Follower` objects.
* @type int $total Total number of followers.
* }
*/
public static function get_followers_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
@ -172,30 +188,25 @@ class Followers {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => 'activitypub_user_id',
'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()
);
$args = wp_parse_args( $args, $defaults );
$query = new WP_Query( $args );
$total = $query->found_posts;
$followers = array_map( array( Follower::class, 'init_from_cpt' ), $query->get_posts() );
$followers = array_filter( $followers );
return compact( 'followers', 'total' );
}
/**
* Get all Followers
* Get all Followers.
*
* @param array $args The WP_Query arguments.
*
* @return array The Term list of Followers.
* @return Follower[] The Term list of Followers.
*/
public static function get_all_followers() {
$args = array(
@ -204,11 +215,11 @@ class Followers {
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'compare' => 'EXISTS',
),
array(
'key' => 'activitypub_actor_json',
'key' => '_activitypub_actor_json',
'compare' => 'EXISTS',
),
),
@ -219,7 +230,7 @@ class Followers {
/**
* Count the total number of followers
*
* @param int $user_id The ID of the WordPress User
* @param int $user_id The ID of the WordPress User.
*
* @return int The number of Followers
*/
@ -232,15 +243,15 @@ class Followers {
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'activitypub_user_id',
'key' => '_activitypub_user_id',
'value' => $user_id,
),
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'compare' => 'EXISTS',
),
array(
'key' => 'activitypub_actor_json',
'key' => '_activitypub_actor_json',
'compare' => 'EXISTS',
),
),
@ -251,21 +262,21 @@ class Followers {
}
/**
* Returns all Inboxes fo a Users Followers
* Returns all Inboxes for an Actor's Followers.
*
* @param int $user_id The ID of the WordPress User
* @param int $user_id The ID of the WordPress User.
*
* @return array The list of Inboxes
* @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' );
$inboxes = wp_cache_get( $cache_key, 'activitypub' );
if ( $inboxes ) {
return $inboxes;
}
// get all Followers of a ID of the WordPress User
// Get all Followers of an ID of the WordPress User.
$posts = new WP_Query(
array(
'nopaging' => true,
@ -275,15 +286,15 @@ class Followers {
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'compare' => 'EXISTS',
),
array(
'key' => 'activitypub_user_id',
'key' => '_activitypub_user_id',
'value' => $user_id,
),
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'value' => '',
'compare' => '!=',
),
@ -303,7 +314,7 @@ class Followers {
$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_key = '_activitypub_inbox'
AND meta_value IS NOT NULL",
$posts
)
@ -316,13 +327,62 @@ class Followers {
}
/**
* Get all Followers that have not been updated for a given time
* Get all Inboxes for a given Activity.
*
* @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.
* @param string $json The ActivityPub Activity JSON.
* @param int $actor_id The WordPress Actor ID.
* @param int $batch_size Optional. The batch size. Default 50.
* @param int $offset Optional. The offset. Default 0.
*
* @return mixed The Term list of Followers, the format depends on $output.
* @return array The list of Inboxes.
*/
public static function get_inboxes_for_activity( $json, $actor_id, $batch_size = 50, $offset = 0 ) {
$inboxes = self::get_inboxes( $actor_id );
if ( self::maybe_add_inboxes_of_blog_user( $json, $actor_id ) ) {
$inboxes = array_fill_keys( $inboxes, 1 );
foreach ( self::get_inboxes( Actors::BLOG_USER_ID ) as $inbox ) {
$inboxes[ $inbox ] = 1;
}
$inboxes = array_keys( $inboxes );
}
return array_slice( $inboxes, $offset, $batch_size );
}
/**
* Maybe add Inboxes of the Blog User.
*
* @param string $json The ActivityPub Activity JSON.
* @param int $actor_id The WordPress Actor ID.
* @return bool True if the Inboxes of the Blog User should be added, false otherwise.
*/
public static function maybe_add_inboxes_of_blog_user( $json, $actor_id ) {
// Only if we're in both Blog and User modes.
if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE !== \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
return false;
}
// Only if this isn't the Blog Actor.
if ( Actors::BLOG_USER_ID === $actor_id ) {
return false;
}
$activity = json_decode( $json, true );
// Only if this is an Update or Delete. Create handles its own "Announce" in dual user mode.
if ( ! in_array( $activity['type'] ?? null, array( 'Update', 'Delete' ), true ) ) {
return false;
}
return true;
}
/**
* Get all Followers that have not been updated for a given time.
*
* @param int $number Optional. Limits the result. Default 50.
* @param int $older_than Optional. The time in seconds. Default 86400 (1 day).
*
* @return Follower[] The Term list of Followers.
*/
public static function get_outdated_followers( $number = 50, $older_than = 86400 ) {
$args = array(
@ -330,7 +390,7 @@ class Followers {
'posts_per_page' => $number,
'orderby' => 'modified',
'order' => 'ASC',
'post_status' => 'any', // 'any' includes 'trash
'post_status' => 'any', // 'any' includes 'trash'.
'date_query' => array(
array(
'column' => 'post_modified_gmt',
@ -340,22 +400,17 @@ class Followers {
);
$posts = new WP_Query( $args );
$items = array();
$items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() );
foreach ( $posts->get_posts() as $follower ) {
$items[] = Follower::init_from_cpt( $follower ); // phpcs:ignore
}
return $items;
return array_filter( $items );
}
/**
* Get all Followers that had errors
* 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.
* @param int $number Optional. The number of Followers to return. Default 20.
*
* @return mixed The Term list of Followers, the format depends on $output.
* @return Follower[] The Term list of Followers.
*/
public static function get_faulty_followers( $number = 20 ) {
$args = array(
@ -365,24 +420,24 @@ class Followers {
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'activitypub_errors',
'key' => '_activitypub_errors',
'compare' => 'EXISTS',
),
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'compare' => 'NOT EXISTS',
),
array(
'key' => 'activitypub_actor_json',
'key' => '_activitypub_actor_json',
'compare' => 'NOT EXISTS',
),
array(
'key' => 'activitypub_inbox',
'key' => '_activitypub_inbox',
'value' => '',
'compare' => '=',
),
array(
'key' => 'activitypub_actor_json',
'key' => '_activitypub_actor_json',
'value' => '',
'compare' => '=',
),
@ -390,21 +445,16 @@ class Followers {
);
$posts = new WP_Query( $args );
$items = array();
$items = array_map( array( Follower::class, 'init_from_cpt' ), $posts->get_posts() );
foreach ( $posts->get_posts() as $follower ) {
$items[] = Follower::init_from_cpt( $follower ); // phpcs:ignore
}
return $items;
return array_filter( $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.
* The error will be stored in 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.
@ -425,7 +475,7 @@ class Followers {
return add_post_meta(
$post_id,
'activitypub_errors',
'_activitypub_errors',
$error_message
);
}

View File

@ -1,113 +1,75 @@
<?php
/**
* Interactions collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use WP_Error;
use Activitypub\Webfinger;
use WP_Comment_Query;
use Activitypub\Comment;
use function Activitypub\object_to_uri;
use function Activitypub\is_post_disabled;
use function Activitypub\url_to_commentid;
use function Activitypub\object_id_to_comment;
use function Activitypub\get_remote_metadata_by_actor;
/**
* ActivityPub Interactions Collection
* ActivityPub Interactions Collection.
*/
class Interactions {
const INSERT = 'insert';
const UPDATE = 'update';
/**
* Add a comment to a post
* Add a comment to a post.
*
* @param array $activity The activity-object
* @param array $activity The activity-object.
*
* @return array|false The commentdata or false on failure
* @return int|false|\WP_Error The comment ID or false or WP_Error on failure.
*/
public static function add_comment( $activity ) {
if (
! isset( $activity['object'] ) ||
! isset( $activity['object']['id'] )
) {
$commentdata = self::activity_to_comment( $activity );
if ( ! $commentdata || ! isset( $activity['object']['inReplyTo'] ) ) {
return false;
}
if ( ! isset( $activity['object']['inReplyTo'] ) ) {
return false;
}
$in_reply_to = object_to_uri( $activity['object']['inReplyTo'] );
$in_reply_to = \esc_url_raw( $in_reply_to );
$comment_post_id = \url_to_postid( $in_reply_to );
$parent_comment_id = url_to_commentid( $in_reply_to );
$in_reply_to = \esc_url_raw( $activity['object']['inReplyTo'] );
$comment_post_id = \url_to_postid( $in_reply_to );
$parent_comment_id = url_to_commentid( $in_reply_to );
// save only replys and reactions
// Save only replies and reactions.
if ( ! $comment_post_id && $parent_comment_id ) {
$parent_comment = get_comment( $parent_comment_id );
$comment_post_id = $parent_comment->comment_post_ID;
}
// not a reply to a post or comment
if ( ! $comment_post_id ) {
if ( is_post_disabled( $comment_post_id ) ) {
return false;
}
$meta = get_remote_metadata_by_actor( $activity['actor'] );
$commentdata['comment_post_ID'] = $comment_post_id;
$commentdata['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0;
if ( ! $meta || \is_wp_error( $meta ) ) {
return false;
}
$commentdata = array(
'comment_post_ID' => $comment_post_id,
'comment_author' => isset( $meta['name'] ) ? \esc_attr( $meta['name'] ) : \esc_attr( $meta['preferredUsername'] ),
'comment_author_url' => \esc_url_raw( $meta['url'] ),
'comment_content' => \addslashes( $activity['object']['content'] ),
'comment_type' => 'comment',
'comment_author_email' => '',
'comment_parent' => $parent_comment_id ? $parent_comment_id : 0,
'comment_meta' => array(
'source_id' => \esc_url_raw( $activity['object']['id'] ),
'protocol' => 'activitypub',
),
);
if ( isset( $meta['icon']['url'] ) ) {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] );
}
if ( isset( $activity['object']['url'] ) ) {
$commentdata['comment_meta']['source_url'] = \esc_url_raw( $activity['object']['url'] );
}
// disable flood control
\remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 );
// 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';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
$comment = \wp_new_comment( $commentdata, true );
\remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10 );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// re-add flood control
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
return $comment;
return self::persist( $commentdata, self::INSERT );
}
/**
* Update a comment
* Update a comment.
*
* @param array $activity The activity-object
* @param array $activity The activity object.
*
* @return array|string|int|\WP_Error|false The commentdata or false on failure
* @return array|string|int|\WP_Error|false The comment data or false on failure.
*/
public static function update_comment( $activity ) {
$meta = get_remote_metadata_by_actor( $activity['actor'] );
//Determine comment_ID
// Determine comment_ID.
$comment = object_id_to_comment( \esc_url_raw( $activity['object']['id'] ) );
$commentdata = \get_comment( $comment, ARRAY_A );
@ -115,44 +77,62 @@ class Interactions {
return false;
}
//found a local comment id
$commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] );
// Found a local comment id.
$commentdata['comment_author'] = \esc_attr( $meta['name'] ? $meta['name'] : $meta['preferredUsername'] );
$commentdata['comment_content'] = \addslashes( $activity['object']['content'] );
if ( isset( $meta['icon']['url'] ) ) {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $meta['icon']['url'] );
return self::persist( $commentdata, self::UPDATE );
}
/**
* Adds an incoming Like, Announce, ... as a comment to a post.
*
* @param array $activity Activity array.
*
* @return array|false Comment data or `false` on failure.
*/
public static function add_reaction( $activity ) {
$commentdata = self::activity_to_comment( $activity );
if ( ! $commentdata ) {
return false;
}
// disable flood control
\remove_action( 'check_comment_flood', 'check_comment_flood_db', 10 );
// 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';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
$url = object_to_uri( $activity['object'] );
$comment_post_id = \url_to_postid( $url );
$parent_comment_id = url_to_commentid( $url );
$state = \wp_update_comment( $commentdata, true );
\remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10 );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// re-add flood control
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
if ( 1 === $state ) {
return $commentdata;
} else {
return $state; // Either `false` or a `WP_Error` instance or `0` or `1`!
if ( ! $comment_post_id && $parent_comment_id ) {
$parent_comment = \get_comment( $parent_comment_id );
$comment_post_id = $parent_comment->comment_post_ID;
}
if ( ! $comment_post_id || is_post_disabled( $comment_post_id ) ) {
// Not a reply to a post or comment.
return false;
}
$comment_type = Comment::get_comment_type_by_activity_type( $activity['type'] );
if ( ! $comment_type ) {
// Not a valid comment type.
return false;
}
$comment_content = $comment_type['excerpt'];
$commentdata['comment_post_ID'] = $comment_post_id;
$commentdata['comment_content'] = \esc_html( $comment_content );
$commentdata['comment_type'] = \esc_attr( $comment_type['type'] );
$commentdata['comment_meta']['source_id'] = \esc_url_raw( $activity['id'] );
return self::persist( $commentdata, self::INSERT );
}
/**
* Get interaction(s) for a given URL/ID.
*
* @param strin $url The URL/ID to get interactions for.
* @param string $url The URL/ID to get interactions for.
*
* @return array The interactions as WP_Comment objects.
*/
@ -194,9 +174,9 @@ class Interactions {
public static function get_interactions_by_actor( $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
// get URL, because $actor seems to be the ID
// Get URL, because $actor seems to be the ID.
if ( $meta && ! is_wp_error( $meta ) && isset( $meta['url'] ) ) {
$actor = $meta['url'];
$actor = object_to_uri( $meta['url'] );
}
$args = array(
@ -207,19 +187,18 @@ class Interactions {
array(
'key' => 'protocol',
'value' => 'activitypub',
'compare' => '=',
),
),
);
$comment_query = new WP_Comment_Query( $args );
return $comment_query->comments;
return get_comments( $args );
}
/**
* Adds line breaks to the list of allowed comment tags.
*
* @param array $allowed_tags Allowed HTML tags.
* @param string $context Context.
* @param string $context Optional. Context. Default empty.
*
* @return array Filtered tag list.
*/
@ -240,4 +219,131 @@ class Interactions {
return $allowed_tags;
}
/**
* Convert an Activity to a WP_Comment
*
* @param array $activity The Activity array.
*
* @return array|false The comment data or false on failure.
*/
public static function activity_to_comment( $activity ) {
$comment_content = null;
$actor = object_to_uri( $activity['actor'] ?? null );
$actor = get_remote_metadata_by_actor( $actor );
// Check Actor-Meta.
if ( ! $actor || is_wp_error( $actor ) ) {
return false;
}
// Check Actor-Name.
if ( isset( $actor['name'] ) ) {
$comment_author = $actor['name'];
} elseif ( isset( $actor['preferredUsername'] ) ) {
$comment_author = $actor['preferredUsername'];
} else {
return false;
}
$url = object_to_uri( $actor['url'] ?? $actor['id'] );
if ( ! $url ) {
$url = object_to_uri( $actor['id'] );
}
if ( isset( $activity['object']['content'] ) ) {
$comment_content = \addslashes( $activity['object']['content'] );
}
$webfinger = Webfinger::uri_to_acct( $url );
if ( is_wp_error( $webfinger ) ) {
$webfinger = '';
} else {
$webfinger = str_replace( 'acct:', '', $webfinger );
}
$commentdata = array(
'comment_author' => \esc_attr( $comment_author ),
'comment_author_url' => \esc_url_raw( $url ),
'comment_content' => $comment_content,
'comment_type' => 'comment',
'comment_author_email' => $webfinger,
'comment_meta' => array(
'source_id' => \esc_url_raw( object_to_uri( $activity['object'] ) ),
'protocol' => 'activitypub',
),
);
if ( isset( $actor['icon']['url'] ) ) {
$commentdata['comment_meta']['avatar_url'] = \esc_url_raw( $actor['icon']['url'] );
}
if ( isset( $activity['object']['url'] ) ) {
$commentdata['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) );
}
return $commentdata;
}
/**
* Persist a comment.
*
* @param array $commentdata The commentdata array.
* @param string $action Optional. Either 'insert' or 'update'. Default 'insert'.
*
* @return array|string|int|\WP_Error|false The comment data or false on failure
*/
public static function persist( $commentdata, $action = self::INSERT ) {
// Disable flood control.
\remove_action( 'check_comment_flood', 'check_comment_flood_db' );
// 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';
}
);
\add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
if ( self::INSERT === $action ) {
$state = \wp_new_comment( $commentdata, true );
} else {
$state = \wp_update_comment( $commentdata, true );
}
\remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ) );
\remove_filter( 'pre_option_require_name_email', '__return_false' );
// Restore flood control.
\add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
if ( 1 === $state ) {
return $commentdata;
} else {
return $state; // Either WP_Comment, false, a WP_Error, 0, or 1!
}
}
/**
* Get the total number of interactions by type for a given ID.
*
* @param int $post_id The post ID.
* @param string $type The type of interaction to count.
*
* @return int The total number of interactions.
*/
public static function count_by_type( $post_id, $type ) {
return \get_comments(
array(
'post_id' => $post_id,
'status' => 'approve',
'type' => $type,
'count' => true,
'paging' => false,
'fields' => 'ids',
)
);
}
}

View File

@ -0,0 +1,351 @@
<?php
/**
* Outbox collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use Activitypub\Dispatcher;
use Activitypub\Scheduler;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use function Activitypub\add_to_outbox;
/**
* ActivityPub Outbox Collection
*
* @link https://www.w3.org/TR/activitypub/#outbox
*/
class Outbox {
const POST_TYPE = 'ap_outbox';
/**
* Add an Item to the outbox.
*
* @param Activity $activity Full Activity object that will be added to the outbox.
* @param int $user_id The real or imaginary user ID of the actor that published the activity that will be added to the outbox.
* @param string $visibility Optional. The visibility of the content. Default: `ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC`. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
*
* @return false|int|\WP_Error The added item or an error.
*/
public static function add( Activity $activity, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) {
$actor_type = Actors::get_type_by_id( $user_id );
$object_id = self::get_object_id( $activity );
$title = self::get_object_title( $activity->get_object() );
if ( ! $activity->get_actor() ) {
$activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
}
$outbox_item = array(
'post_type' => self::POST_TYPE,
'post_title' => sprintf(
/* translators: 1. Activity type, 2. Object Title or Excerpt */
__( '[%1$s] %2$s', 'activitypub' ),
$activity->get_type(),
\wp_trim_words( $title, 5 )
),
'post_content' => wp_slash( $activity->to_json() ),
// ensure that user ID is not below 0.
'post_author' => \max( $user_id, 0 ),
'post_status' => 'pending',
'meta_input' => array(
'_activitypub_object_id' => $object_id,
'_activitypub_activity_type' => $activity->get_type(),
'_activitypub_activity_actor' => $actor_type,
'activitypub_content_visibility' => $visibility,
),
);
$has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
if ( $has_kses ) {
// Prevent KSES from corrupting JSON in post_content.
\kses_remove_filters();
}
$id = \wp_insert_post( $outbox_item, true );
// Update the activity ID if the post was inserted successfully.
if ( $id && ! \is_wp_error( $id ) ) {
$activity->set_id( \get_the_guid( $id ) );
\wp_update_post(
array(
'ID' => $id,
'post_content' => \wp_slash( $activity->to_json() ),
)
);
}
if ( $has_kses ) {
\kses_init_filters();
}
if ( \is_wp_error( $id ) ) {
return $id;
}
if ( ! $id ) {
return false;
}
self::invalidate_existing_items( $object_id, $activity->get_type(), $id );
return $id;
}
/**
* Invalidate existing outbox items with the same activity type and object ID
* by setting their status to 'publish'.
*
* @param string $object_id The ID of the activity object.
* @param string $activity_type The type of the activity.
* @param int $current_id The ID of the current outbox item to exclude.
*
* @return void
*/
private static function invalidate_existing_items( $object_id, $activity_type, $current_id ) {
// Do not invalidate items for Announce activities.
if ( 'Announce' === $activity_type ) {
return;
}
$meta_query = array(
array(
'key' => '_activitypub_object_id',
'value' => $object_id,
),
);
// For non-Delete activities, only invalidate items of the same type.
if ( 'Delete' !== $activity_type ) {
$meta_query[] = array(
'key' => '_activitypub_activity_type',
'value' => $activity_type,
);
}
$existing_items = get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'pending',
'exclude' => array( $current_id ),
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => $meta_query,
'fields' => 'ids',
)
);
foreach ( $existing_items as $existing_item_id ) {
$event_args = array(
Dispatcher::$callback,
$existing_item_id,
Dispatcher::$batch_size,
\get_post_meta( $existing_item_id, '_activitypub_outbox_offset', true ) ?: 0, // phpcs:ignore
);
$timestamp = \wp_next_scheduled( 'activitypub_async_batch', $event_args );
\wp_unschedule_event( $timestamp, 'activitypub_async_batch', $event_args );
$timestamp = \wp_next_scheduled( 'activitypub_process_outbox', array( $existing_item_id ) );
\wp_unschedule_event( $timestamp, 'activitypub_process_outbox', array( $existing_item_id ) );
\wp_publish_post( $existing_item_id );
\delete_post_meta( $existing_item_id, '_activitypub_outbox_offset' );
}
}
/**
* Creates an Undo activity.
*
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
*
* @return int|bool The ID of the outbox item or false on failure.
*/
public static function undo( $outbox_item ) {
$outbox_item = get_post( $outbox_item );
$activity = self::get_activity( $outbox_item );
$type = 'Undo';
if ( 'Create' === $activity->get_type() ) {
$type = 'Delete';
} elseif ( 'Add' === $activity->get_type() ) {
$type = 'Remove';
}
return add_to_outbox( $activity, $type, $outbox_item->post_author );
}
/**
* Reschedule an activity.
*
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
*
* @return bool True if the activity was rescheduled, false otherwise.
*/
public static function reschedule( $outbox_item ) {
$outbox_item = get_post( $outbox_item );
$outbox_item->post_status = 'pending';
$outbox_item->post_date = current_time( 'mysql' );
wp_update_post( $outbox_item );
Scheduler::schedule_outbox_activity_for_federation( $outbox_item->ID );
return true;
}
/**
* Get the Activity object from the Outbox item.
*
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
* @return Activity|\WP_Error The Activity object or WP_Error.
*/
public static function get_activity( $outbox_item ) {
$outbox_item = get_post( $outbox_item );
$actor = self::get_actor( $outbox_item );
if ( is_wp_error( $actor ) ) {
return $actor;
}
$activity_object = \json_decode( $outbox_item->post_content, true );
$type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
if ( $activity_object['type'] === $type ) {
$activity = Activity::init_from_array( $activity_object );
if ( ! $activity->get_actor() ) {
$activity->set_actor( $actor->get_id() );
}
} else {
$activity = new Activity();
$activity->set_type( $type );
$activity->set_id( $outbox_item->guid );
$activity->set_actor( $actor->get_id() );
// Pre-fill the Activity with data (for example cc and to).
$activity->set_object( $activity_object );
}
if ( 'Update' === $type ) {
$activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_modified ) ) );
}
/**
* Filters the Activity object before it is returned.
*
* @param Activity $activity The Activity object.
* @param \WP_Post $outbox_item The outbox item post object.
*/
return apply_filters( 'activitypub_get_outbox_activity', $activity, $outbox_item );
}
/**
* Get the Actor object from the Outbox item.
*
* @param \WP_Post $outbox_item The Outbox post.
*
* @return \Activitypub\Model\User|\Activitypub\Model\Blog|\WP_Error The Actor object or WP_Error.
*/
public static function get_actor( $outbox_item ) {
$actor_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_actor', true );
switch ( $actor_type ) {
case 'blog':
$actor_id = Actors::BLOG_USER_ID;
break;
case 'application':
$actor_id = Actors::APPLICATION_USER_ID;
break;
case 'user':
default:
$actor_id = $outbox_item->post_author;
break;
}
return Actors::get_by_id( $actor_id );
}
/**
* Get the Activity object from the Outbox item.
*
* @param \WP_Post $outbox_item The Outbox post.
*
* @return Activity|\WP_Error The Activity object or WP_Error.
*/
public static function maybe_get_activity( $outbox_item ) {
if ( ! $outbox_item instanceof \WP_Post ) {
return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
}
if ( 'ap_outbox' !== $outbox_item->post_type ) {
return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
}
// Check if Outbox Activity is public.
$visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
if ( ! in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) {
return new \WP_Error( 'private_outbox_item', 'Not a public Outbox item.' );
}
$activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) );
$activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
if ( ! in_array( $activity_type, $activity_types, true ) ) {
return new \WP_Error( 'private_outbox_item', 'Not public Outbox item type.' );
}
return self::get_activity( $outbox_item );
}
/**
* Get the object ID of an activity.
*
* @param Activity|Base_Object|string $data The activity object.
*
* @return string The object ID.
*/
private static function get_object_id( $data ) {
$object = $data->get_object();
if ( is_object( $object ) ) {
return self::get_object_id( $object );
}
if ( is_string( $object ) ) {
return $object;
}
return $data->get_id() ?? $data->get_actor();
}
/**
* Get the title of an activity recursively.
*
* @param Base_Object $activity_object The activity object.
*
* @return string The title.
*/
private static function get_object_title( $activity_object ) {
if ( ! $activity_object ) {
return '';
}
if ( is_string( $activity_object ) ) {
$post_id = url_to_postid( $activity_object );
return $post_id ? get_the_title( $post_id ) : '';
}
$title = $activity_object->get_name() ?? $activity_object->get_content();
if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
$title = $activity_object->get_object()->get_name() ?? $activity_object->get_object()->get_content();
}
return $title;
}
}

View File

@ -0,0 +1,226 @@
<?php
/**
* Replies collection file.
*
* @package Activitypub
*/
namespace Activitypub\Collection;
use WP_Post;
use WP_Comment;
use WP_Error;
use Activitypub\Comment;
use Activitypub\Model\Blog;
use Activitypub\Transformer\Post as PostTransformer;
use Activitypub\Transformer\Comment as CommentTransformer;
use function Activitypub\is_post_disabled;
use function Activitypub\is_local_comment;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\is_user_type_disabled;
/**
* Class containing code for getting replies Collections and CollectionPages of posts and comments.
*/
class Replies {
/**
* Build base arguments for fetching the comments of either a WordPress post or comment.
*
* @param WP_Post|WP_Comment|WP_Error $wp_object The post or comment to fetch replies for on success.
*/
private static function build_args( $wp_object ) {
$args = array(
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
'type' => 'comment',
);
if ( $wp_object instanceof WP_Post ) {
$args['parent'] = 0; // TODO: maybe this is unnecessary.
$args['post_id'] = $wp_object->ID;
} elseif ( $wp_object instanceof WP_Comment ) {
$args['parent'] = $wp_object->comment_ID;
} else {
return new WP_Error();
}
return $args;
}
/**
* Get the replies collections ID.
*
* @param WP_Post|WP_Comment $wp_object The post or comment to fetch replies for.
*
* @return string|WP_Error The rest URL of the replies collection or WP_Error if the object is not a post or comment.
*/
private static function get_id( $wp_object ) {
if ( $wp_object instanceof WP_Post ) {
return get_rest_url_by_path( sprintf( 'posts/%d/replies', $wp_object->ID ) );
} elseif ( $wp_object instanceof WP_Comment ) {
return get_rest_url_by_path( sprintf( 'comments/%d/replies', $wp_object->comment_ID ) );
} else {
return new WP_Error( 'unsupported_object', 'The object is not a post or comment.' );
}
}
/**
* Get the Replies collection.
*
* @param WP_Post|WP_Comment $wp_object The post or comment to fetch replies for.
*
* @return array|\WP_Error|null An associative array containing the replies collection without JSON-LD context on success.
*/
public static function get_collection( $wp_object ) {
$id = self::get_id( $wp_object );
if ( is_wp_error( $id ) ) {
return \wp_is_serving_rest_request() ? $id : null;
}
$replies = array(
'id' => $id,
'type' => 'Collection',
);
$replies['first'] = self::get_collection_page( $wp_object, 1, $replies['id'] );
return $replies;
}
/**
* Returns a replies collection page as an associative array.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
*
* @param WP_Post|WP_Comment $wp_object The post of comment the replies are for.
* @param int $page The current pagination page.
* @param string $part_of Optional. The collection id/url the returned CollectionPage belongs to. Default null.
*
* @return array|WP_Error|null A CollectionPage as an associative array on success, WP_Error or null on failure.
*/
public static function get_collection_page( $wp_object, $page, $part_of = null ) {
// Build initial arguments for fetching approved comments.
$args = self::build_args( $wp_object );
if ( is_wp_error( $args ) ) {
return \wp_is_serving_rest_request() ? $args : null;
}
// Retrieve the partOf if not already given.
$part_of = $part_of ?? self::get_id( $wp_object );
// If the collection page does not exist.
if ( is_wp_error( $part_of ) ) {
return \wp_is_serving_rest_request() ? $part_of : null;
}
// Get to total replies count.
$total_replies = \get_comments( array_merge( $args, array( 'count' => true ) ) );
// If set to zero, we get errors below. You need at least one comment per page, here.
$args['number'] = max( (int) \get_option( 'comments_per_page' ), 1 );
$args['offset'] = intval( $page - 1 ) * $args['number'];
// Get the ActivityPub ID's of the comments, without local-only comments.
$comment_ids = self::get_reply_ids( \get_comments( $args ) );
// Build the associative CollectionPage array.
$collection_page = array(
'id' => \add_query_arg( 'page', $page, $part_of ),
'type' => 'CollectionPage',
'partOf' => $part_of,
'items' => $comment_ids,
);
if ( ( $total_replies / $args['number'] ) > $page ) {
$collection_page['next'] = \add_query_arg( 'page', $page + 1, $part_of );
}
if ( $page > 1 ) {
$collection_page['prev'] = \add_query_arg( 'page', $page - 1, $part_of );
}
return $collection_page;
}
/**
* Get the context collection for a post.
*
* @param int $post_id The post ID.
*
* @return array|false The context for the post or false if the post is not found or disabled.
*/
public static function get_context_collection( $post_id ) {
$post = \get_post( $post_id );
if ( ! $post || is_post_disabled( $post_id ) ) {
return false;
}
$comments = \get_comments(
array(
'post_id' => $post_id,
'type' => 'comment',
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
)
);
$ids = self::get_reply_ids( $comments, true );
$post_uri = ( new PostTransformer( $post ) )->to_id();
\array_unshift( $ids, $post_uri );
$author = Actors::get_by_id( $post->post_author );
if ( is_wp_error( $author ) ) {
if ( is_user_type_disabled( 'blog' ) ) {
return false;
}
$author = new Blog();
}
return array(
'type' => 'OrderedCollection',
'url' => \get_permalink( $post_id ),
'attributedTo' => $author->get_id(),
'totalItems' => count( $ids ),
'items' => $ids,
);
}
/**
* Get the ActivityPub ID's from a list of comments.
*
* It takes only federated/non-local comments into account, others also do not have an
* ActivityPub ID available.
*
* @param WP_Comment[] $comments The comments to retrieve the ActivityPub ids from.
* @param boolean $include_blog_comments Optional. Include blog comments in the returned array. Default false.
*
* @return string[] A list of the ActivityPub ID's.
*/
private static function get_reply_ids( $comments, $include_blog_comments = false ) {
$comment_ids = array();
foreach ( $comments as $comment ) {
if ( is_local_comment( $comment ) ) {
continue;
}
$public_comment_id = Comment::get_source_id( $comment->comment_ID );
if ( $public_comment_id ) {
$comment_ids[] = $public_comment_id;
continue;
}
if ( $include_blog_comments ) {
$comment_ids[] = ( new CommentTransformer( $comment ) )->to_id();
}
}
return \array_unique( $comment_ids );
}
}

View File

@ -1,63 +1,29 @@
<?php
/**
* Users collection file.
*
* @package Activitypub
*/
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\url_to_authorid;
use function Activitypub\is_user_disabled;
class Users {
/**
* Users collection.
*
* @deprecated version 4.2.0
*/
class Users extends Actors {
/**
* 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
* Get the User by ID.
*
* @param int $user_id The User-ID.
*
* @return \Acitvitypub\Model\User The User.
* @return User|Blog|Application|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_id( $user_id ) {
if ( is_string( $user_id ) || is_numeric( $user_id ) ) {
$user_id = (int) $user_id;
}
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_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 )
);
return parent::get_by_id( $user_id );
}
/**
@ -65,199 +31,38 @@ class Users {
*
* @param string $username The User-Name.
*
* @return \Acitvitypub\Model\User The User.
* @return User|Blog|Application|WP_Error The User or WP_Error if user not found.
*/
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 );
}
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_username' );
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] );
}
$username = str_replace( array( '*', '%' ), '', $username );
// 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 )
);
return parent::get_by_username( $username );
}
/**
* Get the User by resource.
*
* @param string $resource The User-Resource.
* @param string $uri The User-Resource.
*
* @return \Acitvitypub\Model\User The User.
* @return User|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_resource( $resource ) {
$scheme = 'acct';
$match = array();
// try to extract the scheme and the host
if ( preg_match( '/^([a-zA-Z^:]+):(.*)$/i', $resource, $match ) ) {
// extract the scheme
$scheme = esc_attr( $match[1] );
}
public static function get_by_resource( $uri ) {
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_resource' );
switch ( $scheme ) {
// check for http(s) URIs
case 'http':
case 'https':
$url_parts = wp_parse_url( $resource );
// check for http(s)://blog.example.com/@username
if (
isset( $url_parts['path'] ) &&
str_starts_with( $url_parts['path'], '/@' )
) {
$identifier = str_replace( '/@', '', $url_parts['path'] );
$identifier = untrailingslashit( $identifier );
return self::get_by_username( $identifier );
}
// check for http(s)://blog.example.com/author/username
$user_id = url_to_authorid( $resource );
if ( $user_id ) {
return self::get_by_id( $user_id );
}
// check for http(s)://blog.example.com/
if (
self::normalize_url( site_url() ) === self::normalize_url( $resource ) ||
self::normalize_url( home_url() ) === self::normalize_url( $resource )
) {
return self::get_by_id( self::BLOG_USER_ID );
}
return new WP_Error(
'activitypub_no_user_found',
\__( 'User not found', 'activitypub' ),
array( 'status' => 404 )
);
// check for acct URIs
case 'acct':
$resource = \str_replace( 'acct:', '', $resource );
$identifier = \substr( $resource, 0, \strrpos( $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 !== $host ) {
return new WP_Error(
'activitypub_wrong_host',
\__( 'Resource host does not match blog host', 'activitypub' ),
array( 'status' => 404 )
);
}
// prepare wildcards https://github.com/mastodon/mastodon/issues/22213
if ( in_array( $identifier, array( '_', '*', '' ), true ) ) {
return self::get_by_id( self::BLOG_USER_ID );
}
return self::get_by_username( $identifier );
default:
return new WP_Error(
'activitypub_wrong_scheme',
\__( 'Wrong scheme', 'activitypub' ),
array( 'status' => 404 )
);
}
return parent::get_by_resource( $uri );
}
/**
* Get the User by resource.
*
* @param string $resource The User-Resource.
* @param string $id The User-Resource.
*
* @return \Acitvitypub\Model\User The User.
* @return User|Blog|Application|WP_Error The User or WP_Error if user not found.
*/
public static function get_by_various( $id ) {
if ( is_numeric( $id ) ) {
return self::get_by_id( $id );
} elseif (
// is URL
filter_var( $id, FILTER_VALIDATE_URL ) ||
// is acct
str_starts_with( $id, 'acct:' )
) {
return self::get_by_resource( $id );
} else {
return self::get_by_username( $id );
}
}
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_by_various' );
/**
* Normalize a host.
*
* @param string $host The host.
*
* @return string The normalized host.
*/
public static function normalize_host( $host ) {
return \str_replace( 'www.', '', $host );
}
/**
* Normalize a URL.
*
* @param string $url The URL.
*
* @return string The normalized URL.
*/
public static function normalize_url( $url ) {
$url = \untrailingslashit( $url );
$url = \str_replace( 'https://', '', $url );
$url = \str_replace( 'http://', '', $url );
$url = \str_replace( 'www.', '', $url );
return $url;
return parent::get_by_various( $id );
}
/**
@ -266,18 +71,8 @@ class Users {
* @return array The User collection.
*/
public static function get_collection() {
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);
_deprecated_function( __METHOD__, '4.2.0', 'Activitypub\Collection\Actors::get_collection' );
$return = array();
foreach ( $users as $user ) {
$return[] = User::from_wp_user( $user->ID );
}
return $return;
return parent::get_collection();
}
}

View File

@ -1,6 +1,8 @@
<?php
/**
* ActivityPub implementation for WordPress/PHP functions either missing from older WordPress/PHP versions or not included by default.
*
* @package Activitypub
*/
if ( ! function_exists( 'str_starts_with' ) ) {
@ -23,19 +25,6 @@ if ( ! function_exists( 'str_starts_with' ) ) {
}
}
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 ) ) );
}
}
if ( ! function_exists( 'is_countable' ) ) {
/**
* Polyfill for `is_countable()` function added in PHP 7.3.
@ -56,18 +45,28 @@ if ( ! function_exists( 'is_countable' ) ) {
* @return bool True if `$array` is a list, otherwise false.
*/
if ( ! function_exists( 'array_is_list' ) ) {
function array_is_list( $array ) {
if ( ! is_array( $array ) ) {
/**
* Check if an array is a list.
*
* An array is considered a list if its keys are a range of numbers
* starting from 0 and ending at count( $array ) - 1.
*
* @param array $input The array to check.
*
* @return bool True if `$input` is a list, otherwise false.
*/
function array_is_list( $input ) {
if ( ! is_array( $input ) ) {
return false;
}
if ( array_values( $array ) === $array ) {
if ( array_values( $input ) === $input ) {
return true;
}
$next_key = -1;
foreach ( $array as $k => $v ) {
foreach ( $input as $k => $v ) {
if ( ++$next_key !== $k ) {
return false;
}
@ -97,3 +96,16 @@ if ( ! function_exists( 'str_contains' ) ) {
return false !== strpos( $haystack, $needle );
}
}
if ( ! function_exists( 'wp_is_serving_rest_request' ) ) {
/**
* Polyfill for `wp_is_serving_rest_request()` function added in WordPress 6.5.
*
* @see https://developer.wordpress.org/reference/functions/wp_is_serving_rest_request/
*
* @return bool True if it's a WordPress REST API request, false otherwise.
*/
function wp_is_serving_rest_request() {
return defined( 'REST_REQUEST' ) && REST_REQUEST;
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* Plugin constants.
*
* @package Activitypub
*/
// The following constants can be defined in your wp-config.php file to override the default values.
\defined( 'ACTIVITYPUB_REST_NAMESPACE' ) || \define( 'ACTIVITYPUB_REST_NAMESPACE', 'activitypub/1.0' );
\defined( 'ACTIVITYPUB_EXCERPT_LENGTH' ) || \define( 'ACTIVITYPUB_EXCERPT_LENGTH', 400 );
\defined( 'ACTIVITYPUB_NOTE_LENGTH' ) || \define( 'ACTIVITYPUB_NOTE_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_URL_REGEXP' ) || \define( 'ACTIVITYPUB_URL_REGEXP', '(https?:|www\.)\S+[\w\/]' );
\defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "[ap_title type=\"html\"]\n\n[ap_content]\n\n[ap_hashtags]" );
\defined( 'ACTIVITYPUB_DISABLE_REWRITES' ) || \define( 'ACTIVITYPUB_DISABLE_REWRITES', false );
\defined( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE' ) || \define( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE', 'wordpress-post-format' );
\defined( 'ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE' ) || \define( 'ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE', 100 );
// The following constants are invariable and define values used throughout the plugin.
/*
* Mastodon HTML sanitizer.
*
* @see https://docs.joinmastodon.org/spec/activitypub/#sanitization
*/
\define(
'ACTIVITYPUB_MASTODON_HTML_SANITIZER',
array(
'p' => array(),
'span' => array( 'class' => true ),
'br' => array(),
'a' => array(
'href' => true,
'rel' => true,
'class' => true,
),
'del' => array(),
'pre' => array(),
'code' => array(),
'em' => array(),
'strong' => array(),
'b' => array(),
'i' => array(),
'u' => array(),
'ul' => array(),
'ol' => array(
'start' => true,
'reversed' => true,
),
'li' => array( 'value' => true ),
'blockquote' => array(),
'h1' => array(),
'h2' => array(),
'h3' => array(),
'h4' => array(),
)
);
\define( 'ACTIVITYPUB_DATE_TIME_RFC3339', 'Y-m-d\TH:i:s\Z' );
// Define Actor-Modes for the plugin.
\define( 'ACTIVITYPUB_ACTOR_MODE', 'actor' );
\define( 'ACTIVITYPUB_BLOG_MODE', 'blog' );
\define( 'ACTIVITYPUB_ACTOR_AND_BLOG_MODE', 'actor_blog' );
// Post visibility constants.
\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC', '' );
\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC', 'quiet_public' );
\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE', 'private' );
\define( 'ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL', 'local' );

View File

@ -1,17 +1,79 @@
<?php
/**
* Debugging functions.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* Allow localhost URLs if WP_DEBUG is true.
*
* @param array $r Array of HTTP request args.
* @param string $url The request URL.
* @param array $parsed_args An array of HTTP request arguments.
*
* @return array Array or string of HTTP request arguments.
*/
function allow_localhost( $r, $url ) {
$r['reject_unsafe_urls'] = false;
function allow_localhost( $parsed_args ) {
$parsed_args['reject_unsafe_urls'] = false;
return $r;
return $parsed_args;
}
add_filter( 'http_request_args', '\Activitypub\allow_localhost', 10, 2 );
\add_filter( 'http_request_args', '\Activitypub\allow_localhost' );
/**
* Debug the outbox post type.
*
* @param array $args The arguments for the post type.
* @param string $post_type The post type.
*
* @return array The arguments for the post type.
*/
function debug_outbox_post_type( $args, $post_type ) {
if ( 'ap_outbox' !== $post_type ) {
return $args;
}
$args['show_ui'] = true;
$args['menu_icon'] = 'dashicons-upload';
return $args;
}
\add_filter( 'register_post_type_args', '\Activitypub\debug_outbox_post_type', 10, 2 );
/**
* Debug the outbox post type column.
*
* @param array $columns The columns.
* @param string $post_type The post type.
*
* @return array The updated columns.
*/
function debug_outbox_post_type_column( $columns, $post_type ) {
if ( 'ap_outbox' !== $post_type ) {
return $columns;
}
$columns['ap_outbox_meta'] = 'Meta';
return $columns;
}
\add_filter( 'manage_posts_columns', '\Activitypub\debug_outbox_post_type_column', 10, 2 );
/**
* Debug the outbox post type meta.
*
* @param string $column_name The column name.
* @param int $post_id The post ID.
*
* @return void
*/
function manage_posts_custom_column( $column_name, $post_id ) {
if ( 'ap_outbox_meta' === $column_name ) {
$meta = \get_post_meta( $post_id );
foreach ( $meta as $key => $value ) {
echo \esc_attr( $key ) . ': ' . \esc_html( $value[0] ) . '<br>';
}
}
}
\add_action( 'manage_posts_custom_column', '\Activitypub\manage_posts_custom_column', 10, 2 );

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,125 @@
<?php
/**
* Announce handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Http;
use Activitypub\Comment;
use Activitypub\Collection\Interactions;
use function Activitypub\object_to_uri;
use function Activitypub\is_activity_public;
/**
* Handle Create requests.
*/
class Announce {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_inbox_announce',
array( self::class, 'handle_announce' ),
10,
3
);
}
/**
* Handles "Announce" requests.
*
* @param array $announcement The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity $activity The activity object.
*/
public static function handle_announce( $announcement, $user_id, $activity = null ) {
// Check if Activity is public or not.
if ( ! is_activity_public( $announcement ) ) {
// @todo maybe send email
return;
}
// Check if reposts are allowed.
if ( ! Comment::is_comment_type_enabled( 'repost' ) ) {
return;
}
self::maybe_save_announce( $announcement, $user_id );
if ( is_string( $announcement['object'] ) ) {
$object = Http::get_remote_object( $announcement['object'] );
} else {
$object = $announcement['object'];
}
if ( ! $object || is_wp_error( $object ) ) {
return;
}
if ( ! isset( $object['type'] ) ) {
return;
}
$type = \strtolower( $object['type'] );
/**
* Fires after an Announce has been received.
*
* @param array $object The object.
* @param int $user_id The id of the local blog-user.
* @param string $type The type of the activity.
* @param \Activitypub\Activity\Activity|null $activity The activity object.
*/
\do_action( 'activitypub_inbox', $object, $user_id, $type, $activity );
/**
* Fires after an Announce of a specific type has been received.
*
* @param array $object The object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity|null $activity The activity object.
*/
\do_action( "activitypub_inbox_{$type}", $object, $user_id, $activity );
}
/**
* Try to save the Announce.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
*/
public static function maybe_save_announce( $activity, $user_id ) {
$url = object_to_uri( $activity['object'] );
if ( empty( $url ) ) {
return;
}
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
if ( $exists ) {
return;
}
$state = Interactions::add_reaction( $activity );
$reaction = null;
if ( $state && ! is_wp_error( $state ) ) {
$reaction = get_comment( $state );
}
/**
* Fires after an Announce has been saved.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param mixed $state The state of the reaction.
* @param mixed $reaction The reaction.
*/
do_action( 'activitypub_handled_announce', $activity, $user_id, $state, $reaction );
}
}

View File

@ -1,18 +1,25 @@
<?php
/**
* Create handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use WP_Error;
use Activitypub\Collection\Interactions;
use function Activitypub\is_self_ping;
use function Activitypub\is_activity_reply;
use function Activitypub\is_activity_public;
use function Activitypub\object_id_to_comment;
/**
* Handle Create requests
* Handle Create requests.
*/
class Create {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
@ -21,50 +28,106 @@ class Create {
10,
3
);
\add_filter(
'activitypub_validate_object',
array( self::class, 'validate_object' ),
10,
3
);
}
/**
* Handles "Create" requests
* Handles "Create" requests.
*
* @param array $array The activity-object
* @param int $user_id The id of the local blog-user
* @param Activitypub\Activity $object The activity object
*
* @return void
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null.
*/
public static function handle_create( $array, $user_id, $object = null ) {
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return;
}
public static function handle_create( $activity, $user_id, $activity_object = null ) {
// Check if Activity is public or not.
if (
! isset( $array['object'] ) ||
! isset( $array['object']['id'] )
! is_activity_public( $activity ) ||
! is_activity_reply( $activity )
) {
return;
}
// check if Activity is public or not
if ( ! is_activity_public( $array ) ) {
// @todo maybe send email
return;
}
$check_dupe = object_id_to_comment( $activity['object']['id'] );
$check_dupe = object_id_to_comment( $array['object']['id'] );
// if comment exists, call update action
// If comment exists, call update action.
if ( $check_dupe ) {
\do_action( 'activitypub_inbox_update', $array, $user_id, $object );
/**
* Fires when a Create activity is received for an existing comment.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \Activitypub\Activity\Activity $activity_object The activity object.
*/
\do_action( 'activitypub_inbox_update', $activity, $user_id, $activity_object );
return;
}
$state = Interactions::add_comment( $array );
if ( is_self_ping( $activity['object']['id'] ) ) {
return;
}
$state = Interactions::add_comment( $activity );
$reaction = null;
if ( $state && ! \is_wp_error( $reaction ) ) {
if ( $state && ! \is_wp_error( $state ) ) {
$reaction = \get_comment( $state );
}
\do_action( 'activitypub_handled_create', $array, $user_id, $state, $reaction );
/**
* Fires after a Create activity has been handled.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
* @param \WP_Comment|\WP_Error $state The comment object or WP_Error.
* @param \WP_Comment|\WP_Error|null $reaction The reaction object or WP_Error.
*/
\do_action( 'activitypub_handled_create', $activity, $user_id, $state, $reaction );
}
/**
* Validate the object.
*
* @param bool $valid The validation state.
* @param string $param The object parameter.
* @param \WP_REST_Request $request The request object.
*
* @return bool The validation state: true if valid, false if not.
*/
public static function validate_object( $valid, $param, $request ) {
$json_params = $request->get_json_params();
if ( empty( $json_params['type'] ) ) {
return false;
}
if (
'Create' !== $json_params['type'] ||
is_wp_error( $request )
) {
return $valid;
}
$object = $json_params['object'];
if ( ! is_array( $object ) ) {
return false;
}
$required = array(
'id',
'content',
);
if ( array_intersect( $required, array_keys( $object ) ) !== $required ) {
return false;
}
return $valid;
}
}

View File

@ -1,52 +1,47 @@
<?php
/**
* Delete handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use WP_Error;
use WP_REST_Request;
use Activitypub\Http;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Interactions;
use function Activitypub\object_to_uri;
/**
* Handles Delete requests.
*/
class Delete {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_inbox_delete',
array( self::class, 'handle_delete' )
);
// defer signature verification for `Delete` requests.
\add_filter(
'activitypub_defer_signature_verification',
array( self::class, 'defer_signature_verification' ),
10,
2
);
// side effect
\add_action(
'activitypub_delete_actor_interactions',
array( self::class, 'delete_interactions' )
);
\add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ) );
\add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 );
\add_action( 'activitypub_delete_actor_interactions', array( self::class, 'delete_interactions' ) );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
}
/**
* Handles "Delete" requests.
*
* @param array $activity The delete activity.
* @param int $user_id The ID of the user performing the delete activity.
*/
public static function handle_delete( $activity ) {
$object_type = isset( $activity['object']['type'] ) ? $activity['object']['type'] : '';
switch ( $object_type ) {
// Actor Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
/*
* Actor Types.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
*/
case 'Person':
case 'Group':
case 'Organization':
@ -54,8 +49,12 @@ class Delete {
case 'Application':
self::maybe_delete_follower( $activity );
break;
// Object and Link Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
/*
* Object and Link Types.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
*/
case 'Note':
case 'Article':
case 'Image':
@ -65,26 +64,34 @@ class Delete {
case 'Document':
self::maybe_delete_interaction( $activity );
break;
// Tombstone Type
// @see: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
/*
* Tombstone Type.
*
* @see: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
*/
case 'Tombstone':
self::maybe_delete_interaction( $activity );
break;
// Minimal Activity
// @see https://www.w3.org/TR/activitystreams-core/#example-1
/*
* Minimal Activity.
*
* @see https://www.w3.org/TR/activitystreams-core/#example-1
*/
default:
// ignore non Minimal Activities.
// Ignore non Minimal Activities.
if ( ! is_string( $activity['object'] ) ) {
return;
}
// check if Object is an Actor.
// Check if Object is an Actor.
if ( $activity['actor'] === $activity['object'] ) {
self::maybe_delete_follower( $activity );
} else { // assume a interaction otherwise.
} else { // Assume an interaction otherwise.
self::maybe_delete_interaction( $activity );
}
// maybe handle Delete Activity for other Object Types.
// Maybe handle Delete Activity for other Object Types.
break;
}
}
@ -95,9 +102,10 @@ class Delete {
* @param array $activity The delete activity.
*/
public static function maybe_delete_follower( $activity ) {
/* @var \Activitypub\Model\Follower $follower Follower object. */
$follower = Followers::get_follower_by_actor( $activity['actor'] );
// verify if Actor is deleted.
// Verify that Actor is deleted.
if ( $follower && Http::is_tombstone( $activity['actor'] ) ) {
$follower->delete();
self::maybe_delete_interactions( $activity );
@ -110,7 +118,7 @@ class Delete {
* @param array $activity The delete activity.
*/
public static function maybe_delete_interactions( $activity ) {
// verify if Actor is deleted.
// Verify that Actor is deleted.
if ( Http::is_tombstone( $activity['actor'] ) ) {
\wp_schedule_single_event(
\time(),
@ -123,15 +131,13 @@ class Delete {
/**
* Delete comments from an Actor.
*
* @param array $comments The comments to delete.
* @param string $actor The URL of the actor whose comments to delete.
*/
public static function delete_interactions( $actor ) {
$comments = Interactions::get_interactions_by_actor( $actor );
if ( is_array( $comments ) ) {
foreach ( $comments as $comment ) {
wp_delete_comment( $comment->comment_ID );
}
foreach ( $comments as $comment ) {
wp_delete_comment( $comment, true );
}
}
@ -139,8 +145,6 @@ class Delete {
* Delete a Reaction if URL is a Tombstone.
*
* @param array $activity The delete activity.
*
* @return void
*/
public static function maybe_delete_interaction( $activity ) {
if ( is_array( $activity['object'] ) ) {
@ -175,4 +179,18 @@ class Delete {
return false;
}
/**
* Set the object to the object ID.
*
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @return \Activitypub\Activity\Activity The filtered Activity object.
*/
public static function outbox_activity( $activity ) {
if ( 'Delete' === $activity->get_type() ) {
$activity->set_object( object_to_uri( $activity->get_object() ) );
}
return $activity;
}
}

View File

@ -1,17 +1,25 @@
<?php
/**
* Follow handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Http;
use Activitypub\Notification;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use function Activitypub\add_to_outbox;
/**
* Handle Follow requests
* Handle Follow requests.
*/
class Follow {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
@ -21,64 +29,70 @@ class Follow {
\add_action(
'activitypub_followers_post_follow',
array( self::class, 'send_follow_response' ),
array( self::class, 'queue_accept' ),
10,
4
);
}
/**
* Handle "Follow" requests
* Handle "Follow" requests.
*
* @param array $activity The activity object
* @param int $user_id The user ID
* @param array $activity The activity object.
*/
public static function handle_follow( $activity ) {
$user = Users::get_by_resource( $activity['object'] );
$user = Actors::get_by_resource( $activity['object'] );
if ( ! $user || is_wp_error( $user ) ) {
// If we can not find a user,
// we can not initiate a follow process
// If we can not find a user, we can not initiate a follow process.
return;
}
$user_id = $user->get__id();
// save follower
// Save follower.
$follower = Followers::add_follower(
$user_id,
$activity['actor']
);
do_action(
'activitypub_followers_post_follow',
/**
* Fires after a new follower has been added.
*
* @param string $actor The URL of the actor (follower) who initiated the follow.
* @param array $activity The complete activity data of the follow request.
* @param int $user_id The ID of the WordPress user being followed.
* @param \Activitypub\Model\Follower|\WP_Error $follower The Follower object containing the new follower's data.
*/
do_action( 'activitypub_followers_post_follow', $activity['actor'], $activity, $user_id, $follower );
// Send notification.
$notification = new Notification(
'follow',
$activity['actor'],
$activity,
$user_id,
$follower
$user_id
);
$notification->send();
}
/**
* Send Accept response
* 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
* @param string $actor The Actor URL.
* @param array $activity_object The Activity object.
* @param int $user_id The ID of the WordPress User.
* @param \Activitypub\Model\Follower|\WP_Error $follower The Follower object.
*/
public static function send_follow_response( $actor, $object, $user_id, $follower ) {
public static function queue_accept( $actor, $activity_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
// Impossible to send a "Reject" because we can not get the Remote-Inbox.
return;
}
// only send minimal data
$object = array_intersect_key(
$object,
// Only send minimal data.
$activity_object = array_intersect_key(
$activity_object,
array_flip(
array(
'id',
@ -89,21 +103,12 @@ class Follow {
)
);
$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->set_actor( Actors::get_by_id( $user_id )->get_id() );
$activity->set_object( $activity_object );
$activity->set_to( array( $actor ) );
$activity = $activity->to_json();
Http::post( $inbox, $activity, $user_id );
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
}
}

View File

@ -0,0 +1,80 @@
<?php
/**
* Like handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Comment;
use Activitypub\Collection\Interactions;
use function Activitypub\object_to_uri;
/**
* Handle Like requests.
*/
class Like {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_like', array( self::class, 'handle_like' ), 10, 2 );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
}
/**
* Handles "Like" requests.
*
* @param array $like The Activity array.
* @param int $user_id The ID of the local blog user.
*/
public static function handle_like( $like, $user_id ) {
if ( ! Comment::is_comment_type_enabled( 'like' ) ) {
return;
}
$url = object_to_uri( $like['object'] );
if ( empty( $url ) ) {
return;
}
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
if ( $exists ) {
return;
}
$state = Interactions::add_reaction( $like );
$reaction = null;
if ( $state && ! is_wp_error( $state ) ) {
$reaction = get_comment( $state );
}
/**
* Fires after a Like has been handled.
*
* @param array $like The Activity array.
* @param int $user_id The ID of the local blog user.
* @param mixed $state The state of the reaction.
* @param mixed $reaction The reaction object.
*/
do_action( 'activitypub_handled_like', $like, $user_id, $state, $reaction );
}
/**
* Set the object to the object ID.
*
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @return \Activitypub\Activity\Activity The filtered Activity object.
*/
public static function outbox_activity( $activity ) {
if ( 'Like' === $activity->get_type() ) {
$activity->set_object( object_to_uri( $activity->get_object() ) );
}
return $activity;
}
}

View File

@ -0,0 +1,213 @@
<?php
/**
* Move handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Http;
use Activitypub\Collection\Followers;
use function Activitypub\object_to_uri;
/**
* Handle Move requests.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move
* @see https://docs.joinmastodon.org/user/moving/
* @see https://docs.joinmastodon.org/spec/activitypub/#Move
*/
class Move {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_move', array( self::class, 'handle_move' ) );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
}
/**
* Handle Move requests.
*
* @param array $activity The JSON "Move" Activity.
*/
public static function handle_move( $activity ) {
$target = self::extract_target( $activity );
$origin = self::extract_origin( $activity );
if ( ! $target || ! $origin ) {
return;
}
$target_object = Http::get_remote_object( $target );
$origin_object = Http::get_remote_object( $origin );
$verified = self::verify_move( $target_object, $origin_object );
if ( ! $verified ) {
return;
}
$target_follower = Followers::get_follower_by_actor( $target );
$origin_follower = Followers::get_follower_by_actor( $origin );
/*
* If the new target is followed, but the origin is not,
* everything is fine, so we can return.
*/
if ( $target_follower && ! $origin_follower ) {
return;
}
/*
* If the new target is not followed, but the origin is,
* update the origin follower to the new target.
*/
if ( ! $target_follower && $origin_follower ) {
$origin_follower->from_array( $target_object );
$origin_follower->set_id( $target );
$origin_id = $origin_follower->upsert();
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$wpdb->posts,
array( 'guid' => sanitize_url( $target ) ),
array( 'ID' => sanitize_key( $origin_id ) )
);
// Clear the cache.
wp_cache_delete( $origin_id, 'posts' );
return;
}
/*
* If the new target is followed, and the origin is followed,
* move users and delete the origin follower.
*/
if ( $target_follower && $origin_follower ) {
$origin_users = \get_post_meta( $origin_follower->get__id(), '_activitypub_user_id', false );
$target_users = \get_post_meta( $target_follower->get__id(), '_activitypub_user_id', false );
// Get all user ids from $origin_users that are not in $target_users.
$users = \array_diff( $origin_users, $target_users );
foreach ( $users as $user_id ) {
\add_post_meta( $target_follower->get__id(), '_activitypub_user_id', $user_id );
}
$origin_follower->delete();
}
}
/**
* Convert the object and origin to the correct format.
*
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @return \Activitypub\Activity\Activity The filtered Activity object.
*/
public static function outbox_activity( $activity ) {
if ( 'Move' === $activity->get_type() ) {
$activity->set_object( object_to_uri( $activity->get_object() ) );
$activity->set_origin( $activity->get_actor() );
$activity->set_target( $activity->get_object() );
}
return $activity;
}
/**
* Extract the target from the activity.
*
* The ActivityStreams spec define the `target` attribute as the
* destination of the activity, but Mastodon uses the `object`
* attribute to move profiles.
*
* @param array $activity The JSON "Move" Activity.
*
* @return string|null The target URI or null if not found.
*/
private static function extract_target( $activity ) {
if ( ! empty( $activity['target'] ) ) {
return object_to_uri( $activity['target'] );
}
if ( ! empty( $activity['object'] ) ) {
return object_to_uri( $activity['object'] );
}
return null;
}
/**
* Extract the origin from the activity.
*
* The ActivityStreams spec define the `origin` attribute as source
* of the activity, but Mastodon uses the `actor` attribute as source
* to move profiles.
*
* @param array $activity The JSON "Move" Activity.
*
* @return string|null The origin URI or null if not found.
*/
private static function extract_origin( $activity ) {
if ( ! empty( $activity['origin'] ) ) {
return object_to_uri( $activity['origin'] );
}
if ( ! empty( $activity['actor'] ) ) {
return object_to_uri( $activity['actor'] );
}
return null;
}
/**
* Verify the move.
*
* @param array $target_object The target object.
* @param array $origin_object The origin object.
*
* @return bool True if the move is verified, false otherwise.
*/
private static function verify_move( $target_object, $origin_object ) {
// Check if both objects are valid.
if ( \is_wp_error( $target_object ) || \is_wp_error( $origin_object ) ) {
return false;
}
// Check if both objects are persons.
if ( 'Person' !== $target_object['type'] || 'Person' !== $origin_object['type'] ) {
return false;
}
// Check if the target and origin are not the same.
if ( $target_object['id'] === $origin_object['id'] ) {
return false;
}
// Check if the target has an alsoKnownAs property.
if ( empty( $target_object['also_known_as'] ) ) {
return false;
}
// Check if the origin is in the alsoKnownAs property of the target.
if ( ! in_array( $origin_object['id'], $target_object['also_known_as'], true ) ) {
return false;
}
// Check if the origin has a movedTo property.
if ( empty( $origin_object['movedTo'] ) ) {
return false;
}
// Check if the movedTo property of the origin is the target.
if ( $origin_object['movedTo'] !== $target_object['id'] ) {
return false;
}
return true;
}
}

View File

@ -1,47 +1,90 @@
<?php
/**
* Undo handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Collection\Users;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Comment;
use function Activitypub\object_to_uri;
/**
* Handle Undo requests
* Handle Undo requests.
*/
class Undo {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_inbox_undo',
array( self::class, 'handle_undo' )
array( self::class, 'handle_undo' ),
10,
2
);
}
/**
* Handle "Unfollow" requests
* Handle "Unfollow" requests.
*
* @param array $activity The JSON "Undo" Activity
* @param int $user_id The ID of the ID of the WordPress User
* @param array $activity The JSON "Undo" Activity.
* @param int|null $user_id The ID of the user who initiated the "Undo" activity.
*/
public static function handle_undo( $activity ) {
public static function handle_undo( $activity, $user_id ) {
if (
isset( $activity['object']['type'] ) &&
'Follow' === $activity['object']['type'] &&
isset( $activity['object']['object'] ) &&
filter_var( $activity['object']['object'], FILTER_VALIDATE_URL )
! isset( $activity['object']['type'] ) ||
! isset( $activity['object']['object'] )
) {
$user = Users::get_by_resource( $activity['object']['object'] );
return;
}
$type = $activity['object']['type'];
$state = false;
// Handle "Unfollow" requests.
if ( 'Follow' === $type ) {
$id = object_to_uri( $activity['object']['object'] );
$user = Actors::get_by_resource( $id );
if ( ! $user || is_wp_error( $user ) ) {
// If we can not find a user,
// we can not initiate a follow process
// If we can not find a user, we can not initiate a follow process.
return;
}
$user_id = $user->get__id();
$actor = object_to_uri( $activity['actor'] );
Followers::remove_follower( $user_id, $activity['actor'] );
$state = Followers::remove_follower( $user_id, $actor );
}
// Handle "Undo" requests for "Like" and "Create" activities.
if ( in_array( $type, array( 'Like', 'Create', 'Announce' ), true ) ) {
if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
return;
}
$object_id = object_to_uri( $activity['object'] );
$comment = Comment::object_id_to_comment( esc_url_raw( $object_id ) );
if ( empty( $comment ) ) {
return;
}
$state = wp_delete_comment( $comment, true );
}
/**
* Fires after an "Undo" activity has been handled.
*
* @param array $activity The JSON "Undo" Activity.
* @param int|null $user_id The ID of the user who initiated the "Undo" activity otherwise null.
* @param mixed $state The state of the "Undo" activity.
*/
do_action( 'activitypub_handled_undo', $activity, $user_id, $state );
}
}

View File

@ -1,7 +1,13 @@
<?php
/**
* Update handler file.
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use WP_Error;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Interactions;
use function Activitypub\get_remote_metadata_by_actor;
@ -11,7 +17,7 @@ use function Activitypub\get_remote_metadata_by_actor;
*/
class Update {
/**
* Initialize the class, registering WordPress hooks
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
@ -21,26 +27,32 @@ class Update {
}
/**
* Handle "Update" requests
* Handle "Update" requests.
*
* @param array $array The activity-object
* @param int $user_id The id of the local blog-user
* @param array $activity The Activity object.
*/
public static function handle_update( $array ) {
$object_type = isset( $array['object']['type'] ) ? $array['object']['type'] : '';
public static function handle_update( $activity ) {
$object_type = isset( $activity['object']['type'] ) ? $activity['object']['type'] : '';
switch ( $object_type ) {
// Actor Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
/*
* Actor Types.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
*/
case 'Person':
case 'Group':
case 'Organization':
case 'Service':
case 'Application':
self::update_actor( $array );
self::update_actor( $activity );
break;
// Object and Link Types
// @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
/*
* Object and Link Types.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
*/
case 'Note':
case 'Article':
case 'Image':
@ -48,22 +60,23 @@ class Update {
case 'Video':
case 'Event':
case 'Document':
self::update_interaction( $array );
self::update_interaction( $activity );
break;
// Minimal Activity
// @see https://www.w3.org/TR/activitystreams-core/#example-1
/*
* Minimal Activity.
*
* @see https://www.w3.org/TR/activitystreams-core/#example-1
*/
default:
break;
}
}
/**
* Update an Interaction
* Update an Interaction.
*
* @param array $activity The activity-object
* @param int $user_id The id of the local blog-user
*
* @return void
* @param array $activity The Activity object.
*/
public static function update_interaction( $activity ) {
$commentdata = Interactions::update_comment( $activity );
@ -76,20 +89,37 @@ class Update {
$state = $commentdata;
}
/**
* Fires after an Update activity has been handled.
*
* @param array $activity The complete Update activity data.
* @param null $user Always null for Update activities.
* @param int|array $state 1 if comment was updated successfully, error data otherwise.
* @param \WP_Comment|null $reaction The updated comment object if successful, null otherwise.
*/
\do_action( 'activitypub_handled_update', $activity, null, $state, $reaction );
}
/**
* Update an Actor
* Update an Actor.
*
* @param array $activity The activity-object
*
* @return void
* @param array $activity The Activity object.
*/
public static function update_actor( $activity ) {
// update cache
get_remote_metadata_by_actor( $activity['actor'], false );
// Update cache.
$actor = get_remote_metadata_by_actor( $activity['actor'], false );
// @todo maybe also update all interactions
if ( ! $actor || \is_wp_error( $actor ) || ! isset( $actor['id'] ) ) {
return;
}
$follower = Followers::get_follower_by_actor( $actor['id'] );
if ( ! $follower ) {
return;
}
$follower->from_array( $actor );
$follower->upsert();
}
}

Some files were not shown because too many files have changed in this diff Show More