updated plugin ActivityPub version 8.3.0

This commit is contained in:
2026-06-03 21:28:46 +00:00
committed by Gitium
parent a4b78ec277
commit 6fe182458a
340 changed files with 43232 additions and 7568 deletions

View File

@ -0,0 +1,77 @@
<?php
/**
* Actor CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use Activitypub\Collection\Actors;
use Activitypub\Scheduler\Actor;
/**
* Manage ActivityPub actors.
*
* @package Activitypub
*/
class Actor_Command extends \WP_CLI_Command {
/**
* Delete an Actor from the Fediverse.
*
* Sends a Delete activity to all followers to remove the actor from
* federated instances.
*
* ## OPTIONS
*
* <id>
* : The ID of the Actor (user ID).
*
* ## EXAMPLES
*
* # Delete actor with user ID 1
* $ wp activitypub actor delete 1
*
* @subcommand delete
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments (unused).
*/
public function delete( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( Actors::APPLICATION_USER_ID === (int) $args[0] ) {
\WP_CLI::error( 'You cannot delete the application actor.' );
}
\add_filter( 'activitypub_user_can_activitypub', '__return_true' );
Actor::schedule_user_delete( $args[0] );
\remove_filter( 'activitypub_user_can_activitypub', '__return_true' );
\WP_CLI::success( '"Delete" activity is queued.' );
}
/**
* Update an Actor on the Fediverse.
*
* Sends an Update activity to all followers to refresh the actor profile
* on federated instances.
*
* ## OPTIONS
*
* <id>
* : The ID of the Actor (user ID).
*
* ## EXAMPLES
*
* # Update actor with user ID 1
* $ wp activitypub actor update 1
*
* @subcommand update
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments (unused).
*/
public function update( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
Actor::schedule_profile_update( $args[0] );
\WP_CLI::success( '"Update" activity is queued.' );
}
}

View File

