diff --git a/.github/changelog/2906-from-description b/.github/changelog/2906-from-description new file mode 100644 index 0000000000..33fc5473ec --- /dev/null +++ b/.github/changelog/2906-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add `wp activitypub fetch` CLI command for fetching remote URLs with signed HTTP requests. diff --git a/includes/class-cli.php b/includes/class-cli.php index 34a73565b4..09aacd7498 100644 --- a/includes/class-cli.php +++ b/includes/class-cli.php @@ -30,6 +30,7 @@ class Cli { * - wp activitypub self-destruct [--status] [--yes] * - wp activitypub move * - wp activitypub follow + * - wp activitypub fetch */ public static function register() { // Register parent command with version subcommand. @@ -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.', + ) + ); } } diff --git a/includes/cli/class-fetch-command.php b/includes/cli/class-fetch-command.php new file mode 100644 index 0000000000..8125c4bb29 --- /dev/null +++ b/includes/cli/class-fetch-command.php @@ -0,0 +1,221 @@ + + * : The URL to fetch. + * + * [--signature=] + * : 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] ); + } + }; + } +}