Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/2906-from-description
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add `wp activitypub fetch` CLI command for fetching remote URLs with signed HTTP requests.
9 changes: 9 additions & 0 deletions includes/class-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class Cli {
* - wp activitypub self-destruct [--status] [--yes]
* - wp activitypub move <from> <to>
* - wp activitypub follow <remote_user>
* - wp activitypub fetch <url>
*/
public static function register() {
// Register parent command with version subcommand.
Expand Down Expand Up @@ -96,5 +97,13 @@ public static function register() {
'shortdesc' => 'Follow a remote ActivityPub user.',
)
);

\WP_CLI::add_command(
'activitypub fetch',
'\Activitypub\Cli\Fetch_Command',
array(
'shortdesc' => 'Fetch a remote URL with a signed ActivityPub request.',
)
);
}
}
221 changes: 221 additions & 0 deletions includes/cli/class-fetch-command.php
Original file line number Diff line number Diff line change
@@ -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] );
}
};
}
}