@ -0,0 +1,274 @@
<?php
/**
* Cache CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use Activitypub\Cache\Avatar;
use Activitypub\Cache\Emoji;
use Activitypub\Cache\File;
use Activitypub\Cache\Media;
/**
* Manage ActivityPub remote media cache.
*
* @package Activitypub
*/
class Cache_Command extends \WP_CLI_Command {
/**
* Clear the remote media cache.
*
* Removes cached files from the uploads directory. By default clears all
* cache types, or specify --type to clear only a specific cache.
*
* ## OPTIONS
*
* [--type=<type>]
* : The cache type to clear. If omitted, clears all caches.
* ---
* options:
* - avatar
* - media
* - emoji
* - all
* default: all
* ---
*
* [--yes]
* : Skip confirmation prompt.
*
* ## EXAMPLES
*
* # Clear all caches
* $ wp activitypub cache clear
*
* # Clear only avatar cache
* $ wp activitypub cache clear --type=avatar
*
* # Clear emoji cache without confirmation
* $ wp activitypub cache clear --type=emoji --yes
*
* @subcommand clear
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments.
*/
public function clear( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$type = $assoc_args['type'] ?? 'all';
$types_to_clear = array();
if ( 'all' === $type ) {
$types_to_clear = array( 'avatar', 'media', 'emoji' );
} else {
$types_to_clear = array( $type );
}
$type_label = 'all' === $type ? 'all cache types' : "{$type} cache";
\WP_CLI::confirm( "Are you sure you want to clear {$type_label}?", $assoc_args );
$total_cleared = 0;
foreach ( $types_to_clear as $cache_type ) {
$cleared = $this->clear_cache_type( $cache_type );
$total_cleared += $cleared;
\WP_CLI::log( \sprintf( 'Cleared %d %s cache directories.', $cleared, $cache_type ) );
}
\WP_CLI::success( \sprintf( 'Cache cleared. Total directories removed: %d', $total_cleared ) );
}
/**
* Show cache status and statistics.
*
* Displays information about cached files including count and total size
* for each cache type.
*
* ## OPTIONS
*
* [--format=<format>]
* : Output format.
* ---
* default: table
* options:
* - table
* - json
* - csv
* - yaml
* ---
*
* ## EXAMPLES
*
* # Show cache status
* $ wp activitypub cache status
*
* # Show status as JSON
* $ wp activitypub cache status --format=json
*
* @subcommand status
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments.
*/
public function status( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$upload_dir = \wp_upload_dir();
$cache_types = array(
'avatar' => array(
'label' => 'Avatars',
'path' => Avatar::get_base_dir(),
),
'media' => array(
'label' => 'Post Media',
'path' => Media::get_base_dir(),
),
'emoji' => array(
'label' => 'Emoji',
'path' => Emoji::get_base_dir(),
),
);
$data = array();
$total_files = 0;
$total_size = 0;
foreach ( $cache_types as $type => $info ) {
$dir = $upload_dir['basedir'] . $info['path'];
$stats = $this->get_directory_stats( $dir );
$data[] = array(
'type' => $info['label'],
'enabled' => $this->is_cache_enabled( $type ) ? 'Yes' : 'No',
'files' => $stats['files'],
'size' => \size_format( $stats['size'] ),
'path' => $info['path'],
);
$total_files += $stats['files'];
$total_size += $stats['size'];
}
// Add totals row for table format.
if ( 'table' === ( $assoc_args['format'] ?? 'table' ) ) {
$data[] = array(
'type' => '---',
'enabled' => '---',
'files' => '---',
'size' => '---',
'path' => '---',
);
$data[] = array(
'type' => 'TOTAL',
'enabled' => '',
'files' => $total_files,
'size' => \size_format( $total_size ),
'path' => '/activitypub/',
);
}
$format = $assoc_args['format'] ?? 'table';
\WP_CLI\Utils\format_items( $format, $data, array( 'type', 'enabled', 'files', 'size', 'path' ) );
}
/**
* Clear a specific cache type.
*
* @param string $type The cache type (avatar, media, emoji).
*
* @return int Number of directories cleared.
*/
private function clear_cache_type( $type ) {
$upload_dir = \wp_upload_dir();
switch ( $type ) {
case 'avatar':
$base_dir = $upload_dir['basedir'] . Avatar::get_base_dir();
break;
case 'media':
$base_dir = $upload_dir['basedir'] . Media::get_base_dir();
break;
case 'emoji':
$base_dir = $upload_dir['basedir'] . Emoji::get_base_dir();
break;
default:
return 0;
}
if ( ! \is_dir( $base_dir ) ) {
return 0;
}
// Count subdirectories before clearing.
$subdirs = \glob( $base_dir . '/*', GLOB_ONLYDIR );
$count = $subdirs ? \count( $subdirs ) : 0;
// Remove all subdirectories using the cache's native delete_directory helper.
foreach ( $subdirs as $subdir ) {
File::delete_directory( $subdir );
}
// Clean up legacy avatar URL meta from previous versions.
if ( 'avatar' === $type ) {
\delete_metadata( 'post', 0, '_activitypub_avatar_url', '', true );
}
return $count;
}
/**
* Get statistics for a cache directory.
*
* @param string $dir The directory path.
*
* @return array {
* Directory statistics.
*
* @type int $files Total number of files.
* @type int $size Total size in bytes.
* }
*/
private function get_directory_stats( $dir ) {
$stats = array(
'files' => 0,
'size' => 0,
);
if ( ! \is_dir( $dir ) ) {
return $stats;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ( $iterator as $file ) {
if ( $file->isFile() ) {
++$stats['files'];
$stats['size'] += $file->getSize();
}
}
return $stats;
}
/**
* Check if a cache type is enabled.
*
* @param string $type The cache type.
*
* @return bool True if enabled.
*/
private function is_cache_enabled( $type ) {
// Check global cache enablement (includes constant and activitypub_remote_cache_enabled filter).
if ( ! \Activitypub\Cache::is_enabled() ) {
return false;
}
// Check type-specific filter.
return (bool) \apply_filters( "activitypub_cache_{$type}_enabled", true );
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Base CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
/**
* Manage ActivityPub plugin functionality and federation.
*
* @package Activitypub
*/
class Command extends \WP_CLI_Command {
/**
* Display the ActivityPub plugin version.
*
* ## EXAMPLES
*
* $ wp activitypub version
* ActivityPub 7.9.0
*
* @subcommand version
*
* @param array $args The positional arguments (unused).
* @param array $assoc_args The associative arguments (unused).
*/
public function version( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
\WP_CLI::line( 'ActivityPub ' . ACTIVITYPUB_PLUGIN_VERSION );
}
}

View File

@ -0,0 +1,95 @@
<?php
/**
* Comment CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use function Activitypub\add_to_outbox;
use function Activitypub\was_comment_received;
/**
* Manage ActivityPub comments.
*
* @package Activitypub
*/
class Comment_Command extends \WP_CLI_Command {
/**
* Delete a Comment from the Fediverse.
*
* Sends a Delete activity to all followers to remove the comment from
* federated instances.
*
* ## OPTIONS
*
* <id>
* : The ID of the Comment.
*
* [--yes]
* : Skip the confirmation prompt.
*
* ## EXAMPLES
*
* # Delete comment with ID 123
* $ wp activitypub comment delete 123
*
* @subcommand delete
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments.
*/
public function delete( $args, $assoc_args ) {
$comment = \get_comment( $args[0] );
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.' );
}
\WP_CLI::confirm( 'Do you really want to delete the Comment with the ID: ' . $args[0], $assoc_args );
add_to_outbox( $comment, 'Delete', $comment->user_id );
\WP_CLI::success( '"Delete" activity is queued.' );
}
/**
* Update a Comment on the Fediverse.
*
* Sends an Update activity to all followers to refresh the comment content
* on federated instances.
*
* ## OPTIONS
*
* <id>
* : The ID of the Comment.
*
* ## EXAMPLES
*
* # Update comment with ID 123
* $ wp activitypub comment update 123
*
* @subcommand update
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments (unused).
*/
public function update( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$comment = \get_comment( $args[0] );
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.' );
}
add_to_outbox( $comment, 'Update', $comment->user_id );
\WP_CLI::success( '"Update" activity is queued.' );
}
}

View File

@ -0,0 +1,221 @@
<?php
/**
* Fetch CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use Activitypub\Http;
use Activitypub\Signature;
use Activitypub\Signature\Http_Message_Signature;
/**
* Fetch a remote ActivityPub URL with signed HTTP requests.
*
* Useful for debugging HTTP Signatures and federation issues.
* Signs requests as the application actor by default.
*
* @package Activitypub
*/
class Fetch_Command extends \WP_CLI_Command {
/**
* Fetch a remote ActivityPub URL with a signed HTTP request.
*
* Signs the request as the application actor and displays the response.
* Supports switching between signature modes for debugging.
*
* ## OPTIONS
*
* <url>
* : The URL to fetch.
*
* [--signature=<mode>]
* : Signature mode: default (plugin-configured), draft-cavage, rfc9421, double-knock, or none.
* ---
* default: default
* options:
* - default
* - draft-cavage
* - rfc9421
* - double-knock
* - none
* ---
*
* [--raw]
* : Output the raw response body without formatting.
*
* [--include-headers]
* : Show response headers alongside the body.
*
* ## EXAMPLES
*
* # Fetch an actor profile with default signature
* $ wp activitypub fetch https://mastodon.social/@Gargron
*
* # Fetch with RFC 9421 signature
* $ wp activitypub fetch https://mastodon.social/@Gargron --signature=rfc9421
*
* # Fetch with Draft Cavage signature
* $ wp activitypub fetch https://mastodon.social/@Gargron --signature=draft-cavage
*
* # Fetch with double-knock (RFC 9421 first, Draft Cavage fallback on 4xx)
* $ wp activitypub fetch https://mastodon.social/@Gargron --signature=double-knock
*
* # Fetch without signature
* $ wp activitypub fetch https://mastodon.social/@Gargron --signature=none
*
* # Show response headers
* $ wp activitypub fetch https://mastodon.social/@Gargron --include-headers
*
* # Output raw response body
* $ wp activitypub fetch https://mastodon.social/@Gargron --raw
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments.
*/
public function __invoke( $args, $assoc_args ) {
$url = $args[0];
$signature_mode = \WP_CLI\Utils\get_flag_value( $assoc_args, 'signature', 'default' );
$raw = \WP_CLI\Utils\get_flag_value( $assoc_args, 'raw', false );
$include_headers = \WP_CLI\Utils\get_flag_value( $assoc_args, 'include-headers', false );
\WP_CLI::log( \sprintf( 'Fetching: %s', $url ) );
\WP_CLI::log( \sprintf( 'Signature mode: %s', $signature_mode ) );
$get_args = array();
$cleanup = $this->apply_signature_mode( $signature_mode, $get_args );
$response = Http::get( $url, $get_args, false );
$cleanup();
if ( \is_wp_error( $response ) ) {
\WP_CLI::error( \sprintf( 'Request failed: %s (Error code: %s).', $response->get_error_message(), $response->get_error_code() ) );
}
$code = \wp_remote_retrieve_response_code( $response );
\WP_CLI::log( \sprintf( 'Response code: %d', $code ) );
\WP_CLI::log( '' );
// Show response headers if requested.
if ( $include_headers ) {
$headers = \wp_remote_retrieve_headers( $response );
\WP_CLI::log( '--- Response Headers ---' );
foreach ( $headers as $name => $value ) {
\WP_CLI::log( \sprintf( '%s: %s', $name, $value ) );
}
\WP_CLI::log( '' );
}
$body = \wp_remote_retrieve_body( $response );
// Output the body.
if ( $raw ) {
\WP_CLI::log( $body );
} else {
$data = \json_decode( $body, true );
if ( \JSON_ERROR_NONE === \json_last_error() ) {
\WP_CLI::log( \wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) );
} else {
\WP_CLI::log( $body );
}
}
}
/**
* Apply signature mode overrides via filters.
*
* For rfc9421, replaces the default sign_request and disables double-knock
* to avoid an infinite retry loop when the server returns 4xx.
*
* @param string $mode The signature mode.
* @param array $args The request arguments, passed by reference.
*
* @return callable Cleanup callback to restore original filters.
*/
private function apply_signature_mode( $mode, &$args ) {
$filters = array();
$restore = array();
switch ( $mode ) {
case 'default':
break;
case 'none':
$args['key_id'] = null;
$args['private_key'] = null;
break;
case 'rfc9421':
case 'double-knock':
// Replace default signing to force RFC 9421. For rfc9421 mode,
// also disable double-knock to prevent an infinite retry loop.
// For double-knock mode, keep it active but skip re-signing on retry.
$removed_sign_request = \remove_filter( 'http_request_args', array( Signature::class, 'sign_request' ), 0 );
$is_double_knock = 'double-knock' === $mode;
$removed_double_knock = false;
if ( ! $is_double_knock ) {
$removed_double_knock = \remove_filter( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10 );
}
$forced_signer = function ( $request_args, $url ) use ( $is_double_knock ) {
if ( ! isset( $request_args['key_id'], $request_args['private_key'] ) ) {
return $request_args;
}
// In double-knock mode, skip if already signed (retry from maybe_double_knock).
if ( $is_double_knock && ! empty( $request_args['headers']['Signature'] ) ) {
return $request_args;
}
return ( new Http_Message_Signature() )->sign( $request_args, $url );
};
\add_filter( 'http_request_args', $forced_signer, 0, 2 );
$filters[] = array( 'http_request_args', $forced_signer, 0 );
if ( $removed_sign_request ) {
$restore[] = array( 'http_request_args', array( Signature::class, 'sign_request' ), 0, 2 );
}
if ( $removed_double_knock ) {
$restore[] = array( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10, 3 );
}
break;
case 'draft-cavage':
$force_cavage = function () {
return '0';
};
\add_filter( 'pre_option_activitypub_rfc9421_signature', $force_cavage );
$filters[] = array( 'pre_option_activitypub_rfc9421_signature', $force_cavage );
break;
default:
\WP_CLI::error(
\sprintf(
'Invalid signature mode "%s". Allowed modes: default, draft-cavage, rfc9421, double-knock, none.',
$mode
)
);
}
return function () use ( $filters, $restore ) {
foreach ( $filters as $filter ) {
\remove_filter( $filter[0], $filter[1], $filter[2] ?? 10 );
}
foreach ( $restore as $filter ) {
\add_filter( $filter[0], $filter[1], $filter[2], $filter[3] );
}
};
}
}

View File

@ -0,0 +1,51 @@
<?php
/**
* Follow CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use function Activitypub\follow;
/**
* Follow a remote ActivityPub user.
*
* @package Activitypub
*/
class Follow_Command extends \WP_CLI_Command {
/**
* Follow a remote user.
*
* Sends a Follow activity to subscribe to a remote ActivityPub user.
* Use --user flag to specify which local user should follow.
*
* ## OPTIONS
*
* <remote_user>
* : The remote user to follow (URL or @user@domain format).
*
* ## EXAMPLES
*
* # Follow a remote user
* $ wp activitypub follow https://example.com/@user
*
* # Follow as a specific local user
* $ wp --user=pfefferle activitypub follow https://example.com/@user
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments (unused).
*/
public function __invoke( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$user_id = \get_current_user_id();
$follow_result = follow( $args[0], $user_id );
if ( \is_wp_error( $follow_result ) ) {
\WP_CLI::error( $follow_result->get_error_message() );
} else {
\WP_CLI::success( 'Follow Scheduled.' );
}
}
}

View File

@ -0,0 +1,54 @@
<?php
/**
* Move CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use Activitypub\Move;
/**
* Move an ActivityPub account to a new URL.
*
* @package Activitypub
*/
class Move_Command extends \WP_CLI_Command {
/**
* Move the blog to a new URL.
*
* Sends a Move activity to notify followers that your blog has moved
* to a new location. Followers on compatible instances will automatically
* update their subscription.
*
* ## OPTIONS
*
* <from>
* : The current URL of the blog.
*
* <to>
* : The new URL of the blog.
*
* ## EXAMPLES
*
* # Move blog from old URL to new URL
* $ wp activitypub move https://example.com/ https://newsite.com/
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments (unused).
*/
public function __invoke( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$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

@ -0,0 +1,100 @@
<?php
/**
* Outbox CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use Activitypub\Collection\Outbox;
/**
* Manage ActivityPub outbox items.
*
* @package Activitypub
*/
class Outbox_Command extends \WP_CLI_Command {
/**
* Undo an activity that was sent to the Fediverse.
*
* Creates an Undo activity for a previously sent activity, effectively
* reversing its effect on federated instances.
*
* ## OPTIONS
*
* <id>
* : The ID or URL of the outbox item to undo.
*
* ## EXAMPLES
*
* # Undo outbox item by ID
* $ wp activitypub outbox undo 123
*
* # Undo outbox item by URL
* $ wp activitypub outbox undo "https://example.com/?post_type=ap_outbox&p=123"
*
* @subcommand undo
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments (unused).
*/
public function undo( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$outbox_item_id = $args[0];
if ( ! is_numeric( $outbox_item_id ) ) {
$outbox_item_id = \url_to_postid( $outbox_item_id );
}
$outbox_item = \get_post( $outbox_item_id );
if ( ! $outbox_item ) {
\WP_CLI::error( 'Activity not found.' );
}
$undo_id = Outbox::undo( $outbox_item );
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.
*
* Useful for retrying failed deliveries or resending activities to
* followers.
*
* ## OPTIONS
*
* <id>
* : The ID or URL of the outbox item to reschedule.
*
* ## EXAMPLES
*
* # Reschedule outbox item by ID
* $ wp activitypub outbox reschedule 123
*
* # Reschedule outbox item by URL
* $ wp activitypub outbox reschedule "https://example.com/?post_type=ap_outbox&p=123"
*
* @subcommand reschedule
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments (unused).
*/
public function reschedule( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$outbox_item_id = $args[0];
if ( ! is_numeric( $outbox_item_id ) ) {
$outbox_item_id = \url_to_postid( $outbox_item_id );
}
$outbox_item = \get_post( $outbox_item_id );
if ( ! $outbox_item ) {
\WP_CLI::error( 'Activity not found.' );
}
Outbox::reschedule( $outbox_item );
\WP_CLI::success( 'Rescheduled activity.' );
}
}

View File

@ -0,0 +1,86 @@
<?php
/**
* Post CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use function Activitypub\add_to_outbox;
/**
* Manage ActivityPub posts.
*
* @package Activitypub
*/
class Post_Command extends \WP_CLI_Command {
/**
* Delete a Post from the Fediverse.
*
* Sends a Delete activity to all followers to remove the post from
* federated instances.
*
* ## OPTIONS
*
* <id>
* : The ID of the Post, Page, Custom Post Type or Attachment.
*
* [--yes]
* : Skip the confirmation prompt.
*
* ## EXAMPLES
*
* # Delete post with ID 123
* $ wp activitypub post delete 123
*
* @subcommand delete
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments.
*/
public function delete( $args, $assoc_args ) {
$post = \get_post( $args[0] );
if ( ! $post ) {
\WP_CLI::error( 'Post not found.' );
}
\WP_CLI::confirm( 'Do you really want to delete the (Custom) Post with the ID: ' . $args[0], $assoc_args );
add_to_outbox( $post, 'Delete', $post->post_author );
\WP_CLI::success( '"Delete" activity is queued.' );
}
/**
* Update a Post on the Fediverse.
*
* Sends an Update activity to all followers to refresh the post content
* on federated instances.
*
* ## OPTIONS
*
* <id>
* : The ID of the Post, Page, Custom Post Type or Attachment.
*
* ## EXAMPLES
*
* # Update post with ID 123
* $ wp activitypub post update 123
*
* @subcommand update
*
* @param array $args The positional arguments.
* @param array $assoc_args The associative arguments (unused).
*/
public function update( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$post = \get_post( $args[0] );
if ( ! $post ) {
\WP_CLI::error( 'Post not found.' );
}
add_to_outbox( $post, 'Update', $post->post_author );
\WP_CLI::success( '"Update" activity is queued.' );
}
}

View File

@ -0,0 +1,302 @@
<?php
/**
* Self-Destruct CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Remote_Posts;
use function Activitypub\add_to_outbox;
/**
* Remove the blog from the Fediverse.
*
* @package Activitypub
*/
class Self_Destruct_Command extends \WP_CLI_Command {
/**
* Remove the entire blog from the Fediverse.
*
* This command permanently removes your blog from ActivityPub networks by sending
* Delete activities to all followers. This action is IRREVERSIBLE.
*
* ## OPTIONS
*
* [--status]
* : Check the status of the self-destruct process instead of running it.
* Use this to monitor progress after initiating the deletion process.
*
* [--yes]
* : Skip the confirmation prompt and proceed with deletion immediately.
* Use with extreme caution as this bypasses all safety checks.
*
* ## EXAMPLES
*
* # Start the self-destruct process (with confirmation prompt)
* $ wp activitypub self-destruct
*
* # Check the status of an ongoing self-destruct process
* $ wp activitypub self-destruct --status
*
* # Force deletion without confirmation (dangerous!)
* $ wp activitypub self-destruct --yes
*
* ## WHAT THIS DOES
*
* - Finds all users with ActivityPub capabilities
* - Creates Delete activities for each user
* - Sends these activities to all followers
* - Removes your blog from ActivityPub discovery
* - Sets a flag to track completion status
*
* ## IMPORTANT NOTES
*
* - This action cannot be undone
* - Keep the ActivityPub plugin active during the process
* - The process may take several minutes to complete
* - You will be notified when the process finishes
*
* @param array $args The positional arguments (unused).
* @param array $assoc_args The associative arguments (--status, --yes).
*/
public function __invoke( $args, $assoc_args = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
// Check if --status flag is provided.
if ( isset( $assoc_args['status'] ) ) {
$this->show_self_destruct_status();
return;
}
// Check if self-destruct has already been run.
if ( \get_option( 'activitypub_self_destruct' ) ) {
\WP_CLI::error( 'Self-destruct has already been initiated. The process may still be running or has completed.' . PHP_EOL . \WP_CLI::colorize( 'To check the status, run: %Bwp activitypub self-destruct --status%n' ) );
return;
}
$this->execute_self_destruct( $assoc_args );
}
/**
* Execute the self-destruct process.
*
* This method handles the actual deletion process:
* 1. Displays warning and confirmation prompt
* 2. Retrieves all ActivityPub-capable users
* 3. Creates and schedules Delete activities for each user
* 4. Sets the self-destruct flag for status tracking
* 5. Provides progress feedback and completion instructions
*
* @param array $assoc_args The associative arguments from WP-CLI.
*/
private function execute_self_destruct( $assoc_args ) {
$this->display_self_destruct_warning();
\WP_CLI::confirm( 'Are you absolutely sure you want to continue?', $assoc_args );
$user_ids = $this->get_activitypub_users();
if ( empty( $user_ids ) ) {
\WP_CLI::warning( 'No ActivityPub users found. Nothing to delete.' );
return;
}
$processed = $this->process_user_deletions( $user_ids );
// Delete all remote posts.
$deleted_posts = Remote_Posts::delete_all();
if ( $deleted_posts > 0 ) {
\WP_CLI::line( \WP_CLI::colorize( "%G✓%n Deleted {$deleted_posts} remote post(s)." ) );
}
$this->display_completion_message( $processed );
}
/**
* Display the self-destruct warning message.
*/
private function display_self_destruct_warning() {
\WP_CLI::line( \WP_CLI::colorize( '%R⚠ DESTRUCTIVE OPERATION ⚠️%n' ) );
\WP_CLI::line( '' );
$question = 'You are about to delete your blog from the Fediverse. This action is IRREVERSIBLE and will:';
\WP_CLI::line( \WP_CLI::colorize( "%y{$question}%n" ) );
\WP_CLI::line( \WP_CLI::colorize( '%y• Send Delete activities to all followers%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%y• Remove your blog from ActivityPub networks%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%y• Delete all cached remote posts%n' ) );
\WP_CLI::line( '' );
}
/**
* Get all users with ActivityPub capabilities.
*
* @return array Array of user IDs with ActivityPub capabilities.
*/
private function get_activitypub_users() {
return \get_users(
array(
'fields' => 'ID',
'capability__in' => array( 'activitypub' ),
)
);
}
/**
* Process user deletions and create Delete activities.
*
* @param array $user_ids Array of user IDs to process.
*
* @return int Number of users successfully processed.
*/
private function process_user_deletions( $user_ids ) {
$user_count = \count( $user_ids );
\WP_CLI::line( \WP_CLI::colorize( '%GStarting Fediverse deletion process...%n' ) );
\WP_CLI::line( \WP_CLI::colorize( "%BFound {$user_count} ActivityPub user(s) to process:%n" ) );
\WP_CLI::line( '' );
// Set the self-destruct flag.
\update_option( 'activitypub_self_destruct', true );
$processed = 0;
foreach ( $user_ids as $user_id ) {
if ( $this->create_delete_activity_for_user( $user_id, $processed, $user_count ) ) {
++$processed;
}
}
\WP_CLI::line( '' );
if ( 0 === $processed ) {
\WP_CLI::error( 'Failed to schedule any deletions. Please check your configuration.' );
}
return $processed;
}
/**
* Create a Delete activity for a specific user.
*
* @param int $user_id The user ID to process.
* @param int $processed Number of users already processed.
* @param int $user_count Total number of users to process.
*
* @return bool True if the activity was created successfully, false otherwise.
*/
private function create_delete_activity_for_user( $user_id, $processed, $user_count ) {
$actor = Actors::get_by_id( $user_id );
if ( ! $actor ) {
\WP_CLI::line( \WP_CLI::colorize( "%R✗ Failed to load user ID: {$user_id}%n" ) );
return false;
}
$activity = new Activity();
$activity->set_actor( $actor->get_id() );
$activity->set_object( $actor->get_id() );
$activity->set_type( 'Delete' );
$result = add_to_outbox( $activity, null, $user_id );
if ( \is_wp_error( $result ) ) {
\WP_CLI::line( \WP_CLI::colorize( "%R✗ Failed to schedule deletion for: %B{$actor->get_name()}%n - {$result->get_error_message()}" ) );
return false;
}
$current = $processed + 1;
\WP_CLI::line( \WP_CLI::colorize( "%G✓%n [{$current}/{$user_count}] Scheduled deletion for: %B{$actor->get_name()}%n" ) );
return true;
}
/**
* Display the completion message after processing.
*
* @param int $processed Number of users successfully processed.
*/
private function display_completion_message( $processed ) {
if ( 0 === $processed ) {
return; // Error already displayed in process_user_deletions.
}
\WP_CLI::success( "Successfully scheduled {$processed} user(s) for Fediverse deletion." );
\WP_CLI::line( '' );
\WP_CLI::line( \WP_CLI::colorize( '%Y📋 Next Steps:%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%Y• Keep the ActivityPub plugin active%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%Y• Delete activities will be sent automatically%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%Y• Process may take several minutes to complete%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%Y• The plugin will notify you when the process is done.%n' ) );
\WP_CLI::line( '' );
}
/**
* Show the status of the self-destruct process.
*
* Checks the current state of the self-destruct process by:
* - Verifying if the process has been initiated
* - Counting remaining pending Delete activities
* - Displaying appropriate status messages and progress
* - Providing guidance on next steps
*
* Status can be:
* - NOT STARTED: Process hasn't been initiated
* - IN PROGRESS: Delete activities are still being processed
* - COMPLETED: All Delete activities have been sent
*/
private function show_self_destruct_status() {
// Only proceed if self-destruct is active.
if ( ! \get_option( 'activitypub_self_destruct', false ) ) {
\WP_CLI::line( \WP_CLI::colorize( '%C❌ Status: NOT STARTED%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%CThe self-destruct process has not been initiated.%n' ) );
\WP_CLI::line( '' );
\WP_CLI::line( \WP_CLI::colorize( '%CTo start the process, run:%n %Bwp activitypub self-destruct%n' ) );
\WP_CLI::line( '' );
return;
}
\WP_CLI::line( \WP_CLI::colorize( '%B🔍 Self-Destruct Status Check%n' ) );
\WP_CLI::line( '' );
// Check if there are any more pending Delete activities for self-destruct.
$pending_deletes = \get_posts(
array(
'post_type' => Outbox::POST_TYPE,
'post_status' => 'pending',
'posts_per_page' => -1,
'fields' => 'ids',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_activitypub_activity_type',
'value' => 'Delete',
),
),
)
);
// Get count of pending Delete activities.
$pending_count = count( $pending_deletes );
// If no more pending Delete activities, self-destruct is complete.
if ( 0 === $pending_count ) {
\WP_CLI::line( \WP_CLI::colorize( '%G✅ Status: COMPLETED%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%GYour blog has been successfully removed from the Fediverse.%n' ) );
\WP_CLI::line( '' );
\WP_CLI::line( \WP_CLI::colorize( '%Y📋 What happened:%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%Y• Delete activities were sent to all followers%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%Y• Your blog is no longer discoverable on ActivityPub networks%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%Y• The self-destruct process has finished%n' ) );
} else {
\WP_CLI::line( \WP_CLI::colorize( '%Y⏳ Status: IN PROGRESS%n' ) );
\WP_CLI::line( \WP_CLI::colorize( '%YThe self-destruct process is currently running.%n' ) );
\WP_CLI::line( '' );
\WP_CLI::line( \WP_CLI::colorize( "%YProgress: {$pending_count} Delete Activities still pending%n" ) );
\WP_CLI::line( '' );
\WP_CLI::line( \WP_CLI::colorize( '%YNote: The process may take several minutes to complete.%n' ) );
}
\WP_CLI::line( '' );
}
}

View File

@ -0,0 +1,230 @@
<?php
/**
* Stats CLI Command.
*
* @package Activitypub
*/
namespace Activitypub\Cli;
use Activitypub\Scheduler\Statistics as Statistics_Scheduler;
use Activitypub\Statistics;
/**
* Manage ActivityPub statistics.
*
* @package Activitypub
*/
class Stats_Command extends \WP_CLI_Command {
/**
* Collect monthly statistics.
*
* Gathers statistics for a given month including post counts, follower
* changes, engagement metrics, and top content.
*
* ## OPTIONS
*
* [--user_id=<user_id>]
* : The user ID to collect stats for. Omit to collect for all active users.
*
* [--year=<year>]
* : The year to collect stats for. Defaults to current year.
*
* [--month=<month>]
* : The month to collect stats for (1-12). Defaults to current month.
* When --year is provided without --month, all months of that year
* are collected (up to the current month for the current year).
*
* [--force]
* : Force recollection even if stats already exist.
*
* ## EXAMPLES
*
* # Collect real stats for current month
* $ wp activitypub stats collect
*
* # Collect stats for a specific month
* $ wp activitypub stats collect --year=2024 --month=6
*
* # Collect all months of a year
* $ wp activitypub stats collect --year=2024
*
* # Force recollect stats for a specific user
* $ wp activitypub stats collect --user_id=1 --force
*
* @subcommand collect
*
* @param array $args The positional arguments (unused).
* @param array $assoc_args The associative arguments.
*/
public function collect( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null;
$year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : (int) \gmdate( 'Y' );
$has_month = isset( $assoc_args['month'] );
$force = isset( $assoc_args['force'] );
$current_year = (int) \gmdate( 'Y' );
$current_month = (int) \gmdate( 'n' );
if ( $year < 2000 || $year > $current_year + 1 ) {
\WP_CLI::error( "Invalid year: {$year}." );
}
/*
* When --month is provided, collect that single month.
* When only --year is provided, collect all months of the year
* (up to the current month for the current year).
*/
if ( $has_month ) {
$months = array( (int) $assoc_args['month'] );
if ( $months[0] < 1 || $months[0] > 12 ) {
\WP_CLI::error( "Invalid month: {$months[0]}. Must be between 1 and 12." );
}
} elseif ( isset( $assoc_args['year'] ) ) {
$last_month = ( $year === $current_year ) ? $current_month : 12;
$months = \range( 1, $last_month );
} else {
$months = array( $current_month );
}
$user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids();
foreach ( $months as $month ) {
foreach ( $user_ids as $uid ) {
if ( $force ) {
$option_name = Statistics::get_monthly_option_name( $uid, $year, $month );
\delete_option( $option_name );
}
Statistics::collect_monthly_stats( $uid, $year, $month );
}
$count = \count( $user_ids );
\WP_CLI::log( "Collected {$year}-{$month} for {$count} user(s)." );
}
$total_months = \count( $months );
\WP_CLI::success( "Monthly stats collected for {$total_months} month(s)." );
}
/**
* Compile annual statistics.
*
* Aggregates monthly statistics into an annual summary including totals,
* averages, and highlights for the year.
*
* ## OPTIONS
*
* [--user_id=<user_id>]
* : The user ID to compile stats for. Omit to compile for all active users.
*
* [--year=<year>]
* : The year to compile stats for. Defaults to previous year.
*
* ## EXAMPLES
*
* # Compile annual stats for previous year
* $ wp activitypub stats compile
*
* # Compile annual stats for a specific year
* $ wp activitypub stats compile --year=2024
*
* # Compile for a specific user
* $ wp activitypub stats compile --user_id=1 --year=2024
*
* @subcommand compile
*
* @param array $args The positional arguments (unused).
* @param array $assoc_args The associative arguments.
*/
public function compile( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null;
$year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : ( (int) \gmdate( 'Y' ) - 1 );
$user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids();
foreach ( $user_ids as $uid ) {
Statistics::compile_annual_summary( $uid, $year );
}
$count = count( $user_ids );
\WP_CLI::success( "Annual stats compiled for {$count} user(s) ({$year})." );
}
/**
* Send the stats report email.
*
* Without --month, sends the annual Fediverse Year in Review.
* With --month, sends the monthly stats report for that month.
*
* ## OPTIONS
*
* [--user_id=<user_id>]
* : The user ID to send the email for. Omit to send for all active users.
*
* [--year=<year>]
* : The year. Defaults to previous year (annual) or current year (monthly).
*
* [--month=<month>]
* : The month (1-12). If provided, sends a monthly report instead of annual.
*
* ## EXAMPLES
*
* # Send annual report for previous year
* $ wp activitypub stats send
*
* # Send annual report for a specific year
* $ wp activitypub stats send --year=2025
*
* # Send monthly report for a specific month
* $ wp activitypub stats send --year=2025 --month=6
*
* # Send monthly report for a specific user
* $ wp activitypub stats send --user_id=1 --year=2025 --month=6
*
* @subcommand send
*
* @param array $args The positional arguments (unused).
* @param array $assoc_args The associative arguments.
*/
public function send( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null;
$is_monthly = isset( $assoc_args['month'] );
if ( $is_monthly ) {
$month = (int) $assoc_args['month'];
$year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : (int) \gmdate( 'Y' );
if ( $month < 1 || $month > 12 ) {
\WP_CLI::error( "Invalid month: {$month}. Must be between 1 and 12." );
}
} else {
$year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : ( (int) \gmdate( 'Y' ) - 1 );
}
$user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids();
$sent = 0;
foreach ( $user_ids as $uid ) {
if ( $is_monthly ) {
Statistics_Scheduler::send_monthly_email( $uid, $year, $month, true );
\WP_CLI::log( "Monthly report email sent for user {$uid} ({$year}-{$month})." );
} else {
$summary = Statistics::compile_annual_summary( $uid, $year );
if ( empty( $summary ) ) {
\WP_CLI::warning( "No stats found for user {$uid} ({$year}), skipping." );
continue;
}
Statistics_Scheduler::send_annual_email( $uid, $year, $summary, true );
\WP_CLI::log( "Annual report email sent for user {$uid} ({$year})." );
}
++$sent;
}
$type = $is_monthly ? 'Monthly' : 'Annual';
$period = $is_monthly ? "{$year}-{$month}" : "{$year}";
\WP_CLI::success( "{$type} report email sent for {$sent} user(s) ({$period})." );
}
}