From ca0b57e41081a8aaae9c5df65cbce90fcc37995d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 00:06:16 +0100 Subject: [PATCH 001/105] Add Client-to-Server (C2S) ActivityPub API support Implements the SWICG ActivityPub API specification for C2S interactions: - OAuth 2.0 with PKCE authentication - POST to outbox for creating activities - GET inbox for reading received activities - Actor discovery with OAuth endpoints - Handlers for Create, Update, Delete, Follow, Undo activities New files: - includes/oauth/ - OAuth server, tokens, clients, auth codes, scopes - includes/rest/class-oauth-controller.php - OAuth endpoints Modified: - Outbox controller extended with POST support - Inbox controller extended with GET support - Handler classes extended with outbox handlers - Actor models include OAuth endpoints when C2S enabled - New activitypub_enable_c2s setting --- activitypub.php | 4 + includes/class-options.php | 10 + includes/class-post-types.php | 272 ++++++ includes/handler/class-create.php | 67 ++ includes/handler/class-delete.php | 51 ++ includes/handler/class-follow.php | 50 ++ includes/handler/class-undo.php | 56 ++ includes/handler/class-update.php | 77 ++ includes/model/class-blog.php | 10 +- includes/model/class-user.php | 10 +- includes/oauth/class-authorization-code.php | 296 +++++++ includes/oauth/class-client.php | 345 ++++++++ includes/oauth/class-scope.php | 187 +++++ includes/oauth/class-server.php | 255 ++++++ includes/oauth/class-token.php | 386 +++++++++ includes/rest/class-inbox-controller.php | 357 ++++++-- includes/rest/class-oauth-controller.php | 792 ++++++++++++++++++ includes/rest/class-outbox-controller.php | 207 +++++ .../class-advanced-settings-fields.php | 40 + 19 files changed, 3406 insertions(+), 66 deletions(-) create mode 100644 includes/oauth/class-authorization-code.php create mode 100644 includes/oauth/class-client.php create mode 100644 includes/oauth/class-scope.php create mode 100644 includes/oauth/class-server.php create mode 100644 includes/oauth/class-token.php create mode 100644 includes/rest/class-oauth-controller.php diff --git a/activitypub.php b/activitypub.php index 8b448f25e7..91c129c596 100644 --- a/activitypub.php +++ b/activitypub.php @@ -69,6 +69,10 @@ function rest_init() { if ( is_blog_public() ) { ( new Rest\Nodeinfo_Controller() )->register_routes(); } + + // Load OAuth endpoints if C2S is enabled. + OAuth\Server::init(); + ( new Rest\OAuth_Controller() )->register_routes(); } \add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' ); diff --git a/includes/class-options.php b/includes/class-options.php index 54b04da7c1..42f3714b03 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -330,6 +330,16 @@ public static function register_settings() { ) ); + \register_setting( + 'activitypub_advanced', + 'activitypub_enable_c2s', + array( + 'type' => 'boolean', + 'description' => 'Enable Client-to-Server (C2S) support for third-party ActivityPub clients.', + 'default' => false, + ) + ); + /* * Options Group: activitypub_blog */ diff --git a/includes/class-post-types.php b/includes/class-post-types.php index 64cb6b3c3a..52c375f9ab 100644 --- a/includes/class-post-types.php +++ b/includes/class-post-types.php @@ -15,6 +15,10 @@ use Activitypub\Collection\Outbox; use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Actors; +use Activitypub\OAuth\Authorization_Code; +use Activitypub\OAuth\Client; +use Activitypub\OAuth\Scope; +use Activitypub\OAuth\Token; /** * Post Types class. @@ -30,6 +34,7 @@ public static function init() { \add_action( 'init', array( self::class, 'register_post_post_type' ), 11 ); \add_action( 'init', array( self::class, 'register_extra_fields_post_types' ), 11 ); \add_action( 'init', array( self::class, 'register_activitypub_post_meta' ), 11 ); + \add_action( 'init', array( self::class, 'register_oauth_post_types' ), 11 ); \add_action( 'rest_api_init', array( self::class, 'register_ap_actor_rest_field' ) ); \add_action( 'rest_api_init', array( self::class, 'register_ap_post_actor_rest_field' ) ); @@ -456,6 +461,273 @@ public static function register_extra_fields_post_types() { \do_action( 'activitypub_after_register_post_type' ); } + /** + * Register OAuth 2.0 post types for C2S support. + * + * Registers post types for OAuth tokens, clients, and authorization codes. + */ + public static function register_oauth_post_types() { + // OAuth Tokens post type. + \register_post_type( + Token::POST_TYPE, + array( + 'labels' => array( + 'name' => \_x( 'OAuth Tokens', 'post_type plural name', 'activitypub' ), + 'singular_name' => \_x( 'OAuth Token', 'post_type single name', 'activitypub' ), + ), + 'public' => false, + 'show_in_rest' => false, + 'hierarchical' => false, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => true, + 'can_export' => false, + 'supports' => array( 'author', 'custom-fields' ), + 'exclude_from_search' => true, + ) + ); + + // OAuth Token meta. + \register_post_meta( + Token::POST_TYPE, + '_activitypub_access_token_hash', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'SHA-256 hash of the access token.', + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + + \register_post_meta( + Token::POST_TYPE, + '_activitypub_refresh_token_hash', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'SHA-256 hash of the refresh token.', + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + + \register_post_meta( + Token::POST_TYPE, + '_activitypub_client_id', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'The OAuth client ID associated with this token.', + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + + \register_post_meta( + Token::POST_TYPE, + '_activitypub_scopes', + array( + 'type' => 'array', + 'single' => true, + 'description' => 'Granted OAuth scopes.', + 'sanitize_callback' => array( Scope::class, 'sanitize' ), + ) + ); + + \register_post_meta( + Token::POST_TYPE, + '_activitypub_expires_at', + array( + 'type' => 'integer', + 'single' => true, + 'description' => 'Unix timestamp when the access token expires.', + 'sanitize_callback' => 'absint', + ) + ); + + // OAuth Clients post type. + \register_post_type( + Client::POST_TYPE, + array( + 'labels' => array( + 'name' => \_x( 'OAuth Clients', 'post_type plural name', 'activitypub' ), + 'singular_name' => \_x( 'OAuth Client', 'post_type single name', 'activitypub' ), + ), + 'public' => false, + 'show_in_rest' => false, + 'hierarchical' => false, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => false, + 'can_export' => true, + 'supports' => array( 'title', 'editor', 'custom-fields' ), + 'exclude_from_search' => true, + ) + ); + + // OAuth Client meta. + \register_post_meta( + Client::POST_TYPE, + '_activitypub_client_id', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'Unique OAuth client identifier (UUID).', + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + + \register_post_meta( + Client::POST_TYPE, + '_activitypub_client_secret_hash', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'SHA-256 hash of the client secret (null for public clients).', + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + + \register_post_meta( + Client::POST_TYPE, + '_activitypub_redirect_uris', + array( + 'type' => 'array', + 'single' => true, + 'description' => 'Allowed redirect URIs for this client.', + 'sanitize_callback' => static function ( $value ) { + if ( ! is_array( $value ) ) { + return array(); + } + return array_map( 'sanitize_url', $value ); + }, + ) + ); + + \register_post_meta( + Client::POST_TYPE, + '_activitypub_allowed_scopes', + array( + 'type' => 'array', + 'single' => true, + 'description' => 'Allowed OAuth scopes for this client.', + 'sanitize_callback' => array( Scope::class, 'sanitize' ), + ) + ); + + \register_post_meta( + Client::POST_TYPE, + '_activitypub_is_public', + array( + 'type' => 'boolean', + 'single' => true, + 'description' => 'Whether this is a public client (PKCE-only, no secret).', + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => true, + ) + ); + + // OAuth Authorization Codes post type. + \register_post_type( + Authorization_Code::POST_TYPE, + array( + 'labels' => array( + 'name' => \_x( 'OAuth Codes', 'post_type plural name', 'activitypub' ), + 'singular_name' => \_x( 'OAuth Code', 'post_type single name', 'activitypub' ), + ), + 'public' => false, + 'show_in_rest' => false, + 'hierarchical' => false, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => true, + 'can_export' => false, + 'supports' => array( 'author', 'custom-fields' ), + 'exclude_from_search' => true, + ) + ); + + // OAuth Authorization Code meta. + \register_post_meta( + Authorization_Code::POST_TYPE, + '_activitypub_code_hash', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'SHA-256 hash of the authorization code.', + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + + \register_post_meta( + Authorization_Code::POST_TYPE, + '_activitypub_client_id', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'The OAuth client ID that requested this code.', + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + + \register_post_meta( + Authorization_Code::POST_TYPE, + '_activitypub_redirect_uri', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'The redirect URI used for this authorization.', + 'sanitize_callback' => 'sanitize_url', + ) + ); + + \register_post_meta( + Authorization_Code::POST_TYPE, + '_activitypub_scopes', + array( + 'type' => 'array', + 'single' => true, + 'description' => 'Requested OAuth scopes.', + 'sanitize_callback' => array( Scope::class, 'sanitize' ), + ) + ); + + \register_post_meta( + Authorization_Code::POST_TYPE, + '_activitypub_code_challenge', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'PKCE code challenge.', + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + + \register_post_meta( + Authorization_Code::POST_TYPE, + '_activitypub_code_challenge_method', + array( + 'type' => 'string', + 'single' => true, + 'description' => 'PKCE code challenge method (S256 or plain).', + 'sanitize_callback' => static function ( $value ) { + $allowed = array( 'S256', 'plain' ); + return in_array( $value, $allowed, true ) ? $value : 'S256'; + }, + 'default' => 'S256', + ) + ); + + \register_post_meta( + Authorization_Code::POST_TYPE, + '_activitypub_expires_at', + array( + 'type' => 'integer', + 'single' => true, + 'description' => 'Unix timestamp when the authorization code expires (10 minutes).', + 'sanitize_callback' => 'absint', + ) + ); + } + /** * Register post meta for ActivityPub supported post types. */ diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index e470156be7..33b34af56d 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -26,6 +26,7 @@ class Create { */ public static function init() { \add_action( 'activitypub_handled_inbox_create', array( self::class, 'handle_create' ), 10, 3 ); + \add_action( 'activitypub_handled_outbox_create', array( self::class, 'handle_outbox_create' ), 10, 4 ); \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'maybe_unbury' ), 10, 2 ); } @@ -64,6 +65,72 @@ public static function handle_create( $activity, $user_ids, $activity_object = n \do_action( 'activitypub_handled_create', $activity, (array) $user_ids, $success, $result ); } + /** + * Handle outbox "Create" activities (C2S). + * + * Creates a WordPress post from the ActivityPub object. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_create( $data, $user_id, $activity, $outbox_id ) { + $object = $data['object'] ?? array(); + + if ( ! is_array( $object ) ) { + return; + } + + $type = $object['type'] ?? ''; + + // Only handle Note and Article types. + if ( ! in_array( $type, array( 'Note', 'Article' ), true ) ) { + return; + } + + $content = $object['content'] ?? ''; + $name = $object['name'] ?? ''; + $summary = $object['summary'] ?? ''; + + // Use name as title for Articles, or generate from content for Notes. + $title = $name; + if ( empty( $title ) && ! empty( $content ) ) { + $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); + } + + // Determine visibility. + $visibility = \get_post_meta( $outbox_id, 'activitypub_content_visibility', true ); + + $post_data = array( + 'post_author' => $user_id > 0 ? $user_id : 0, + 'post_title' => $title, + 'post_content' => $content, + 'post_excerpt' => $summary, + 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish', + 'post_type' => 'post', + 'meta_input' => array( + 'activitypub_content_visibility' => $visibility, + ), + ); + + $post_id = \wp_insert_post( $post_data, true ); + + if ( \is_wp_error( $post_id ) ) { + return; + } + + /** + * Fires after a post has been created from a C2S Create activity. + * + * @param int $post_id The created post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_outbox_created_post', $post_id, $data, $user_id, $outbox_id ); + } + /** * Handle interactions like replies. * diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 00b4b0a3a6..8e4f6f0495 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -23,6 +23,7 @@ class Delete { */ public static function init() { \add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ), 10, 2 ); + \add_action( 'activitypub_handled_outbox_delete', array( self::class, 'handle_outbox_delete' ), 10, 4 ); \add_filter( 'activitypub_skip_inbox_storage', array( self::class, 'skip_inbox_storage' ), 10, 2 ); \add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 ); \add_action( 'activitypub_delete_remote_actor_interactions', array( self::class, 'delete_interactions' ) ); @@ -353,4 +354,54 @@ public static function maybe_bury( $outbox_id, $activity ) { Tombstone::bury( $object->get_url() ); } } + + /** + * Handle outbox "Delete" activities (C2S). + * + * Deletes a WordPress post. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_delete( $data, $user_id, $activity, $outbox_id ) { + $object = $data['object'] ?? ''; + + // Get the object ID (can be a string URL or an object with an id). + $object_id = object_to_uri( $object ); + + if ( empty( $object_id ) ) { + return; + } + + // Find the post by its ActivityPub ID. + $post = Posts::get_by_guid( $object_id ); + + if ( ! $post instanceof \WP_Post ) { + return; + } + + // Verify the user owns this post. + if ( (int) $post->post_author !== $user_id && $user_id > 0 ) { + return; + } + + // Trash the post (use wp_delete_post with false to move to trash). + $result = \wp_trash_post( $post->ID ); + + if ( ! $result ) { + return; + } + + /** + * Fires after a post has been deleted from a C2S Delete activity. + * + * @param int $post_id The deleted post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_outbox_deleted_post', $post->ID, $data, $user_id, $outbox_id ); + } } diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php index 3095411875..db3e39b636 100644 --- a/includes/handler/class-follow.php +++ b/includes/handler/class-follow.php @@ -10,6 +10,7 @@ use Activitypub\Activity\Activity; use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; +use Activitypub\Collection\Following; use Activitypub\Collection\Remote_Actors; use function Activitypub\add_to_outbox; @@ -24,6 +25,7 @@ class Follow { public static function init() { \add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 ); \add_action( 'activitypub_handled_follow', array( self::class, 'queue_accept' ), 10, 4 ); + \add_action( 'activitypub_handled_outbox_follow', array( self::class, 'handle_outbox_follow' ), 10, 4 ); } /** @@ -148,4 +150,52 @@ public static function queue_reject( $activity, $user_id ) { add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); } + + /** + * Handle outbox "Follow" activities (C2S). + * + * Adds the target actor to the user's following list (pending until accepted). + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_follow( $data, $user_id, $activity, $outbox_id ) { + $object = $data['object'] ?? ''; + + // The object should be the actor URL to follow. + if ( empty( $object ) || ! \is_string( $object ) ) { + return; + } + + // Fetch or create the remote actor. + $remote_actor = Remote_Actors::fetch_by_uri( $object ); + + if ( \is_wp_error( $remote_actor ) ) { + return; + } + + // Check if already following. + $all_meta = \get_post_meta( $remote_actor->ID ); + $following = $all_meta[ Following::FOLLOWING_META_KEY ] ?? array(); + $pending = $all_meta[ Following::PENDING_META_KEY ] ?? array(); + + if ( \in_array( (string) $user_id, $following, true ) || \in_array( (string) $user_id, $pending, true ) ) { + return; + } + + // Add to pending following. + \add_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, (string) $user_id ); + + /** + * Fires after a Follow activity has been sent via C2S. + * + * @param int $remote_actor_id The remote actor post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_outbox_follow_sent', $remote_actor->ID, $data, $user_id, $outbox_id ); + } } diff --git a/includes/handler/class-undo.php b/includes/handler/class-undo.php index b2eda6c006..d280b7f635 100644 --- a/includes/handler/class-undo.php +++ b/includes/handler/class-undo.php @@ -7,7 +7,9 @@ namespace Activitypub\Handler; +use Activitypub\Collection\Following; use Activitypub\Collection\Inbox as Inbox_Collection; +use Activitypub\Collection\Remote_Actors; use function Activitypub\object_to_uri; @@ -20,6 +22,7 @@ class Undo { */ public static function init() { \add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo' ), 10, 2 ); + \add_action( 'activitypub_handled_outbox_undo', array( self::class, 'handle_outbox_undo' ), 10, 4 ); \add_action( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); } @@ -82,4 +85,57 @@ public static function validate_object( $valid, $param, $request ) { return $valid; } + + /** + * Handle outbox "Undo" activities (C2S). + * + * Handles Undo Follow (unfollow) activities. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_id ) { + $object = $data['object'] ?? array(); + + if ( ! \is_array( $object ) ) { + return; + } + + $type = $object['type'] ?? ''; + + // Only handle Undo Follow for now. + if ( 'Follow' !== $type ) { + return; + } + + // Get the target actor from the original Follow activity. + $target = $object['object'] ?? ''; + + if ( empty( $target ) || ! \is_string( $target ) ) { + return; + } + + // Get the remote actor. + $remote_actor = Remote_Actors::get_by_uri( $target ); + + if ( \is_wp_error( $remote_actor ) ) { + return; + } + + // Remove following relationship. + \delete_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, $user_id ); + \delete_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, $user_id ); + + /** + * Fires after an Undo Follow activity has been sent via C2S. + * + * @param int $remote_actor_id The remote actor post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_outbox_undo_follow_sent', $remote_actor->ID, $data, $user_id, $outbox_id ); + } } diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index 0e5a6cf6dd..19803a69f6 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -23,6 +23,7 @@ class Update { */ public static function init() { \add_action( 'activitypub_handled_inbox_update', array( self::class, 'handle_update' ), 10, 3 ); + \add_action( 'activitypub_handled_outbox_update', array( self::class, 'handle_outbox_update' ), 10, 4 ); } /** @@ -146,4 +147,80 @@ public static function update_actor( $activity, $user_ids ) { */ \do_action( 'activitypub_handled_update', $activity, (array) $user_ids, $state, $actor ); } + + /** + * Handle outbox "Update" activities (C2S). + * + * Updates a WordPress post from the ActivityPub object. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_update( $data, $user_id, $activity, $outbox_id ) { + $object = $data['object'] ?? array(); + + if ( ! \is_array( $object ) ) { + return; + } + + $type = $object['type'] ?? ''; + + // Only handle Note and Article types. + if ( ! \in_array( $type, array( 'Note', 'Article' ), true ) ) { + return; + } + + $object_id = $object['id'] ?? ''; + + if ( empty( $object_id ) ) { + return; + } + + // Find the post by its ActivityPub ID. + $post = Posts::get_by_guid( $object_id ); + + if ( ! $post instanceof \WP_Post ) { + return; + } + + // Verify the user owns this post. + if ( (int) $post->post_author !== $user_id && $user_id > 0 ) { + return; + } + + $content = $object['content'] ?? ''; + $name = $object['name'] ?? ''; + $summary = $object['summary'] ?? ''; + + // Use name as title for Articles, or generate from content for Notes. + $title = $name; + if ( empty( $title ) && ! empty( $content ) ) { + $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); + } + + $post_data = array( + 'ID' => $post->ID, + 'post_title' => $title, + 'post_content' => $content, + 'post_excerpt' => $summary, + ); + + $post_id = \wp_update_post( $post_data, true ); + + if ( \is_wp_error( $post_id ) ) { + return; + } + + /** + * Fires after a post has been updated from a C2S Update activity. + * + * @param int $post_id The updated post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id ); + } } diff --git a/includes/model/class-blog.php b/includes/model/class-blog.php index bfc8a46841..7dedf2377e 100644 --- a/includes/model/class-blog.php +++ b/includes/model/class-blog.php @@ -396,9 +396,17 @@ public function get_following() { * @return string[]|null The endpoints. */ public function get_endpoints() { - return array( + $endpoints = array( 'sharedInbox' => get_rest_url_by_path( 'inbox' ), ); + + // Add OAuth endpoints if C2S is enabled. + if ( \get_option( 'activitypub_enable_c2s', false ) ) { + $endpoints['oauthAuthorizationEndpoint'] = get_rest_url_by_path( 'oauth/authorize' ); + $endpoints['oauthTokenEndpoint'] = get_rest_url_by_path( 'oauth/token' ); + } + + return $endpoints; } /** diff --git a/includes/model/class-user.php b/includes/model/class-user.php index a987d5b8fd..57ae3b127c 100644 --- a/includes/model/class-user.php +++ b/includes/model/class-user.php @@ -317,9 +317,17 @@ public function get_featured_tags() { * @return string[]|null The endpoints. */ public function get_endpoints() { - return array( + $endpoints = array( 'sharedInbox' => get_rest_url_by_path( 'inbox' ), ); + + // Add OAuth endpoints if C2S is enabled. + if ( \get_option( 'activitypub_enable_c2s', false ) ) { + $endpoints['oauthAuthorizationEndpoint'] = get_rest_url_by_path( 'oauth/authorize' ); + $endpoints['oauthTokenEndpoint'] = get_rest_url_by_path( 'oauth/token' ); + } + + return $endpoints; } /** diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php new file mode 100644 index 0000000000..33e7340086 --- /dev/null +++ b/includes/oauth/class-authorization-code.php @@ -0,0 +1,296 @@ +post_id = $post_id; + } + + /** + * Create a new authorization code. + * + * @param int $user_id WordPress user ID. + * @param string $client_id OAuth client ID. + * @param string $redirect_uri The redirect URI. + * @param array $scopes Requested scopes. + * @param string $code_challenge PKCE code challenge. + * @param string $code_challenge_method PKCE method (S256 or plain). + * @return string|\WP_Error The authorization code or error. + */ + public static function create( + $user_id, + $client_id, + $redirect_uri, + $scopes, + $code_challenge, + $code_challenge_method = 'S256' + ) { + // Validate client. + $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { + return $client; + } + + // Validate redirect URI. + if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { + return new \WP_Error( + 'activitypub_invalid_redirect_uri', + \__( 'Invalid redirect URI for this client.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Filter scopes to only allowed ones. + $filtered_scopes = $client->filter_scopes( Scope::validate( $scopes ) ); + + // Generate the code. + $code = self::generate_code(); + $expires_at = time() + self::EXPIRATION; + + // Create the authorization code post. + $post_id = \wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => self::STATUS_PENDING, + 'post_author' => $user_id, + 'post_title' => sprintf( + /* translators: %1$s: client ID */ + \__( 'Auth code for %1$s', 'activitypub' ), + $client_id + ), + 'meta_input' => array( + '_activitypub_code_hash' => Token::hash_token( $code ), + '_activitypub_client_id' => $client_id, + '_activitypub_redirect_uri' => $redirect_uri, + '_activitypub_scopes' => $filtered_scopes, + '_activitypub_code_challenge' => $code_challenge, + '_activitypub_code_challenge_method' => $code_challenge_method, + '_activitypub_expires_at' => $expires_at, + ), + ), + true + ); + + if ( \is_wp_error( $post_id ) ) { + return $post_id; + } + + return $code; + } + + /** + * Exchange authorization code for tokens. + * + * @param string $code The authorization code. + * @param string $client_id The client ID. + * @param string $redirect_uri The redirect URI (must match original). + * @param string $code_verifier The PKCE code verifier. + * @return array|\WP_Error Token data or error. + */ + public static function exchange( $code, $client_id, $redirect_uri, $code_verifier ) { + $hash = Token::hash_token( $code ); + + // Find the authorization code. + $posts = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => self::STATUS_PENDING, + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'AND', + array( + 'key' => '_activitypub_code_hash', + 'value' => $hash, + ), + array( + 'key' => '_activitypub_client_id', + 'value' => $client_id, + ), + ), + 'numberposts' => 1, + ) + ); + + if ( empty( $posts ) ) { + return new \WP_Error( + 'activitypub_invalid_code', + \__( 'Invalid authorization code.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + $post = $posts[0]; + + // Check expiration. + $expires_at = (int) \get_post_meta( $post->ID, '_activitypub_expires_at', true ); + if ( $expires_at < time() ) { + // Mark as used to prevent further attempts. + \wp_update_post( + array( + 'ID' => $post->ID, + 'post_status' => self::STATUS_USED, + ) + ); + + return new \WP_Error( + 'activitypub_code_expired', + \__( 'Authorization code has expired.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Verify redirect URI matches. + $stored_redirect_uri = \get_post_meta( $post->ID, '_activitypub_redirect_uri', true ); + if ( $redirect_uri !== $stored_redirect_uri ) { + return new \WP_Error( + 'activitypub_redirect_uri_mismatch', + \__( 'Redirect URI does not match.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Verify PKCE. + $code_challenge = \get_post_meta( $post->ID, '_activitypub_code_challenge', true ); + $code_challenge_method = \get_post_meta( $post->ID, '_activitypub_code_challenge_method', true ) ?: 'S256'; + + if ( ! self::verify_pkce( $code_verifier, $code_challenge, $code_challenge_method ) ) { + return new \WP_Error( + 'activitypub_invalid_pkce', + \__( 'Invalid PKCE code verifier.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Mark the code as used (single use). + \wp_update_post( + array( + 'ID' => $post->ID, + 'post_status' => self::STATUS_USED, + ) + ); + + // Get the user and scopes. + $user_id = $post->post_author; + $scopes = \get_post_meta( $post->ID, '_activitypub_scopes', true ); + + // Create and return the tokens. + return Token::create( $user_id, $client_id, $scopes ); + } + + /** + * Verify PKCE code_verifier against code_challenge. + * + * @param string $code_verifier The PKCE code verifier. + * @param string $code_challenge The stored code challenge. + * @param string $method The challenge method (S256 or plain). + * @return bool True if valid. + */ + public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) { + if ( empty( $code_verifier ) || empty( $code_challenge ) ) { + return false; + } + + if ( 'plain' === $method ) { + return hash_equals( $code_challenge, $code_verifier ); + } + + // S256: BASE64URL(SHA256(code_verifier)) == code_challenge. + $computed = self::compute_code_challenge( $code_verifier ); + + return hash_equals( $code_challenge, $computed ); + } + + /** + * Compute a PKCE code challenge from a code verifier. + * + * @param string $code_verifier The code verifier. + * @return string The code challenge (BASE64URL encoded SHA256 hash). + */ + public static function compute_code_challenge( $code_verifier ) { + $hash = hash( 'sha256', $code_verifier, true ); + return rtrim( strtr( base64_encode( $hash ), '+/', '-_' ), '=' ); + } + + /** + * Generate a random authorization code. + * + * @return string The authorization code. + */ + public static function generate_code() { + return Token::generate_token( 32 ); + } + + /** + * Clean up expired authorization codes. + * + * Should be called periodically via cron. + * + * @return int Number of codes deleted. + */ + public static function cleanup() { + global $wpdb; + + $expired_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT 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_expires_at' + AND pm.meta_value < %d", + self::POST_TYPE, + time() + ) + ); + + $count = 0; + foreach ( $expired_ids as $post_id ) { + if ( \wp_delete_post( $post_id, true ) ) { + ++$count; + } + } + + return $count; + } +} diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php new file mode 100644 index 0000000000..37827d000f --- /dev/null +++ b/includes/oauth/class-client.php @@ -0,0 +1,345 @@ +post_id = $post_id; + } + + /** + * Register a new OAuth client. + * + * @param array $data Client registration data. + * - name: Client name (required). + * - redirect_uris: Array of redirect URIs (required). + * - description: Client description (optional). + * - is_public: Whether client is public/PKCE-only (default true). + * - scopes: Allowed scopes (optional, defaults to all). + * @return array|\WP_Error Client credentials or error. + */ + public static function register( $data ) { + $name = $data['name'] ?? ''; + $redirect_uris = $data['redirect_uris'] ?? array(); + $description = $data['description'] ?? ''; + $is_public = $data['is_public'] ?? true; + $scopes = $data['scopes'] ?? Scope::ALL; + + // Validate required fields. + if ( empty( $name ) ) { + return new \WP_Error( + 'activitypub_missing_client_name', + \__( 'Client name is required.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + if ( empty( $redirect_uris ) ) { + return new \WP_Error( + 'activitypub_missing_redirect_uri', + \__( 'At least one redirect URI is required.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Validate redirect URIs. + foreach ( $redirect_uris as $uri ) { + if ( ! self::validate_uri_format( $uri ) ) { + return new \WP_Error( + 'activitypub_invalid_redirect_uri', + /* translators: %s: The invalid redirect URI */ + sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ), + array( 'status' => 400 ) + ); + } + } + + // Generate client credentials. + $client_id = self::generate_client_id(); + $client_secret = null; + + if ( ! $is_public ) { + $client_secret = self::generate_client_secret(); + } + + // Create the client post. + $post_id = \wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $name, + 'post_content' => $description, + 'meta_input' => array( + '_activitypub_client_id' => $client_id, + '_activitypub_client_secret_hash' => $client_secret ? Token::hash_token( $client_secret ) : '', + '_activitypub_redirect_uris' => array_map( 'sanitize_url', $redirect_uris ), + '_activitypub_allowed_scopes' => Scope::validate( $scopes ), + '_activitypub_is_public' => (bool) $is_public, + ), + ), + true + ); + + if ( \is_wp_error( $post_id ) ) { + return $post_id; + } + + $result = array( + 'client_id' => $client_id, + ); + + if ( $client_secret ) { + $result['client_secret'] = $client_secret; + } + + return $result; + } + + /** + * Get client by client_id. + * + * @param string $client_id The client ID. + * @return Client|\WP_Error The client or error. + */ + public static function get( $client_id ) { + $posts = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'meta_key' => '_activitypub_client_id', + 'meta_value' => $client_id, + 'numberposts' => 1, + ) + ); + + if ( empty( $posts ) ) { + return new \WP_Error( + 'activitypub_client_not_found', + \__( 'OAuth client not found.', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + return new self( $posts[0]->ID ); + } + + /** + * Validate client credentials. + * + * @param string $client_id The client ID. + * @param string|null $client_secret The client secret (optional for public clients). + * @return bool True if valid. + */ + public static function validate( $client_id, $client_secret = null ) { + $client = self::get( $client_id ); + + if ( \is_wp_error( $client ) ) { + return false; + } + + // Public clients don't need secret validation. + if ( $client->is_public() ) { + return true; + } + + // Confidential clients require a valid secret. + if ( empty( $client_secret ) ) { + return false; + } + + $stored_hash = \get_post_meta( $client->post_id, '_activitypub_client_secret_hash', true ); + + return hash_equals( $stored_hash, Token::hash_token( $client_secret ) ); + } + + /** + * Check if redirect URI is valid for this client. + * + * @param string $redirect_uri The redirect URI to validate. + * @return bool True if valid. + */ + public function is_valid_redirect_uri( $redirect_uri ) { + $allowed_uris = $this->get_redirect_uris(); + + // Exact match required. + return in_array( $redirect_uri, $allowed_uris, true ); + } + + /** + * Get client name. + * + * @return string The client name. + */ + public function get_name() { + $post = \get_post( $this->post_id ); + return $post ? $post->post_title : ''; + } + + /** + * Get client description. + * + * @return string The client description. + */ + public function get_description() { + $post = \get_post( $this->post_id ); + return $post ? $post->post_content : ''; + } + + /** + * Get client ID. + * + * @return string The client ID. + */ + public function get_client_id() { + return \get_post_meta( $this->post_id, '_activitypub_client_id', true ); + } + + /** + * Get allowed redirect URIs. + * + * @return array The redirect URIs. + */ + public function get_redirect_uris() { + $uris = \get_post_meta( $this->post_id, '_activitypub_redirect_uris', true ); + return is_array( $uris ) ? $uris : array(); + } + + /** + * Get allowed scopes for this client. + * + * @return array The allowed scopes. + */ + public function get_allowed_scopes() { + $scopes = \get_post_meta( $this->post_id, '_activitypub_allowed_scopes', true ); + return is_array( $scopes ) ? $scopes : Scope::ALL; + } + + /** + * Check if this is a public client. + * + * @return bool True if public. + */ + public function is_public() { + return (bool) \get_post_meta( $this->post_id, '_activitypub_is_public', true ); + } + + /** + * Filter requested scopes to only those allowed for this client. + * + * @param array $requested_scopes The requested scopes. + * @return array Filtered scopes. + */ + public function filter_scopes( $requested_scopes ) { + $allowed = $this->get_allowed_scopes(); + return array_values( array_intersect( $requested_scopes, $allowed ) ); + } + + /** + * Generate a unique client ID. + * + * @return string UUID v4. + */ + public static function generate_client_id() { + // Generate UUID v4. + $data = random_bytes( 16 ); + $data[6] = chr( ord( $data[6] ) & 0x0f | 0x40 ); // Version 4. + $data[8] = chr( ord( $data[8] ) & 0x3f | 0x80 ); // Variant. + + return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) ); + } + + /** + * Generate a client secret. + * + * @return string The client secret. + */ + public static function generate_client_secret() { + return Token::generate_token( 32 ); + } + + /** + * Validate a redirect URI format. + * + * @param string $uri The URI to validate. + * @return bool True if valid. + */ + private static function validate_uri_format( $uri ) { + $parsed = wp_parse_url( $uri ); + + if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { + return false; + } + + // Allow http for localhost development. + if ( 'http' === $parsed['scheme'] ) { + $localhost_hosts = array( 'localhost', '127.0.0.1', '[::1]' ); + if ( ! in_array( $parsed['host'], $localhost_hosts, true ) ) { + return false; + } + } elseif ( 'https' !== $parsed['scheme'] ) { + // Only allow https for production. + return false; + } + + return true; + } + + /** + * Delete a client and all its tokens. + * + * @param string $client_id The client ID to delete. + * @return bool True on success. + */ + public static function delete( $client_id ) { + $client = self::get( $client_id ); + + if ( \is_wp_error( $client ) ) { + return false; + } + + // Delete all tokens for this client. + $tokens = \get_posts( + array( + 'post_type' => Token::POST_TYPE, + 'meta_key' => '_activitypub_client_id', + 'meta_value' => $client_id, + 'numberposts' => -1, + 'fields' => 'ids', + ) + ); + + foreach ( $tokens as $token_id ) { + \wp_delete_post( $token_id, true ); + } + + // Delete the client. + return (bool) \wp_delete_post( $client->post_id, true ); + } +} diff --git a/includes/oauth/class-scope.php b/includes/oauth/class-scope.php new file mode 100644 index 0000000000..b1c47084d1 --- /dev/null +++ b/includes/oauth/class-scope.php @@ -0,0 +1,187 @@ + 'Read actor profile, collections, and objects', + self::WRITE => 'Create activities via POST to outbox', + self::FOLLOW => 'Manage following relationships', + self::PUSH => 'Subscribe to real-time event streams', + self::PROFILE => 'Edit actor profile', + ); + + /** + * Default scopes when none are requested. + * + * @var array + */ + const DEFAULT_SCOPES = array( + self::READ, + ); + + /** + * Validate and filter requested scopes. + * + * @param string|array $scopes The requested scopes (space-separated string or array). + * @return array Valid scopes. + */ + public static function validate( $scopes ) { + if ( is_string( $scopes ) ) { + $scopes = self::parse( $scopes ); + } + + if ( ! is_array( $scopes ) ) { + return self::DEFAULT_SCOPES; + } + + $valid_scopes = array_intersect( $scopes, self::ALL ); + + if ( empty( $valid_scopes ) ) { + return self::DEFAULT_SCOPES; + } + + return array_values( $valid_scopes ); + } + + /** + * Parse a space-separated scope string to array. + * + * @param string $scope_string Space-separated scopes. + * @return array Scope array. + */ + public static function parse( $scope_string ) { + if ( empty( $scope_string ) || ! is_string( $scope_string ) ) { + return array(); + } + + $scopes = preg_split( '/\s+/', trim( $scope_string ) ); + + return array_filter( array_map( 'trim', $scopes ) ); + } + + /** + * Convert scopes array to space-separated string. + * + * @param array $scopes The scopes array. + * @return string Space-separated scope string. + */ + public static function to_string( $scopes ) { + if ( ! is_array( $scopes ) ) { + return ''; + } + + return implode( ' ', $scopes ); + } + + /** + * Check if a scope is valid. + * + * @param string $scope The scope to check. + * @return bool True if valid, false otherwise. + */ + public static function is_valid( $scope ) { + return in_array( $scope, self::ALL, true ); + } + + /** + * Get the description for a scope. + * + * @param string $scope The scope. + * @return string The description or empty string if not found. + */ + public static function get_description( $scope ) { + return self::DESCRIPTIONS[ $scope ] ?? ''; + } + + /** + * Get all scopes with their descriptions. + * + * @return array Associative array of scope => description. + */ + public static function get_all_with_descriptions() { + return self::DESCRIPTIONS; + } + + /** + * Check if scopes contain a specific scope. + * + * @param array $scopes The scopes to check. + * @param string $scope The scope to look for. + * @return bool True if the scope is present. + */ + public static function contains( $scopes, $scope ) { + return is_array( $scopes ) && in_array( $scope, $scopes, true ); + } + + /** + * Sanitize callback for scope storage. + * + * @param mixed $value The value to sanitize. + * @return array Sanitized scopes array. + */ + public static function sanitize( $value ) { + if ( is_string( $value ) ) { + $value = self::parse( $value ); + } + + if ( ! is_array( $value ) ) { + return array(); + } + + return self::validate( $value ); + } +} diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php new file mode 100644 index 0000000000..af933df0d9 --- /dev/null +++ b/includes/oauth/class-server.php @@ -0,0 +1,255 @@ +get_user_id() ); + + return true; + } + + /** + * Get the current OAuth token from the request. + * + * @return Token|null The validated token or null. + */ + public static function get_current_token() { + return self::$current_token; + } + + /** + * Check if the current request is authenticated via OAuth. + * + * @return bool True if OAuth authenticated. + */ + public static function is_oauth_request() { + return null !== self::$current_token; + } + + /** + * Check if the current token has a specific scope. + * + * @param string $scope The scope to check. + * @return bool True if the current token has the scope. + */ + public static function has_scope( $scope ) { + if ( ! self::$current_token ) { + return false; + } + + return self::$current_token->has_scope( $scope ); + } + + /** + * Extract Bearer token from Authorization header. + * + * @return string|null The token string or null. + */ + public static function get_bearer_token() { + $auth_header = self::get_authorization_header(); + + if ( ! $auth_header ) { + return null; + } + + // Check for Bearer token. + if ( 0 !== strpos( $auth_header, 'Bearer ' ) ) { + return null; + } + + return substr( $auth_header, 7 ); + } + + /** + * Get the Authorization header. + * + * @return string|null The authorization header value or null. + */ + private static function get_authorization_header() { + // Check for standard header. + if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { + return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); + } + + // Check for redirect header (some servers use this). + if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { + return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); + } + + // Try to get from Apache. + if ( function_exists( 'apache_request_headers' ) ) { + $headers = apache_request_headers(); + if ( isset( $headers['Authorization'] ) ) { + return sanitize_text_field( $headers['Authorization'] ); + } + // Check case-insensitive. + foreach ( $headers as $key => $value ) { + if ( 'authorization' === strtolower( $key ) ) { + return sanitize_text_field( $value ); + } + } + } + + return null; + } + + /** + * Verify PKCE code_verifier against code_challenge. + * + * @param string $code_verifier The PKCE code verifier. + * @param string $code_challenge The stored code challenge. + * @param string $method The challenge method (S256 or plain). + * @return bool True if valid. + */ + public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) { + return Authorization_Code::verify_pkce( $code_verifier, $code_challenge, $method ); + } + + /** + * Generate a cryptographically secure random string. + * + * @param int $length The length of the string in bytes. + * @return string The random string as hex. + */ + public static function generate_token( $length = 32 ) { + return Token::generate_token( $length ); + } + + /** + * Permission callback for OAuth-protected endpoints. + * + * @param \WP_REST_Request $request The REST request. + * @param string $scope Required scope (optional). + * @return bool|\WP_Error True if authorized, error otherwise. + */ + public static function check_oauth_permission( $request, $scope = null ) { + // Must be authenticated via OAuth. + if ( ! self::is_oauth_request() ) { + return new \WP_Error( + 'activitypub_oauth_required', + \__( 'OAuth authentication required.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Check scope if specified. + if ( $scope && ! self::has_scope( $scope ) ) { + return new \WP_Error( + 'activitypub_insufficient_scope', + /* translators: %s: The required scope */ + sprintf( \__( 'This action requires the "%s" scope.', 'activitypub' ), $scope ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Check if C2S (Client-to-Server) is enabled. + * + * @return bool True if C2S is enabled. + */ + public static function is_c2s_enabled() { + return (bool) \get_option( 'activitypub_enable_c2s', false ); + } + + /** + * Run cleanup tasks for OAuth data. + */ + public static function cleanup() { + // Clean up expired tokens. + Token::cleanup_expired(); + + // Clean up expired authorization codes. + Authorization_Code::cleanup(); + } + + /** + * Get OAuth server metadata for discovery. + * + * @return array OAuth server metadata. + */ + public static function get_metadata() { + $base_url = \trailingslashit( \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE ) ); + + return array( + 'issuer' => \home_url(), + 'authorization_endpoint' => $base_url . 'oauth/authorize', + 'token_endpoint' => $base_url . 'oauth/token', + 'revocation_endpoint' => $base_url . 'oauth/revoke', + 'registration_endpoint' => $base_url . 'oauth/clients', + 'scopes_supported' => Scope::ALL, + 'response_types_supported' => array( 'code' ), + 'response_modes_supported' => array( 'query' ), + 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ), + 'token_endpoint_auth_methods_supported' => array( 'none', 'client_secret_post' ), + 'code_challenge_methods_supported' => array( 'S256', 'plain' ), + 'service_documentation' => 'https://github.com/swicg/activitypub-api', + ); + } +} diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php new file mode 100644 index 0000000000..03bb845b5a --- /dev/null +++ b/includes/oauth/class-token.php @@ -0,0 +1,386 @@ +post_id = $post_id; + } + + /** + * Create a new access token. + * + * @param int $user_id WordPress user ID. + * @param string $client_id OAuth client ID. + * @param array $scopes Granted scopes. + * @param int $expires Expiration time in seconds. + * @return array|\WP_Error Token data or error. + */ + public static function create( $user_id, $client_id, $scopes, $expires = self::DEFAULT_EXPIRATION ) { + // Generate tokens. + $access_token = self::generate_token(); + $refresh_token = self::generate_token(); + + // Calculate expiration. + $expires_at = time() + $expires; + + // Create the token post. + $post_id = \wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => self::STATUS_ACTIVE, + 'post_author' => $user_id, + 'post_title' => sprintf( + /* translators: %1$s: client ID, %2$s: user login */ + \__( 'Token for %1$s (%2$s)', 'activitypub' ), + $client_id, + \get_userdata( $user_id )->user_login ?? $user_id + ), + 'meta_input' => array( + '_activitypub_access_token_hash' => self::hash_token( $access_token ), + '_activitypub_refresh_token_hash' => self::hash_token( $refresh_token ), + '_activitypub_client_id' => $client_id, + '_activitypub_scopes' => Scope::validate( $scopes ), + '_activitypub_expires_at' => $expires_at, + ), + ), + true + ); + + if ( \is_wp_error( $post_id ) ) { + return $post_id; + } + + return array( + 'access_token' => $access_token, + 'token_type' => 'Bearer', + 'expires_in' => $expires, + 'refresh_token' => $refresh_token, + 'scope' => Scope::to_string( $scopes ), + ); + } + + /** + * Validate an access token. + * + * @param string $token The access token to validate. + * @return Token|\WP_Error The token object or error. + */ + public static function validate( $token ) { + $hash = self::hash_token( $token ); + + $posts = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => self::STATUS_ACTIVE, + 'meta_key' => '_activitypub_access_token_hash', + 'meta_value' => $hash, + 'numberposts' => 1, + ) + ); + + if ( empty( $posts ) ) { + return new \WP_Error( + 'activitypub_invalid_token', + \__( 'Invalid or expired access token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + $post = $posts[0]; + $expires_at = (int) \get_post_meta( $post->ID, '_activitypub_expires_at', true ); + + if ( $expires_at < time() ) { + return new \WP_Error( + 'activitypub_token_expired', + \__( 'Access token has expired.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + return new self( $post->ID ); + } + + /** + * Refresh an access token using a refresh token. + * + * @param string $refresh_token The refresh token. + * @param string $client_id The client ID (must match original). + * @return array|\WP_Error New token data or error. + */ + public static function refresh( $refresh_token, $client_id ) { + $hash = self::hash_token( $refresh_token ); + + $posts = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => self::STATUS_ACTIVE, + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'AND', + array( + 'key' => '_activitypub_refresh_token_hash', + 'value' => $hash, + ), + array( + 'key' => '_activitypub_client_id', + 'value' => $client_id, + ), + ), + 'numberposts' => 1, + ) + ); + + if ( empty( $posts ) ) { + return new \WP_Error( + 'activitypub_invalid_refresh_token', + \__( 'Invalid refresh token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + $post = $posts[0]; + + // Get existing data. + $user_id = $post->post_author; + $scopes = \get_post_meta( $post->ID, '_activitypub_scopes', true ); + + // Revoke the old token. + \wp_update_post( + array( + 'ID' => $post->ID, + 'post_status' => self::STATUS_REVOKED, + ) + ); + + // Create a new token. + return self::create( $user_id, $client_id, $scopes ); + } + + /** + * Revoke a token. + * + * @param string $token The token to revoke (access or refresh). + * @return bool True on success. + */ + public static function revoke( $token ) { + $hash = self::hash_token( $token ); + + // Check both access and refresh token hashes. + $posts = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => self::STATUS_ACTIVE, + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'OR', + array( + 'key' => '_activitypub_access_token_hash', + 'value' => $hash, + ), + array( + 'key' => '_activitypub_refresh_token_hash', + 'value' => $hash, + ), + ), + 'numberposts' => 1, + ) + ); + + if ( empty( $posts ) ) { + // Token doesn't exist or already revoked - that's fine per RFC 7009. + return true; + } + + $result = \wp_update_post( + array( + 'ID' => $posts[0]->ID, + 'post_status' => self::STATUS_REVOKED, + ) + ); + + return ! \is_wp_error( $result ); + } + + /** + * Revoke all tokens for a user. + * + * @param int $user_id WordPress user ID. + * @return int Number of tokens revoked. + */ + public static function revoke_all_for_user( $user_id ) { + $posts = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => self::STATUS_ACTIVE, + 'author' => $user_id, + 'numberposts' => -1, + ) + ); + + $count = 0; + foreach ( $posts as $post ) { + $result = \wp_update_post( + array( + 'ID' => $post->ID, + 'post_status' => self::STATUS_REVOKED, + ) + ); + if ( ! \is_wp_error( $result ) ) { + ++$count; + } + } + + return $count; + } + + /** + * Check if token has a specific scope. + * + * @param string $scope The scope to check. + * @return bool True if token has scope. + */ + public function has_scope( $scope ) { + $scopes = $this->get_scopes(); + return Scope::contains( $scopes, $scope ); + } + + /** + * Get the user ID associated with this token. + * + * @return int The WordPress user ID. + */ + public function get_user_id() { + $post = \get_post( $this->post_id ); + return $post ? (int) $post->post_author : 0; + } + + /** + * Get the client ID associated with this token. + * + * @return string The OAuth client ID. + */ + public function get_client_id() { + return \get_post_meta( $this->post_id, '_activitypub_client_id', true ); + } + + /** + * Get the scopes for this token. + * + * @return array The granted scopes. + */ + public function get_scopes() { + $scopes = \get_post_meta( $this->post_id, '_activitypub_scopes', true ); + return is_array( $scopes ) ? $scopes : array(); + } + + /** + * Get the expiration timestamp. + * + * @return int Unix timestamp. + */ + public function get_expires_at() { + return (int) \get_post_meta( $this->post_id, '_activitypub_expires_at', true ); + } + + /** + * Check if the token is expired. + * + * @return bool True if expired. + */ + public function is_expired() { + return $this->get_expires_at() < time(); + } + + /** + * Generate a cryptographically secure random token. + * + * @param int $length The length of the token in bytes (default 32 = 64 hex chars). + * @return string The random token as a hex string. + */ + public static function generate_token( $length = 32 ) { + return bin2hex( random_bytes( $length ) ); + } + + /** + * Hash a token for secure storage. + * + * @param string $token The token to hash. + * @return string The SHA-256 hash. + */ + public static function hash_token( $token ) { + return hash( 'sha256', $token ); + } + + /** + * Clean up expired tokens. + * + * Should be called periodically via cron. + * + * @return int Number of tokens deleted. + */ + public static function cleanup_expired() { + global $wpdb; + + $expired_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT 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_expires_at' + AND pm.meta_value < %d", + self::POST_TYPE, + time() - DAY_IN_SECONDS // Grace period of 1 day. + ) + ); + + $count = 0; + foreach ( $expired_ids as $post_id ) { + if ( \wp_delete_post( $post_id, true ) ) { + ++$count; + } + } + + return $count; + } +} diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 100980c046..652d2d5fcc 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -8,14 +8,19 @@ namespace Activitypub\Rest; use Activitypub\Activity\Activity; +use Activitypub\Activity\Base_Object; use Activitypub\Collection\Actors; use Activitypub\Collection\Following; use Activitypub\Collection\Inbox; use Activitypub\Http; use Activitypub\Moderation; +use Activitypub\OAuth\Scope; +use Activitypub\OAuth\Server as OAuth_Server; use function Activitypub\camel_to_snake_case; use function Activitypub\extract_recipients_from_activity; +use function Activitypub\get_masked_wp_version; +use function Activitypub\get_rest_url_by_path; use function Activitypub\is_activity_public; use function Activitypub\is_collection; use function Activitypub\is_same_domain; @@ -29,6 +34,8 @@ * @see https://www.w3.org/TR/activitypub/#inbox */ class Inbox_Controller extends \WP_REST_Controller { + use Collection; + /** * The namespace of this controller's route. * @@ -43,10 +50,18 @@ class Inbox_Controller extends \WP_REST_Controller { */ protected $rest_base = 'inbox'; + /** + * The base for user-specific inbox routes. + * + * @var string + */ + protected $user_rest_base = '(?:users|actors)/(?P[\-]?\d+)/inbox'; + /** * Register routes. */ public function register_routes() { + // Shared inbox (POST only). \register_rest_route( $this->namespace, '/' . $this->rest_base, @@ -55,80 +70,294 @@ public function register_routes() { 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'args' => $this->get_create_item_args(), + ), + 'schema' => array( $this, 'get_item_schema' ), + ) + ); + + // User-specific inbox (GET for C2S, POST for S2S). + \register_rest_route( + $this->namespace, + '/' . $this->user_rest_base, + array( + 'args' => array( + 'user_id' => array( + 'description' => 'The ID of the user or actor.', + 'type' => 'integer', + 'validate_callback' => array( $this, 'validate_user_id' ), + ), + ), + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => array( - 'id' => array( - 'description' => 'The unique identifier for the activity.', - 'type' => 'string', - 'format' => 'uri', - 'required' => true, - ), - 'actor' => array( - 'description' => 'The actor performing the activity.', - 'type' => 'string', - 'required' => true, - 'sanitize_callback' => '\Activitypub\object_to_uri', + 'page' => array( + 'description' => 'Current page of the collection.', + 'type' => 'integer', + 'minimum' => 1, + // No default so we can differentiate between Collection and CollectionPage requests. ), - 'type' => array( - 'description' => 'The type of the activity.', - 'type' => 'string', - 'required' => true, - ), - 'object' => array( - 'description' => 'The object of the activity.', - 'required' => true, - 'validate_callback' => static function ( $param, $request, $key ) { - /** - * Filter the ActivityPub object validation. - * - * @param bool $validate The validation result. - * @param array $param The object data. - * @param \WP_REST_Request $request The request object. - * @param string $key The key. - */ - return \apply_filters( 'activitypub_validate_object', true, $param, $request, $key ); - }, - ), - 'to' => array( - 'description' => 'The primary recipients of the activity.', - 'type' => array( 'string', 'array' ), - 'required' => false, - 'sanitize_callback' => static function ( $param ) { - if ( \is_string( $param ) ) { - $param = array( $param ); - } - - return $param; - }, - ), - 'cc' => array( - 'description' => 'The secondary recipients of the activity.', - 'type' => array( 'string', 'array' ), - 'sanitize_callback' => static function ( $param ) { - if ( \is_string( $param ) ) { - $param = array( $param ); - } - - return $param; - }, - ), - 'bcc' => array( - 'description' => 'The private recipients of the activity.', - 'type' => array( 'string', 'array' ), - 'sanitize_callback' => static function ( $param ) { - if ( \is_string( $param ) ) { - $param = array( $param ); - } - - return $param; - }, + 'per_page' => array( + 'description' => 'Maximum number of items to be returned in result set.', + 'type' => 'integer', + 'default' => 20, + 'minimum' => 1, + 'maximum' => 100, ), ), ), + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'args' => $this->get_create_item_args(), + ), 'schema' => array( $this, 'get_item_schema' ), ) ); } + /** + * Get the arguments for create_item. + * + * @return array The arguments. + */ + private function get_create_item_args() { + return array( + 'id' => array( + 'description' => 'The unique identifier for the activity.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'actor' => array( + 'description' => 'The actor performing the activity.', + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => '\Activitypub\object_to_uri', + ), + 'type' => array( + 'description' => 'The type of the activity.', + 'type' => 'string', + 'required' => true, + ), + 'object' => array( + 'description' => 'The object of the activity.', + 'required' => true, + 'validate_callback' => static function ( $param, $request, $key ) { + /** + * Filter the ActivityPub object validation. + * + * @param bool $validate The validation result. + * @param array $param The object data. + * @param \WP_REST_Request $request The request object. + * @param string $key The key. + */ + return \apply_filters( 'activitypub_validate_object', true, $param, $request, $key ); + }, + ), + 'to' => array( + 'description' => 'The primary recipients of the activity.', + 'type' => array( 'string', 'array' ), + 'required' => false, + 'sanitize_callback' => static function ( $param ) { + if ( \is_string( $param ) ) { + $param = array( $param ); + } + + return $param; + }, + ), + 'cc' => array( + 'description' => 'The secondary recipients of the activity.', + 'type' => array( 'string', 'array' ), + 'sanitize_callback' => static function ( $param ) { + if ( \is_string( $param ) ) { + $param = array( $param ); + } + + return $param; + }, + ), + 'bcc' => array( + 'description' => 'The private recipients of the activity.', + 'type' => array( 'string', 'array' ), + 'sanitize_callback' => static function ( $param ) { + if ( \is_string( $param ) ) { + $param = array( $param ); + } + + return $param; + }, + ), + ); + } + + /** + * Validates the user_id parameter. + * + * @param mixed $user_id The user_id parameter. + * @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise. + */ + public function validate_user_id( $user_id ) { + $user = Actors::get_by_id( $user_id ); + if ( \is_wp_error( $user ) ) { + return $user; + } + + return true; + } + + /** + * Permission check for reading inbox items (C2S). + * + * @param \WP_REST_Request $request Full details about the request. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public function get_items_permissions_check( \WP_REST_Request $request ) { + // Check if C2S is enabled. + if ( ! OAuth_Server::is_c2s_enabled() ) { + return new \WP_Error( + 'activitypub_c2s_disabled', + \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $user_id = $request->get_param( 'user_id' ); + + // Validate the user. + $user = Actors::get_by_id( $user_id ); + if ( \is_wp_error( $user ) ) { + return $user; + } + + // Validate OAuth token and scope. + $result = OAuth_Server::check_oauth_permission( $request, Scope::READ ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + // Verify the token belongs to the requested user. + $token = OAuth_Server::get_current_token(); + + if ( ! $token || $token->get_user_id() !== $user_id ) { + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'You can only read your own inbox.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Retrieves a collection of inbox items. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $page = $request->get_param( 'page' ) ?? 1; + $user_id = $request->get_param( 'user_id' ); + $user = Actors::get_by_id( $user_id ); + + /** + * Action triggered prior to the ActivityPub inbox being created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_inbox_pre', $request ); + + $args = array( + 'posts_per_page' => $request->get_param( 'per_page' ), + 'paged' => $page, + 'post_type' => Inbox::POST_TYPE, + 'post_status' => 'publish', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_user_id', + 'value' => $user_id, + ), + ), + ); + + /** + * Filters WP_Query arguments when querying Inbox items via the REST API. + * + * Enables adding extra arguments or setting defaults for an inbox collection request. + * + * @param array $args Array of arguments for WP_Query. + * @param \WP_REST_Request $request The REST API request. + */ + $args = \apply_filters( 'activitypub_rest_inbox_query', $args, $request ); + + $inbox_query = new \WP_Query(); + $query_result = $inbox_query->query( $args ); + + $response = array( + '@context' => Base_Object::JSON_LD_CONTEXT, + 'id' => get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user_id ) ), + 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(), + 'actor' => $user->get_id(), + 'type' => 'OrderedCollection', + 'totalItems' => (int) $inbox_query->found_posts, + 'orderedItems' => array(), + ); + + \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); + foreach ( $query_result as $inbox_item ) { + if ( ! $inbox_item instanceof \WP_Post ) { + continue; + } + + $response['orderedItems'][] = $this->prepare_item_for_response( $inbox_item, $request ); + } + + $response = $this->prepare_collection_response( $response, $request ); + if ( \is_wp_error( $response ) ) { + return $response; + } + + /** + * Filter the ActivityPub inbox array. + * + * @param array $response The ActivityPub inbox array. + * @param \WP_REST_Request $request The request object. + */ + $response = \apply_filters( 'activitypub_rest_inbox_array', $response, $request ); + + /** + * Action triggered after the ActivityPub inbox has been created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_inbox_post', $request ); + + $response = \rest_ensure_response( $response ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Prepares the item for the REST response. + * + * @param mixed $item WordPress representation of the item. + * @param \WP_REST_Request $request Request object. + * @return array Response object on success. + */ + public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $activity = \json_decode( $item->post_content, true ); + + return $activity; + } + /** * The shared inbox. * diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php new file mode 100644 index 0000000000..f45de9751d --- /dev/null +++ b/includes/rest/class-oauth-controller.php @@ -0,0 +1,792 @@ +namespace, + '/' . $this->rest_base . '/authorize', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'authorize' ), + 'permission_callback' => '__return_true', + 'args' => $this->get_authorize_args(), + ), + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'authorize_submit' ), + 'permission_callback' => array( $this, 'authorize_submit_permissions_check' ), + 'args' => array_merge( + $this->get_authorize_args(), + array( + 'approve' => array( + 'description' => 'Whether the user approved the authorization.', + 'type' => 'boolean', + 'required' => true, + ), + '_wpnonce' => array( + 'description' => 'WordPress nonce for CSRF protection.', + 'type' => 'string', + 'required' => true, + ), + ) + ), + ), + ) + ); + + // Token endpoint. + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/token', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'token' ), + 'permission_callback' => '__return_true', + 'args' => $this->get_token_args(), + ), + ) + ); + + // Revocation endpoint. + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/revoke', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'revoke' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'token' => array( + 'description' => 'The token to revoke.', + 'type' => 'string', + 'required' => true, + ), + 'token_type_hint' => array( + 'description' => 'Hint about the token type.', + 'type' => 'string', + 'enum' => array( 'access_token', 'refresh_token' ), + ), + ), + ), + ) + ); + + // Dynamic client registration (RFC 7591). + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/clients', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'register_client' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'client_name' => array( + 'description' => 'Human-readable name of the client.', + 'type' => 'string', + 'required' => true, + ), + 'redirect_uris' => array( + 'description' => 'Array of redirect URIs.', + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'required' => true, + ), + 'client_uri' => array( + 'description' => 'URL of the client homepage.', + 'type' => 'string', + 'format' => 'uri', + ), + 'scope' => array( + 'description' => 'Space-separated list of requested scopes.', + 'type' => 'string', + ), + ), + ), + ) + ); + + // OAuth server metadata (RFC 8414). + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/.well-known/oauth-authorization-server', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_metadata' ), + 'permission_callback' => '__return_true', + ), + ) + ); + } + + /** + * Handle authorization request (GET /oauth/authorize). + * + * Displays authorization page or redirects to WP login. + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error + */ + public function authorize( \WP_REST_Request $request ) { + // Check if C2S is enabled. + if ( ! OAuth_Server::is_c2s_enabled() ) { + return new \WP_Error( + 'activitypub_c2s_disabled', + \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $client_id = $request->get_param( 'client_id' ); + $redirect_uri = $request->get_param( 'redirect_uri' ); + $response_type = $request->get_param( 'response_type' ); + $scope = $request->get_param( 'scope' ); + $state = $request->get_param( 'state' ); + + // Validate client. + $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { + return $client; + } + + // Validate redirect URI. + if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { + return new \WP_Error( + 'activitypub_invalid_redirect_uri', + \__( 'Invalid redirect URI for this client.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Only support 'code' response type. + if ( 'code' !== $response_type ) { + return $this->redirect_with_error( + $redirect_uri, + 'unsupported_response_type', + 'Only authorization code flow is supported.', + $state + ); + } + + // Check for PKCE (required). + $code_challenge = $request->get_param( 'code_challenge' ); + if ( empty( $code_challenge ) ) { + return $this->redirect_with_error( + $redirect_uri, + 'invalid_request', + 'PKCE code_challenge is required.', + $state + ); + } + + // If user is not logged in, redirect to login page. + if ( ! \is_user_logged_in() ) { + $login_url = \wp_login_url( $request->get_uri() ); + return new \WP_REST_Response( + null, + 302, + array( 'Location' => $login_url ) + ); + } + + // User is logged in - display consent page. + $scopes = Scope::validate( Scope::parse( $scope ) ); + $user = \wp_get_current_user(); + $nonce = \wp_create_nonce( 'activitypub_oauth_authorize' ); + + // Build consent page HTML. + $html = $this->render_consent_page( + $client, + $scopes, + $user, + $request->get_params(), + $nonce + ); + + return new \WP_REST_Response( + $html, + 200, + array( 'Content-Type' => 'text/html; charset=' . \get_option( 'blog_charset' ) ) + ); + } + + /** + * Handle authorization approval (POST /oauth/authorize). + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error + */ + public function authorize_submit( \WP_REST_Request $request ) { + // Check if C2S is enabled. + if ( ! OAuth_Server::is_c2s_enabled() ) { + return new \WP_Error( + 'activitypub_c2s_disabled', + \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $client_id = $request->get_param( 'client_id' ); + $redirect_uri = $request->get_param( 'redirect_uri' ); + $scope = $request->get_param( 'scope' ); + $state = $request->get_param( 'state' ); + $code_challenge = $request->get_param( 'code_challenge' ); + $code_challenge_method = $request->get_param( 'code_challenge_method' ) ?: 'S256'; + $approve = $request->get_param( 'approve' ); + + // User denied authorization. + if ( ! $approve ) { + return $this->redirect_with_error( + $redirect_uri, + 'access_denied', + 'The user denied the authorization request.', + $state + ); + } + + // Create authorization code. + $scopes = Scope::validate( Scope::parse( $scope ) ); + $code = Authorization_Code::create( + \get_current_user_id(), + $client_id, + $redirect_uri, + $scopes, + $code_challenge, + $code_challenge_method + ); + + if ( \is_wp_error( $code ) ) { + return $this->redirect_with_error( + $redirect_uri, + 'server_error', + $code->get_error_message(), + $state + ); + } + + // Redirect back to client with code. + $redirect_url = \add_query_arg( + array( + 'code' => $code, + 'state' => $state, + ), + $redirect_uri + ); + + return new \WP_REST_Response( + null, + 302, + array( 'Location' => $redirect_url ) + ); + } + + /** + * Permission check for authorization submission. + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if allowed, error otherwise. + */ + public function authorize_submit_permissions_check( \WP_REST_Request $request ) { + if ( ! \is_user_logged_in() ) { + return new \WP_Error( + 'activitypub_not_logged_in', + \__( 'You must be logged in to authorize applications.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Verify nonce. + $nonce = $request->get_param( '_wpnonce' ); + if ( ! \wp_verify_nonce( $nonce, 'activitypub_oauth_authorize' ) ) { + return new \WP_Error( + 'activitypub_invalid_nonce', + \__( 'Invalid security token. Please try again.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Handle token request (POST /oauth/token). + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error + */ + public function token( \WP_REST_Request $request ) { + // Check if C2S is enabled. + if ( ! OAuth_Server::is_c2s_enabled() ) { + return new \WP_Error( + 'activitypub_c2s_disabled', + \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $grant_type = $request->get_param( 'grant_type' ); + $client_id = $request->get_param( 'client_id' ); + + // Validate client. + $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { + return $this->token_error( 'invalid_client', 'Unknown client.' ); + } + + // Validate client credentials if confidential. + if ( ! $client->is_public() ) { + $client_secret = $request->get_param( 'client_secret' ); + if ( ! Client::validate( $client_id, $client_secret ) ) { + return $this->token_error( 'invalid_client', 'Invalid client credentials.' ); + } + } + + switch ( $grant_type ) { + case 'authorization_code': + return $this->handle_authorization_code_grant( $request, $client_id ); + + case 'refresh_token': + return $this->handle_refresh_token_grant( $request, $client_id ); + + default: + return $this->token_error( 'unsupported_grant_type', 'Grant type not supported.' ); + } + } + + /** + * Handle authorization code grant. + * + * @param \WP_REST_Request $request The request object. + * @param string $client_id The client ID. + * @return \WP_REST_Response|\WP_Error + */ + private function handle_authorization_code_grant( \WP_REST_Request $request, $client_id ) { + $code = $request->get_param( 'code' ); + $redirect_uri = $request->get_param( 'redirect_uri' ); + $code_verifier = $request->get_param( 'code_verifier' ); + + if ( empty( $code ) ) { + return $this->token_error( 'invalid_request', 'Authorization code is required.' ); + } + + if ( empty( $code_verifier ) ) { + return $this->token_error( 'invalid_request', 'PKCE code_verifier is required.' ); + } + + $result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier ); + + if ( \is_wp_error( $result ) ) { + return $this->token_error( 'invalid_grant', $result->get_error_message() ); + } + + return $this->token_response( $result ); + } + + /** + * Handle refresh token grant. + * + * @param \WP_REST_Request $request The request object. + * @param string $client_id The client ID. + * @return \WP_REST_Response|\WP_Error + */ + private function handle_refresh_token_grant( \WP_REST_Request $request, $client_id ) { + $refresh_token = $request->get_param( 'refresh_token' ); + + if ( empty( $refresh_token ) ) { + return $this->token_error( 'invalid_request', 'Refresh token is required.' ); + } + + $result = Token::refresh( $refresh_token, $client_id ); + + if ( \is_wp_error( $result ) ) { + return $this->token_error( 'invalid_grant', $result->get_error_message() ); + } + + return $this->token_response( $result ); + } + + /** + * Handle token revocation (POST /oauth/revoke). + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response + */ + public function revoke( \WP_REST_Request $request ) { + $token = $request->get_param( 'token' ); + + // Per RFC 7009, always return 200 even if token doesn't exist. + Token::revoke( $token ); + + return new \WP_REST_Response( null, 200 ); + } + + /** + * Handle dynamic client registration (POST /oauth/clients). + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error + */ + public function register_client( \WP_REST_Request $request ) { + // Check if C2S is enabled. + if ( ! OAuth_Server::is_c2s_enabled() ) { + return new \WP_Error( + 'activitypub_c2s_disabled', + \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + // Check if dynamic registration is allowed. + if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', true ) ) { + return new \WP_Error( + 'activitypub_registration_disabled', + \__( 'Dynamic client registration is not allowed.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $client_name = $request->get_param( 'client_name' ); + $redirect_uris = $request->get_param( 'redirect_uris' ); + $client_uri = $request->get_param( 'client_uri' ); + $scope = $request->get_param( 'scope' ); + + $result = Client::register( + array( + 'name' => $client_name, + 'redirect_uris' => $redirect_uris, + 'description' => $client_uri ?? '', + 'is_public' => true, // Dynamic clients are always public. + 'scopes' => $scope ? Scope::parse( $scope ) : Scope::ALL, + ) + ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + // RFC 7591 response format. + $response = array( + 'client_id' => $result['client_id'], + 'client_name' => $client_name, + 'redirect_uris' => $redirect_uris, + 'token_endpoint_auth_method' => 'none', + ); + + if ( isset( $result['client_secret'] ) ) { + $response['client_secret'] = $result['client_secret']; + } + + return new \WP_REST_Response( $response, 201 ); + } + + /** + * Get OAuth server metadata. + * + * @return \WP_REST_Response + */ + public function get_metadata() { + return new \WP_REST_Response( + OAuth_Server::get_metadata(), + 200, + array( 'Content-Type' => 'application/json' ) + ); + } + + /** + * Get arguments for authorize endpoint. + * + * @return array Validation schema. + */ + private function get_authorize_args() { + return array( + 'response_type' => array( + 'description' => 'OAuth response type (must be "code").', + 'type' => 'string', + 'required' => true, + 'enum' => array( 'code' ), + ), + 'client_id' => array( + 'description' => 'The OAuth client identifier.', + 'type' => 'string', + 'required' => true, + ), + 'redirect_uri' => array( + 'description' => 'The URI to redirect to after authorization.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'scope' => array( + 'description' => 'Space-separated list of requested scopes.', + 'type' => 'string', + ), + 'state' => array( + 'description' => 'Opaque value for CSRF protection.', + 'type' => 'string', + ), + 'code_challenge' => array( + 'description' => 'PKCE code challenge.', + 'type' => 'string', + 'required' => true, + ), + 'code_challenge_method' => array( + 'description' => 'PKCE code challenge method.', + 'type' => 'string', + 'enum' => array( 'S256', 'plain' ), + 'default' => 'S256', + ), + ); + } + + /** + * Get arguments for token endpoint. + * + * @return array Validation schema. + */ + private function get_token_args() { + return array( + 'grant_type' => array( + 'description' => 'The grant type.', + 'type' => 'string', + 'required' => true, + 'enum' => array( 'authorization_code', 'refresh_token' ), + ), + 'client_id' => array( + 'description' => 'The OAuth client identifier.', + 'type' => 'string', + 'required' => true, + ), + 'client_secret' => array( + 'description' => 'The OAuth client secret (for confidential clients).', + 'type' => 'string', + ), + 'code' => array( + 'description' => 'The authorization code (for authorization_code grant).', + 'type' => 'string', + ), + 'redirect_uri' => array( + 'description' => 'The redirect URI (must match original for authorization_code grant).', + 'type' => 'string', + 'format' => 'uri', + ), + 'code_verifier' => array( + 'description' => 'PKCE code verifier.', + 'type' => 'string', + ), + 'refresh_token' => array( + 'description' => 'The refresh token (for refresh_token grant).', + 'type' => 'string', + ), + ); + } + + /** + * Create a token error response. + * + * @param string $error Error code. + * @param string $error_description Error description. + * @return \WP_REST_Response + */ + private function token_error( $error, $error_description ) { + return new \WP_REST_Response( + array( + 'error' => $error, + 'error_description' => $error_description, + ), + 400, + array( 'Content-Type' => 'application/json' ) + ); + } + + /** + * Create a token success response. + * + * @param array $token_data Token data. + * @return \WP_REST_Response + */ + private function token_response( $token_data ) { + return new \WP_REST_Response( + $token_data, + 200, + array( + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store', + 'Pragma' => 'no-cache', + ) + ); + } + + /** + * Redirect with an OAuth error. + * + * @param string $redirect_uri The redirect URI. + * @param string $error Error code. + * @param string $description Error description. + * @param string $state The state parameter. + * @return \WP_REST_Response + */ + private function redirect_with_error( $redirect_uri, $error, $description, $state = null ) { + $params = array( + 'error' => $error, + 'error_description' => $description, + ); + + if ( $state ) { + $params['state'] = $state; + } + + $redirect_url = \add_query_arg( $params, $redirect_uri ); + + return new \WP_REST_Response( + null, + 302, + array( 'Location' => $redirect_url ) + ); + } + + /** + * Render the consent page HTML. + * + * @param Client $client The OAuth client. + * @param array $scopes Requested scopes. + * @param \WP_User $user The current user. + * @param array $params Request parameters. + * @param string $nonce Security nonce. + * @return string HTML content. + */ + private function render_consent_page( $client, $scopes, $user, $params, $nonce ) { + $action_url = \rest_url( $this->namespace . '/' . $this->rest_base . '/authorize' ); + $site_name = \get_bloginfo( 'name' ); + + ob_start(); + ?> + + > + + + + <?php esc_html_e( 'Authorize Application', 'activitypub' ); ?> - <?php echo esc_html( $site_name ); ?> + + + +
+

+ +

+ ' . esc_html( $client->get_name() ) . '' + ); + ?> +

+ + + + +
+

+
    + +
  • + +
+
+ + +
+ $value ) : ?> + + + + + + +
+ + +
+
+
+ + + \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + ), 'schema' => array( $this, 'get_item_schema' ), ) ); @@ -317,4 +327,201 @@ public function overload_total_items( $response, $request ) { return $response; } + + /** + * Permission check for creating items (C2S). + * + * @param \WP_REST_Request $request Full details about the request. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public function create_item_permissions_check( \WP_REST_Request $request ) { + // Check if C2S is enabled. + if ( ! OAuth_Server::is_c2s_enabled() ) { + return new \WP_Error( + 'activitypub_c2s_disabled', + \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + // Must be authenticated via OAuth with 'write' scope. + $permission = OAuth_Server::check_oauth_permission( $request, Scope::WRITE ); + if ( \is_wp_error( $permission ) ) { + return $permission; + } + + // Token user must match actor in URL. + $user_id = $request->get_param( 'user_id' ); + $token = OAuth_Server::get_current_token(); + + if ( ! $token || $token->get_user_id() !== $user_id ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You can only post to your own outbox.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Create an item in the outbox (C2S). + * + * Follows the same pattern as the Inbox controller: + * 1. Store the activity in the outbox + * 2. Trigger action hooks for handlers to process + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error on failure. + */ + public function create_item( \WP_REST_Request $request ) { + $user_id = $request->get_param( 'user_id' ); + $user = Actors::get_by_id( $user_id ); + $data = $request->get_json_params(); + + if ( empty( $data ) ) { + return new \WP_Error( + 'activitypub_invalid_request', + \__( 'Request body must be a valid ActivityPub object or activity.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Determine if this is an Activity or a bare Object. + $type = $data['type'] ?? ''; + $is_activity = in_array( $type, Activity::TYPES, true ); + + // If it's a bare object, wrap it in a Create activity. + if ( ! $is_activity ) { + $data = $this->wrap_in_create( $data, $user ); + } + + $activity_type = camel_to_snake_case( $data['type'] ?? '' ); + + // Determine visibility from addressing. + $visibility = $this->determine_visibility( $data ); + + // Add to outbox - this handles storage and triggers federation. + $outbox_id = add_to_outbox( $data, null, $user_id, $visibility ); + + if ( ! $outbox_id || \is_wp_error( $outbox_id ) ) { + return new \WP_Error( + 'activitypub_outbox_error', + \__( 'Failed to add activity to outbox.', 'activitypub' ), + array( 'status' => 500 ) + ); + } + + // Get the stored activity for hooks. + $activity = Outbox::get_activity( $outbox_id ); + + /** + * Fires for each outbox activity. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param string $type The activity type (snake_case). + * @param \Activitypub\Activity\Activity $activity The Activity object. + */ + \do_action( 'activitypub_outbox', $data, $user_id, $activity_type, $activity ); + + /** + * Fires for specific outbox activity types. + * + * The dynamic portion of the hook name, `$activity_type`, refers to the + * activity type in snake_case (e.g., 'create', 'update', 'delete', 'like'). + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + */ + \do_action( 'activitypub_outbox_' . $activity_type, $data, $user_id, $activity ); + + /** + * Fires after an outbox activity has been stored. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param string $type The activity type (snake_case). + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_handled_outbox', $data, $user_id, $activity_type, $activity, $outbox_id ); + + /** + * Fires after a specific outbox activity type has been stored. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_handled_outbox_' . $activity_type, $data, $user_id, $activity, $outbox_id ); + + if ( \is_wp_error( $activity ) ) { + return $activity; + } + + $result = $activity->to_array( false ); + + // Return 201 Created with Location header. + $response = new \WP_REST_Response( $result, 201 ); + $response->header( 'Location', $result['id'] ?? \get_the_guid( $outbox_id ) ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Wrap a bare object in a Create activity. + * + * @param array $object The object data. + * @param mixed $user The user/actor. + * @return array The wrapped Create activity. + */ + private function wrap_in_create( $object, $user ) { + // Copy addressing from object to activity. + $addressing = array(); + foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $field ) { + if ( ! empty( $object[ $field ] ) ) { + $addressing[ $field ] = $object[ $field ]; + } + } + + return array_merge( + array( + '@context' => Base_Object::JSON_LD_CONTEXT, + 'type' => 'Create', + 'actor' => $user->get_id(), + 'object' => $object, + ), + $addressing + ); + } + + /** + * Determine content visibility from activity addressing. + * + * @param array $activity The activity data. + * @return string Visibility constant. + */ + private function determine_visibility( $activity ) { + $public = 'https://www.w3.org/ns/activitystreams#Public'; + $to = (array) ( $activity['to'] ?? array() ); + $cc = (array) ( $activity['cc'] ?? array() ); + + // Check if public. + if ( in_array( $public, $to, true ) ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; + } + + // Check if unlisted (public in cc). + if ( in_array( $public, $cc, true ) ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC; + } + + // Private (no public addressing). + return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + } } diff --git a/includes/wp-admin/class-advanced-settings-fields.php b/includes/wp-admin/class-advanced-settings-fields.php index 5efa7aff77..20772f73c5 100644 --- a/includes/wp-admin/class-advanced-settings-fields.php +++ b/includes/wp-admin/class-advanced-settings-fields.php @@ -98,6 +98,15 @@ public static function register_advanced_fields() { 'activitypub_advanced_settings', array( 'label_for' => 'activitypub_object_type' ) ); + + \add_settings_field( + 'activitypub_enable_c2s', + \__( 'Client-to-Server (C2S)', 'activitypub' ), + array( self::class, 'render_enable_c2s_field' ), + 'activitypub_advanced_settings', + 'activitypub_advanced_settings', + array( 'label_for' => 'activitypub_enable_c2s' ) + ); } /** @@ -253,4 +262,35 @@ public static function render_object_type_field() {

+

+ +

+

+ +

+

+ SWICG ActivityPub API specification, which is still under development. Some features may change in future versions.', 'activitypub' ), + array( + 'a' => array( + 'href' => true, + 'target' => true, + ), + ) + ); + ?> +

+ Date: Sun, 1 Feb 2026 00:10:11 +0100 Subject: [PATCH 002/105] Fix PHPCS warnings in OAuth and outbox controller --- includes/oauth/class-authorization-code.php | 1 + includes/oauth/class-client.php | 4 ++++ includes/oauth/class-token.php | 2 ++ includes/rest/class-outbox-controller.php | 12 ++++++------ 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index 33e7340086..da927eee67 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -250,6 +250,7 @@ public static function verify_pkce( $code_verifier, $code_challenge, $method = ' */ public static function compute_code_challenge( $code_verifier ) { $hash = hash( 'sha256', $code_verifier, true ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PKCE BASE64URL encoding per RFC 7636. return rtrim( strtr( base64_encode( $hash ), '+/', '-_' ), '=' ); } diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index 37827d000f..c09628727a 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -129,6 +129,7 @@ public static function register( $data ) { * @return Client|\WP_Error The client or error. */ public static function get( $client_id ) { + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Client lookup by ID is necessary. $posts = \get_posts( array( 'post_type' => self::POST_TYPE, @@ -138,6 +139,7 @@ public static function get( $client_id ) { 'numberposts' => 1, ) ); + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value if ( empty( $posts ) ) { return new \WP_Error( @@ -325,6 +327,7 @@ public static function delete( $client_id ) { } // Delete all tokens for this client. + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Token cleanup by client ID is necessary. $tokens = \get_posts( array( 'post_type' => Token::POST_TYPE, @@ -334,6 +337,7 @@ public static function delete( $client_id ) { 'fields' => 'ids', ) ); + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value foreach ( $tokens as $token_id ) { \wp_delete_post( $token_id, true ); diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index 03bb845b5a..6eb0f33ab1 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -111,6 +111,7 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D public static function validate( $token ) { $hash = self::hash_token( $token ); + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Token lookup by hash is necessary. $posts = \get_posts( array( 'post_type' => self::POST_TYPE, @@ -120,6 +121,7 @@ public static function validate( $token ) { 'numberposts' => 1, ) ); + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value if ( empty( $posts ) ) { return new \WP_Error( diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index c4daead459..22644d0223 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -476,16 +476,16 @@ public function create_item( \WP_REST_Request $request ) { /** * Wrap a bare object in a Create activity. * - * @param array $object The object data. - * @param mixed $user The user/actor. + * @param array $object_data The object data. + * @param mixed $user The user/actor. * @return array The wrapped Create activity. */ - private function wrap_in_create( $object, $user ) { + private function wrap_in_create( $object_data, $user ) { // Copy addressing from object to activity. $addressing = array(); foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $field ) { - if ( ! empty( $object[ $field ] ) ) { - $addressing[ $field ] = $object[ $field ]; + if ( ! empty( $object_data[ $field ] ) ) { + $addressing[ $field ] = $object_data[ $field ]; } } @@ -494,7 +494,7 @@ private function wrap_in_create( $object, $user ) { '@context' => Base_Object::JSON_LD_CONTEXT, 'type' => 'Create', 'actor' => $user->get_id(), - 'object' => $object, + 'object' => $object_data, ), $addressing ); From 51bd49b353d577a4681eed4383cb0ce8efea657e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 00:20:02 +0100 Subject: [PATCH 003/105] Add outbox handlers for Like and Announce activities Add C2S support for Like and Announce activities by hooking into the activitypub_handled_outbox_like and activitypub_handled_outbox_announce actions. These handlers fire corresponding sent actions that can be used to track when activities are sent via C2S. --- includes/handler/class-announce.php | 29 +++++++++++++++++++++++++++++ includes/handler/class-like.php | 29 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/includes/handler/class-announce.php b/includes/handler/class-announce.php index 9f58ba7fc3..91d0c7f022 100644 --- a/includes/handler/class-announce.php +++ b/includes/handler/class-announce.php @@ -25,6 +25,7 @@ class Announce { */ public static function init() { \add_action( 'activitypub_inbox_announce', array( self::class, 'handle_announce' ), 10, 3 ); + \add_action( 'activitypub_handled_outbox_announce', array( self::class, 'handle_outbox_announce' ), 10, 4 ); } /** @@ -130,4 +131,32 @@ public static function maybe_save_announce( $activity, $user_ids ) { */ \do_action( 'activitypub_handled_announce', $activity, (array) $user_ids, $success, $result ); } + + /** + * Handle outbox "Announce" activities (C2S). + * + * Records an announce/boost from the local user on remote content. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_announce( $data, $user_id, $activity, $outbox_id ) { + $object_url = object_to_uri( $data['object'] ?? '' ); + + if ( empty( $object_url ) ) { + return; + } + + /** + * Fires after an Announce activity has been sent via C2S. + * + * @param string $object_url The URL of the announced object. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_outbox_announce_sent', $object_url, $data, $user_id, $outbox_id ); + } } diff --git a/includes/handler/class-like.php b/includes/handler/class-like.php index 1d8912a7d8..fe77e36b94 100644 --- a/includes/handler/class-like.php +++ b/includes/handler/class-like.php @@ -21,6 +21,7 @@ class Like { */ public static function init() { \add_action( 'activitypub_inbox_like', array( self::class, 'handle_like' ), 10, 2 ); + \add_action( 'activitypub_handled_outbox_like', array( self::class, 'handle_outbox_like' ), 10, 4 ); \add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) ); } @@ -65,6 +66,34 @@ public static function handle_like( $like, $user_ids ) { \do_action( 'activitypub_handled_like', $like, (array) $user_ids, $success, $result ); } + /** + * Handle outbox "Like" activities (C2S). + * + * Records a like from the local user on remote content. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_like( $data, $user_id, $activity, $outbox_id ) { + $object_url = object_to_uri( $data['object'] ?? '' ); + + if ( empty( $object_url ) ) { + return; + } + + /** + * Fires after a Like activity has been sent via C2S. + * + * @param string $object_url The URL of the liked object. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_outbox_like_sent', $object_url, $data, $user_id, $outbox_id ); + } + /** * Set the object to the object ID. * From c99d88135535ea3c60dc2b9aba1532cbc209b7a2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 00:24:04 +0100 Subject: [PATCH 004/105] Add PHPUnit tests for OAuth classes Add comprehensive test coverage for the OAuth infrastructure: - Test_Scope: Scope parsing, validation, and string conversion - Test_Token: Token creation, validation, refresh, and revocation - Test_Client: Client registration, validation, and scope filtering - Test_Authorization_Code: PKCE flow, code exchange, and security checks --- .../oauth/class-test-authorization-code.php | 493 +++++++++++++++++ .../includes/oauth/class-test-client.php | 507 ++++++++++++++++++ .../tests/includes/oauth/class-test-scope.php | 303 +++++++++++ .../tests/includes/oauth/class-test-token.php | 353 ++++++++++++ 4 files changed, 1656 insertions(+) create mode 100644 tests/phpunit/tests/includes/oauth/class-test-authorization-code.php create mode 100644 tests/phpunit/tests/includes/oauth/class-test-client.php create mode 100644 tests/phpunit/tests/includes/oauth/class-test-scope.php create mode 100644 tests/phpunit/tests/includes/oauth/class-test-token.php diff --git a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php new file mode 100644 index 0000000000..5c59f5aa06 --- /dev/null +++ b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php @@ -0,0 +1,493 @@ +user_id = $this->factory->user->create( + array( + 'role' => 'editor', + ) + ); + + // Create a test client. + $client_result = Client::register( + array( + 'name' => 'Test Client', + 'redirect_uris' => array( $this->redirect_uri ), + ) + ); + $this->client_id = $client_result['client_id']; + } + + /** + * Tear down the test. + */ + public function tear_down() { + // Clean up client. + if ( $this->client_id ) { + Client::delete( $this->client_id ); + } + + parent::tear_down(); + } + + /** + * Generate a PKCE code verifier. + * + * @return string + */ + protected function generate_code_verifier() { + return bin2hex( random_bytes( 32 ) ); + } + + /** + * Test compute_code_challenge method. + * + * @covers ::compute_code_challenge + */ + public function test_compute_code_challenge() { + $verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + // Verify BASE64URL encoding (no +, /, or = characters). + $this->assertDoesNotMatchRegularExpression( '/[+\/=]/', $challenge ); + + // Should be a valid S256 challenge. + $this->assertNotEmpty( $challenge ); + } + + /** + * Test verify_pkce method with S256. + * + * @covers ::verify_pkce + */ + public function test_verify_pkce_s256() { + $verifier = $this->generate_code_verifier(); + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + $this->assertTrue( Authorization_Code::verify_pkce( $verifier, $challenge, 'S256' ) ); + $this->assertFalse( Authorization_Code::verify_pkce( 'wrong_verifier', $challenge, 'S256' ) ); + } + + /** + * Test verify_pkce method with plain. + * + * @covers ::verify_pkce + */ + public function test_verify_pkce_plain() { + $verifier = $this->generate_code_verifier(); + + $this->assertTrue( Authorization_Code::verify_pkce( $verifier, $verifier, 'plain' ) ); + $this->assertFalse( Authorization_Code::verify_pkce( 'wrong_verifier', $verifier, 'plain' ) ); + } + + /** + * Test verify_pkce method with empty values. + * + * @covers ::verify_pkce + */ + public function test_verify_pkce_empty() { + $this->assertFalse( Authorization_Code::verify_pkce( '', 'challenge', 'S256' ) ); + $this->assertFalse( Authorization_Code::verify_pkce( 'verifier', '', 'S256' ) ); + $this->assertFalse( Authorization_Code::verify_pkce( '', '', 'S256' ) ); + } + + /** + * Test generate_code method. + * + * @covers ::generate_code + */ + public function test_generate_code() { + $code = Authorization_Code::generate_code(); + + // Should be 64 hex characters (32 bytes). + $this->assertEquals( 64, strlen( $code ) ); + $this->assertMatchesRegularExpression( '/^[0-9a-f]+$/', $code ); + } + + /** + * Test create method. + * + * @covers ::create + */ + public function test_create() { + $verifier = $this->generate_code_verifier(); + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + $code = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ, Scope::WRITE ), + $challenge, + 'S256' + ); + + $this->assertIsString( $code ); + $this->assertEquals( 64, strlen( $code ) ); + } + + /** + * Test create method with invalid client. + * + * @covers ::create + */ + public function test_create_invalid_client() { + $result = Authorization_Code::create( + $this->user_id, + 'invalid-client-id', + $this->redirect_uri, + array( Scope::READ ), + 'challenge', + 'S256' + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_client_not_found', $result->get_error_code() ); + } + + /** + * Test create method with invalid redirect URI. + * + * @covers ::create + */ + public function test_create_invalid_redirect_uri() { + $result = Authorization_Code::create( + $this->user_id, + $this->client_id, + 'https://other.com/callback', + array( Scope::READ ), + 'challenge', + 'S256' + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_invalid_redirect_uri', $result->get_error_code() ); + } + + /** + * Test exchange method. + * + * @covers ::exchange + */ + public function test_exchange() { + $verifier = $this->generate_code_verifier(); + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + $code = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ, Scope::WRITE ), + $challenge, + 'S256' + ); + + $result = Authorization_Code::exchange( + $code, + $this->client_id, + $this->redirect_uri, + $verifier + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'access_token', $result ); + $this->assertArrayHasKey( 'refresh_token', $result ); + $this->assertArrayHasKey( 'token_type', $result ); + $this->assertArrayHasKey( 'expires_in', $result ); + $this->assertArrayHasKey( 'scope', $result ); + + $this->assertEquals( 'Bearer', $result['token_type'] ); + } + + /** + * Test exchange method prevents code reuse. + * + * @covers ::exchange + */ + public function test_exchange_prevents_reuse() { + $verifier = $this->generate_code_verifier(); + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + $code = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ ), + $challenge, + 'S256' + ); + + // First exchange should succeed. + $result1 = Authorization_Code::exchange( + $code, + $this->client_id, + $this->redirect_uri, + $verifier + ); + $this->assertIsArray( $result1 ); + + // Second exchange with same code should fail. + $result2 = Authorization_Code::exchange( + $code, + $this->client_id, + $this->redirect_uri, + $verifier + ); + $this->assertInstanceOf( \WP_Error::class, $result2 ); + $this->assertEquals( 'activitypub_invalid_code', $result2->get_error_code() ); + } + + /** + * Test exchange method with invalid code. + * + * @covers ::exchange + */ + public function test_exchange_invalid_code() { + $result = Authorization_Code::exchange( + 'invalid_code', + $this->client_id, + $this->redirect_uri, + 'verifier' + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_invalid_code', $result->get_error_code() ); + } + + /** + * Test exchange method with wrong redirect URI. + * + * @covers ::exchange + */ + public function test_exchange_redirect_uri_mismatch() { + $verifier = $this->generate_code_verifier(); + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + $code = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ ), + $challenge, + 'S256' + ); + + $result = Authorization_Code::exchange( + $code, + $this->client_id, + 'https://other.com/callback', // Wrong redirect URI. + $verifier + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_redirect_uri_mismatch', $result->get_error_code() ); + } + + /** + * Test exchange method with wrong PKCE verifier. + * + * @covers ::exchange + */ + public function test_exchange_invalid_pkce() { + $verifier = $this->generate_code_verifier(); + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + $code = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ ), + $challenge, + 'S256' + ); + + $result = Authorization_Code::exchange( + $code, + $this->client_id, + $this->redirect_uri, + 'wrong_verifier' // Wrong PKCE verifier. + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_invalid_pkce', $result->get_error_code() ); + } + + /** + * Test exchange method with wrong client ID. + * + * @covers ::exchange + */ + public function test_exchange_wrong_client() { + $verifier = $this->generate_code_verifier(); + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + $code = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ ), + $challenge, + 'S256' + ); + + $result = Authorization_Code::exchange( + $code, + 'wrong-client-id', + $this->redirect_uri, + $verifier + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_invalid_code', $result->get_error_code() ); + } + + /** + * Test exchange method filters scopes to client allowed scopes. + * + * @covers ::exchange + * @covers ::create + */ + public function test_exchange_filters_scopes() { + // Create a client with limited scopes. + $limited_client = Client::register( + array( + 'name' => 'Limited Client', + 'redirect_uris' => array( 'https://limited.com/callback' ), + 'scopes' => array( Scope::READ ), + ) + ); + + $verifier = $this->generate_code_verifier(); + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + // Request more scopes than allowed. + $code = Authorization_Code::create( + $this->user_id, + $limited_client['client_id'], + 'https://limited.com/callback', + array( Scope::READ, Scope::WRITE, Scope::FOLLOW ), + $challenge, + 'S256' + ); + + $result = Authorization_Code::exchange( + $code, + $limited_client['client_id'], + 'https://limited.com/callback', + $verifier + ); + + // Should only have the allowed scope. + $this->assertIsArray( $result ); + $this->assertEquals( 'read', $result['scope'] ); + + // Clean up. + Client::delete( $limited_client['client_id'] ); + } + + /** + * Test cleanup method. + * + * @covers ::cleanup + */ + public function test_cleanup() { + // Cleanup should run without errors. + $count = Authorization_Code::cleanup(); + $this->assertIsInt( $count ); + } + + /** + * Test complete PKCE flow. + * + * @covers ::create + * @covers ::exchange + * @covers ::verify_pkce + * @covers ::compute_code_challenge + */ + public function test_complete_pkce_flow() { + // Step 1: Client generates code verifier and challenge. + $code_verifier = $this->generate_code_verifier(); + $code_challenge = Authorization_Code::compute_code_challenge( $code_verifier ); + + // Step 2: Server creates authorization code. + $code = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ, Scope::WRITE ), + $code_challenge, + 'S256' + ); + + $this->assertIsString( $code ); + + // Step 3: Client exchanges code for tokens. + $tokens = Authorization_Code::exchange( + $code, + $this->client_id, + $this->redirect_uri, + $code_verifier + ); + + $this->assertIsArray( $tokens ); + $this->assertArrayHasKey( 'access_token', $tokens ); + $this->assertArrayHasKey( 'refresh_token', $tokens ); + + // Step 4: Verify the access token works. + $token = Token::validate( $tokens['access_token'] ); + $this->assertInstanceOf( Token::class, $token ); + $this->assertEquals( $this->user_id, $token->get_user_id() ); + $this->assertEquals( $this->client_id, $token->get_client_id() ); + $this->assertTrue( $token->has_scope( Scope::READ ) ); + $this->assertTrue( $token->has_scope( Scope::WRITE ) ); + } +} diff --git a/tests/phpunit/tests/includes/oauth/class-test-client.php b/tests/phpunit/tests/includes/oauth/class-test-client.php new file mode 100644 index 0000000000..cd3c117f59 --- /dev/null +++ b/tests/phpunit/tests/includes/oauth/class-test-client.php @@ -0,0 +1,507 @@ +created_clients as $client_id ) { + Client::delete( $client_id ); + } + $this->created_clients = array(); + + parent::tear_down(); + } + + /** + * Helper to create a client and track it for cleanup. + * + * @param array $data Client registration data. + * @return array|WP_Error Client credentials. + */ + protected function create_client( $data ) { + $result = Client::register( $data ); + if ( ! is_wp_error( $result ) ) { + $this->created_clients[] = $result['client_id']; + } + return $result; + } + + /** + * Test generate_client_id produces UUID v4 format. + * + * @covers ::generate_client_id + */ + public function test_generate_client_id() { + $client_id = Client::generate_client_id(); + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx. + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $client_id + ); + } + + /** + * Test generate_client_secret. + * + * @covers ::generate_client_secret + */ + public function test_generate_client_secret() { + $secret = Client::generate_client_secret(); + + // Should be 64 hex characters (32 bytes). + $this->assertEquals( 64, strlen( $secret ) ); + $this->assertMatchesRegularExpression( '/^[0-9a-f]+$/', $secret ); + } + + /** + * Test register method creates public client. + * + * @covers ::register + */ + public function test_register_public_client() { + $result = $this->create_client( + array( + 'name' => 'Test Public Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + 'is_public' => true, + ) + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'client_id', $result ); + $this->assertArrayNotHasKey( 'client_secret', $result ); + } + + /** + * Test register method creates confidential client. + * + * @covers ::register + */ + public function test_register_confidential_client() { + $result = $this->create_client( + array( + 'name' => 'Test Confidential Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + 'is_public' => false, + ) + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'client_id', $result ); + $this->assertArrayHasKey( 'client_secret', $result ); + } + + /** + * Test register method requires name. + * + * @covers ::register + */ + public function test_register_requires_name() { + $result = Client::register( + array( + 'redirect_uris' => array( 'https://example.com/callback' ), + ) + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_missing_client_name', $result->get_error_code() ); + } + + /** + * Test register method requires redirect_uris. + * + * @covers ::register + */ + public function test_register_requires_redirect_uris() { + $result = Client::register( + array( + 'name' => 'Test Client', + ) + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_missing_redirect_uri', $result->get_error_code() ); + } + + /** + * Test register method validates redirect URI format. + * + * @covers ::register + */ + public function test_register_validates_redirect_uri_https() { + $result = Client::register( + array( + 'name' => 'Test Client', + 'redirect_uris' => array( 'http://example.com/callback' ), + ) + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_invalid_redirect_uri', $result->get_error_code() ); + } + + /** + * Test register method allows http for localhost. + * + * @covers ::register + */ + public function test_register_allows_localhost_http() { + $result = $this->create_client( + array( + 'name' => 'Localhost Client', + 'redirect_uris' => array( 'http://localhost:8080/callback' ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'client_id', $result ); + } + + /** + * Test register method allows http for 127.0.0.1. + * + * @covers ::register + */ + public function test_register_allows_loopback_http() { + $result = $this->create_client( + array( + 'name' => 'Loopback Client', + 'redirect_uris' => array( 'http://127.0.0.1:3000/callback' ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'client_id', $result ); + } + + /** + * Test get method retrieves client. + * + * @covers ::get + */ + public function test_get_client() { + $result = $this->create_client( + array( + 'name' => 'Test Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + ) + ); + + $client = Client::get( $result['client_id'] ); + + $this->assertInstanceOf( Client::class, $client ); + $this->assertEquals( 'Test Client', $client->get_name() ); + } + + /** + * Test get method returns error for non-existent client. + * + * @covers ::get + */ + public function test_get_nonexistent_client() { + $result = Client::get( 'nonexistent-client-id' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_client_not_found', $result->get_error_code() ); + } + + /** + * Test validate method for public client. + * + * @covers ::validate + */ + public function test_validate_public_client() { + $result = $this->create_client( + array( + 'name' => 'Public Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + 'is_public' => true, + ) + ); + + // Public clients don't need a secret. + $this->assertTrue( Client::validate( $result['client_id'] ) ); + $this->assertTrue( Client::validate( $result['client_id'], null ) ); + } + + /** + * Test validate method for confidential client. + * + * @covers ::validate + */ + public function test_validate_confidential_client() { + $result = $this->create_client( + array( + 'name' => 'Confidential Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + 'is_public' => false, + ) + ); + + // Valid secret should pass. + $this->assertTrue( Client::validate( $result['client_id'], $result['client_secret'] ) ); + + // No secret should fail. + $this->assertFalse( Client::validate( $result['client_id'] ) ); + + // Wrong secret should fail. + $this->assertFalse( Client::validate( $result['client_id'], 'wrong_secret' ) ); + } + + /** + * Test validate method for non-existent client. + * + * @covers ::validate + */ + public function test_validate_nonexistent_client() { + $this->assertFalse( Client::validate( 'nonexistent-client-id' ) ); + } + + /** + * Test is_valid_redirect_uri method. + * + * @covers ::is_valid_redirect_uri + */ + public function test_is_valid_redirect_uri() { + $result = $this->create_client( + array( + 'name' => 'Test Client', + 'redirect_uris' => array( + 'https://example.com/callback', + 'https://example.com/oauth', + ), + ) + ); + + $client = Client::get( $result['client_id'] ); + + $this->assertTrue( $client->is_valid_redirect_uri( 'https://example.com/callback' ) ); + $this->assertTrue( $client->is_valid_redirect_uri( 'https://example.com/oauth' ) ); + $this->assertFalse( $client->is_valid_redirect_uri( 'https://example.com/other' ) ); + $this->assertFalse( $client->is_valid_redirect_uri( 'https://other.com/callback' ) ); + } + + /** + * Test get_redirect_uris method. + * + * @covers ::get_redirect_uris + */ + public function test_get_redirect_uris() { + $uris = array( + 'https://example.com/callback', + 'https://example.com/oauth', + ); + $result = $this->create_client( + array( + 'name' => 'Test Client', + 'redirect_uris' => $uris, + ) + ); + + $client = Client::get( $result['client_id'] ); + + $this->assertEquals( $uris, $client->get_redirect_uris() ); + } + + /** + * Test get_allowed_scopes method. + * + * @covers ::get_allowed_scopes + */ + public function test_get_allowed_scopes() { + $scopes = array( Scope::READ, Scope::WRITE ); + $result = $this->create_client( + array( + 'name' => 'Test Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + 'scopes' => $scopes, + ) + ); + + $client = Client::get( $result['client_id'] ); + + $this->assertEquals( $scopes, $client->get_allowed_scopes() ); + } + + /** + * Test get_allowed_scopes defaults to all scopes. + * + * @covers ::get_allowed_scopes + */ + public function test_get_allowed_scopes_default() { + $result = $this->create_client( + array( + 'name' => 'Test Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + ) + ); + + $client = Client::get( $result['client_id'] ); + + $this->assertEquals( Scope::ALL, $client->get_allowed_scopes() ); + } + + /** + * Test is_public method. + * + * @covers ::is_public + */ + public function test_is_public() { + $public_result = $this->create_client( + array( + 'name' => 'Public Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + 'is_public' => true, + ) + ); + + $confidential_result = $this->create_client( + array( + 'name' => 'Confidential Client', + 'redirect_uris' => array( 'https://other.com/callback' ), + 'is_public' => false, + ) + ); + + $public_client = Client::get( $public_result['client_id'] ); + $confidential_client = Client::get( $confidential_result['client_id'] ); + + $this->assertTrue( $public_client->is_public() ); + $this->assertFalse( $confidential_client->is_public() ); + } + + /** + * Test filter_scopes method. + * + * @covers ::filter_scopes + */ + public function test_filter_scopes() { + $result = $this->create_client( + array( + 'name' => 'Limited Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + 'scopes' => array( Scope::READ, Scope::WRITE ), + ) + ); + + $client = Client::get( $result['client_id'] ); + + $filtered = $client->filter_scopes( array( Scope::READ, Scope::FOLLOW, Scope::WRITE ) ); + $this->assertEquals( array( Scope::READ, Scope::WRITE ), $filtered ); + + $filtered = $client->filter_scopes( array( Scope::FOLLOW, Scope::PUSH ) ); + $this->assertEquals( array(), $filtered ); + } + + /** + * Test delete method. + * + * @covers ::delete + */ + public function test_delete() { + $result = Client::register( + array( + 'name' => 'Delete Test Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + ) + ); + $client_id = $result['client_id']; + + // Create a token for this client. + $user_id = $this->factory->user->create(); + Token::create( $user_id, $client_id, array( Scope::READ ) ); + + // Delete the client. + $delete_result = Client::delete( $client_id ); + $this->assertTrue( $delete_result ); + + // Client should no longer exist. + $get_result = Client::get( $client_id ); + $this->assertInstanceOf( \WP_Error::class, $get_result ); + } + + /** + * Test delete method with non-existent client. + * + * @covers ::delete + */ + public function test_delete_nonexistent() { + $result = Client::delete( 'nonexistent-client-id' ); + $this->assertFalse( $result ); + } + + /** + * Test get_description method. + * + * @covers ::get_description + */ + public function test_get_description() { + $result = $this->create_client( + array( + 'name' => 'Test Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + 'description' => 'Test client description', + ) + ); + + $client = Client::get( $result['client_id'] ); + + $this->assertEquals( 'Test client description', $client->get_description() ); + } + + /** + * Test get_client_id method. + * + * @covers ::get_client_id + */ + public function test_get_client_id() { + $result = $this->create_client( + array( + 'name' => 'Test Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + ) + ); + + $client = Client::get( $result['client_id'] ); + + $this->assertEquals( $result['client_id'], $client->get_client_id() ); + } +} diff --git a/tests/phpunit/tests/includes/oauth/class-test-scope.php b/tests/phpunit/tests/includes/oauth/class-test-scope.php new file mode 100644 index 0000000000..3a97f4e2b6 --- /dev/null +++ b/tests/phpunit/tests/includes/oauth/class-test-scope.php @@ -0,0 +1,303 @@ +assertEquals( 'read', Scope::READ ); + $this->assertEquals( 'write', Scope::WRITE ); + $this->assertEquals( 'follow', Scope::FOLLOW ); + $this->assertEquals( 'push', Scope::PUSH ); + $this->assertEquals( 'profile', Scope::PROFILE ); + } + + /** + * Test ALL constant contains all scopes. + * + * @covers ::ALL + */ + public function test_all_scopes_constant() { + $this->assertContains( Scope::READ, Scope::ALL ); + $this->assertContains( Scope::WRITE, Scope::ALL ); + $this->assertContains( Scope::FOLLOW, Scope::ALL ); + $this->assertContains( Scope::PUSH, Scope::ALL ); + $this->assertContains( Scope::PROFILE, Scope::ALL ); + $this->assertCount( 5, Scope::ALL ); + } + + /** + * Test parse method with space-separated string. + * + * @covers ::parse + */ + public function test_parse_space_separated() { + $result = Scope::parse( 'read write follow' ); + $this->assertEquals( array( 'read', 'write', 'follow' ), $result ); + } + + /** + * Test parse method with single scope. + * + * @covers ::parse + */ + public function test_parse_single_scope() { + $result = Scope::parse( 'read' ); + $this->assertEquals( array( 'read' ), $result ); + } + + /** + * Test parse method with empty string. + * + * @covers ::parse + */ + public function test_parse_empty_string() { + $result = Scope::parse( '' ); + $this->assertEquals( array(), $result ); + } + + /** + * Test parse method with null. + * + * @covers ::parse + */ + public function test_parse_null() { + $result = Scope::parse( null ); + $this->assertEquals( array(), $result ); + } + + /** + * Test parse method with extra whitespace. + * + * @covers ::parse + */ + public function test_parse_extra_whitespace() { + $result = Scope::parse( ' read write ' ); + $this->assertEquals( array( 'read', 'write' ), $result ); + } + + /** + * Test validate method with valid scopes array. + * + * @covers ::validate + */ + public function test_validate_valid_array() { + $result = Scope::validate( array( 'read', 'write' ) ); + $this->assertEquals( array( 'read', 'write' ), $result ); + } + + /** + * Test validate method with string input. + * + * @covers ::validate + */ + public function test_validate_string_input() { + $result = Scope::validate( 'read write follow' ); + $this->assertEquals( array( 'read', 'write', 'follow' ), $result ); + } + + /** + * Test validate method filters out invalid scopes. + * + * @covers ::validate + */ + public function test_validate_filters_invalid() { + $result = Scope::validate( array( 'read', 'invalid', 'write' ) ); + $this->assertEquals( array( 'read', 'write' ), $result ); + } + + /** + * Test validate method returns defaults for empty input. + * + * @covers ::validate + */ + public function test_validate_empty_returns_defaults() { + $result = Scope::validate( array() ); + $this->assertEquals( Scope::DEFAULT_SCOPES, $result ); + } + + /** + * Test validate method returns defaults for all-invalid input. + * + * @covers ::validate + */ + public function test_validate_all_invalid_returns_defaults() { + $result = Scope::validate( array( 'invalid1', 'invalid2' ) ); + $this->assertEquals( Scope::DEFAULT_SCOPES, $result ); + } + + /** + * Test validate method with non-array input. + * + * @covers ::validate + */ + public function test_validate_non_array_returns_defaults() { + $result = Scope::validate( 123 ); + $this->assertEquals( Scope::DEFAULT_SCOPES, $result ); + } + + /** + * Test to_string method. + * + * @covers ::to_string + */ + public function test_to_string() { + $result = Scope::to_string( array( 'read', 'write', 'follow' ) ); + $this->assertEquals( 'read write follow', $result ); + } + + /** + * Test to_string method with empty array. + * + * @covers ::to_string + */ + public function test_to_string_empty() { + $result = Scope::to_string( array() ); + $this->assertEquals( '', $result ); + } + + /** + * Test to_string method with non-array. + * + * @covers ::to_string + */ + public function test_to_string_non_array() { + $result = Scope::to_string( 'not an array' ); + $this->assertEquals( '', $result ); + } + + /** + * Test is_valid method with valid scope. + * + * @covers ::is_valid + */ + public function test_is_valid_true() { + $this->assertTrue( Scope::is_valid( 'read' ) ); + $this->assertTrue( Scope::is_valid( 'write' ) ); + $this->assertTrue( Scope::is_valid( 'follow' ) ); + $this->assertTrue( Scope::is_valid( 'push' ) ); + $this->assertTrue( Scope::is_valid( 'profile' ) ); + } + + /** + * Test is_valid method with invalid scope. + * + * @covers ::is_valid + */ + public function test_is_valid_false() { + $this->assertFalse( Scope::is_valid( 'invalid' ) ); + $this->assertFalse( Scope::is_valid( '' ) ); + $this->assertFalse( Scope::is_valid( 'READ' ) ); // Case sensitive. + } + + /** + * Test get_description method. + * + * @covers ::get_description + */ + public function test_get_description() { + $this->assertNotEmpty( Scope::get_description( 'read' ) ); + $this->assertNotEmpty( Scope::get_description( 'write' ) ); + } + + /** + * Test get_description method with invalid scope. + * + * @covers ::get_description + */ + public function test_get_description_invalid() { + $this->assertEquals( '', Scope::get_description( 'invalid' ) ); + } + + /** + * Test get_all_with_descriptions method. + * + * @covers ::get_all_with_descriptions + */ + public function test_get_all_with_descriptions() { + $result = Scope::get_all_with_descriptions(); + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'read', $result ); + $this->assertArrayHasKey( 'write', $result ); + $this->assertArrayHasKey( 'follow', $result ); + $this->assertArrayHasKey( 'push', $result ); + $this->assertArrayHasKey( 'profile', $result ); + } + + /** + * Test contains method with scope present. + * + * @covers ::contains + */ + public function test_contains_true() { + $scopes = array( 'read', 'write' ); + $this->assertTrue( Scope::contains( $scopes, 'read' ) ); + $this->assertTrue( Scope::contains( $scopes, 'write' ) ); + } + + /** + * Test contains method with scope not present. + * + * @covers ::contains + */ + public function test_contains_false() { + $scopes = array( 'read', 'write' ); + $this->assertFalse( Scope::contains( $scopes, 'follow' ) ); + } + + /** + * Test contains method with non-array. + * + * @covers ::contains + */ + public function test_contains_non_array() { + $this->assertFalse( Scope::contains( 'not an array', 'read' ) ); + } + + /** + * Test sanitize method with string. + * + * @covers ::sanitize + */ + public function test_sanitize_string() { + $result = Scope::sanitize( 'read write invalid' ); + $this->assertEquals( array( 'read', 'write' ), $result ); + } + + /** + * Test sanitize method with array. + * + * @covers ::sanitize + */ + public function test_sanitize_array() { + $result = Scope::sanitize( array( 'read', 'invalid', 'write' ) ); + $this->assertEquals( array( 'read', 'write' ), $result ); + } + + /** + * Test sanitize method with non-array/non-string. + * + * @covers ::sanitize + */ + public function test_sanitize_invalid_type() { + $result = Scope::sanitize( 123 ); + $this->assertEquals( array(), $result ); + } +} diff --git a/tests/phpunit/tests/includes/oauth/class-test-token.php b/tests/phpunit/tests/includes/oauth/class-test-token.php new file mode 100644 index 0000000000..7428872d4d --- /dev/null +++ b/tests/phpunit/tests/includes/oauth/class-test-token.php @@ -0,0 +1,353 @@ +user_id = $this->factory->user->create( + array( + 'role' => 'editor', + ) + ); + + // Create a test client. + $client_result = Client::register( + array( + 'name' => 'Test Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + ) + ); + $this->client_id = $client_result['client_id']; + } + + /** + * Tear down the test. + */ + public function tear_down() { + // Clean up clients and tokens. + if ( $this->client_id ) { + Client::delete( $this->client_id ); + } + + parent::tear_down(); + } + + /** + * Test generate_token produces a hex string. + * + * @covers ::generate_token + */ + public function test_generate_token() { + $token = Token::generate_token(); + + // Default length is 32 bytes = 64 hex chars. + $this->assertEquals( 64, strlen( $token ) ); + $this->assertMatchesRegularExpression( '/^[0-9a-f]+$/', $token ); + } + + /** + * Test generate_token with custom length. + * + * @covers ::generate_token + */ + public function test_generate_token_custom_length() { + $token = Token::generate_token( 16 ); + $this->assertEquals( 32, strlen( $token ) ); + } + + /** + * Test hash_token produces SHA-256 hash. + * + * @covers ::hash_token + */ + public function test_hash_token() { + $token = 'test_token_value'; + $hash = Token::hash_token( $token ); + + // SHA-256 produces 64 hex chars. + $this->assertEquals( 64, strlen( $hash ) ); + $this->assertEquals( hash( 'sha256', $token ), $hash ); + } + + /** + * Test create method returns token data. + * + * @covers ::create + */ + public function test_create() { + $scopes = array( Scope::READ, Scope::WRITE ); + $result = Token::create( $this->user_id, $this->client_id, $scopes ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'access_token', $result ); + $this->assertArrayHasKey( 'token_type', $result ); + $this->assertArrayHasKey( 'expires_in', $result ); + $this->assertArrayHasKey( 'refresh_token', $result ); + $this->assertArrayHasKey( 'scope', $result ); + + $this->assertEquals( 'Bearer', $result['token_type'] ); + $this->assertEquals( Token::DEFAULT_EXPIRATION, $result['expires_in'] ); + $this->assertEquals( 'read write', $result['scope'] ); + } + + /** + * Test validate method with valid token. + * + * @covers ::validate + */ + public function test_validate_valid_token() { + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + $token = Token::validate( $result['access_token'] ); + + $this->assertInstanceOf( Token::class, $token ); + $this->assertEquals( $this->user_id, $token->get_user_id() ); + $this->assertEquals( $this->client_id, $token->get_client_id() ); + } + + /** + * Test validate method with invalid token. + * + * @covers ::validate + */ + public function test_validate_invalid_token() { + $result = Token::validate( 'invalid_token_value' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_invalid_token', $result->get_error_code() ); + } + + /** + * Test validate method with expired token. + * + * @covers ::validate + */ + public function test_validate_expired_token() { + // Create a token that expires immediately. + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ), 0 ); + + // Wait a moment for expiration. + sleep( 1 ); + + $validation = Token::validate( $result['access_token'] ); + + $this->assertInstanceOf( \WP_Error::class, $validation ); + $this->assertEquals( 'activitypub_token_expired', $validation->get_error_code() ); + } + + /** + * Test token has_scope method. + * + * @covers ::has_scope + */ + public function test_has_scope() { + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ, Scope::WRITE ) ); + $token = Token::validate( $result['access_token'] ); + + $this->assertTrue( $token->has_scope( Scope::READ ) ); + $this->assertTrue( $token->has_scope( Scope::WRITE ) ); + $this->assertFalse( $token->has_scope( Scope::FOLLOW ) ); + } + + /** + * Test token get_scopes method. + * + * @covers ::get_scopes + */ + public function test_get_scopes() { + $scopes = array( Scope::READ, Scope::WRITE ); + $result = Token::create( $this->user_id, $this->client_id, $scopes ); + $token = Token::validate( $result['access_token'] ); + + $this->assertEquals( $scopes, $token->get_scopes() ); + } + + /** + * Test token get_expires_at method. + * + * @covers ::get_expires_at + */ + public function test_get_expires_at() { + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + $token = Token::validate( $result['access_token'] ); + + $expires_at = $token->get_expires_at(); + $this->assertIsInt( $expires_at ); + $this->assertGreaterThan( time(), $expires_at ); + } + + /** + * Test token is_expired method. + * + * @covers ::is_expired + */ + public function test_is_expired() { + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + $token = Token::validate( $result['access_token'] ); + + $this->assertFalse( $token->is_expired() ); + } + + /** + * Test refresh method. + * + * @covers ::refresh + */ + public function test_refresh() { + $original = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + $result = Token::refresh( $original['refresh_token'], $this->client_id ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'access_token', $result ); + $this->assertArrayHasKey( 'refresh_token', $result ); + + // New tokens should be different. + $this->assertNotEquals( $original['access_token'], $result['access_token'] ); + $this->assertNotEquals( $original['refresh_token'], $result['refresh_token'] ); + + // Old token should be revoked. + $old_validation = Token::validate( $original['access_token'] ); + $this->assertInstanceOf( \WP_Error::class, $old_validation ); + } + + /** + * Test refresh method with invalid refresh token. + * + * @covers ::refresh + */ + public function test_refresh_invalid_token() { + $result = Token::refresh( 'invalid_refresh_token', $this->client_id ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_invalid_refresh_token', $result->get_error_code() ); + } + + /** + * Test refresh method with wrong client ID. + * + * @covers ::refresh + */ + public function test_refresh_wrong_client() { + $original = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + $result = Token::refresh( $original['refresh_token'], 'wrong_client_id' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_invalid_refresh_token', $result->get_error_code() ); + } + + /** + * Test revoke method with access token. + * + * @covers ::revoke + */ + public function test_revoke_access_token() { + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + + $revoke_result = Token::revoke( $result['access_token'] ); + $this->assertTrue( $revoke_result ); + + // Token should no longer validate. + $validation = Token::validate( $result['access_token'] ); + $this->assertInstanceOf( \WP_Error::class, $validation ); + } + + /** + * Test revoke method with refresh token. + * + * @covers ::revoke + */ + public function test_revoke_refresh_token() { + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + + $revoke_result = Token::revoke( $result['refresh_token'] ); + $this->assertTrue( $revoke_result ); + + // Refresh should fail. + $refresh_result = Token::refresh( $result['refresh_token'], $this->client_id ); + $this->assertInstanceOf( \WP_Error::class, $refresh_result ); + } + + /** + * Test revoke method with non-existent token. + * + * @covers ::revoke + */ + public function test_revoke_nonexistent_token() { + // Per RFC 7009, revoking a non-existent token should succeed. + $result = Token::revoke( 'nonexistent_token' ); + $this->assertTrue( $result ); + } + + /** + * Test revoke_all_for_user method. + * + * @covers ::revoke_all_for_user + */ + public function test_revoke_all_for_user() { + // Create multiple tokens. + $token1 = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + $token2 = Token::create( $this->user_id, $this->client_id, array( Scope::WRITE ) ); + $token3 = Token::create( $this->user_id, $this->client_id, array( Scope::FOLLOW ) ); + + $count = Token::revoke_all_for_user( $this->user_id ); + $this->assertEquals( 3, $count ); + + // All tokens should be revoked. + $this->assertInstanceOf( \WP_Error::class, Token::validate( $token1['access_token'] ) ); + $this->assertInstanceOf( \WP_Error::class, Token::validate( $token2['access_token'] ) ); + $this->assertInstanceOf( \WP_Error::class, Token::validate( $token3['access_token'] ) ); + } + + /** + * Test cleanup_expired method. + * + * @covers ::cleanup_expired + */ + public function test_cleanup_expired() { + // Create a token that expires immediately. + Token::create( $this->user_id, $this->client_id, array( Scope::READ ), 0 ); + + // Wait for expiration plus grace period buffer (normally 1 day, but we can't wait that long). + // For this test, we'll just verify the method runs without error. + $count = Token::cleanup_expired(); + $this->assertIsInt( $count ); + } +} From d686406832c2b4e702bfb673fb2910bb9f63a262 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 09:31:19 +0100 Subject: [PATCH 005/105] Fix method signature compatibility with parent class Remove type hint from get_items_permissions_check() to match the parent WP_REST_Controller class signature, which doesn't use type hints. --- includes/rest/class-inbox-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 652d2d5fcc..2c3bc5cecd 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -216,7 +216,7 @@ public function validate_user_id( $user_id ) { * @param \WP_REST_Request $request Full details about the request. * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ - public function get_items_permissions_check( \WP_REST_Request $request ) { + public function get_items_permissions_check( $request ) { // Check if C2S is enabled. if ( ! OAuth_Server::is_c2s_enabled() ) { return new \WP_Error( From c428161b361cdcf32e03d5d9349532b05f2890c3 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 09:34:30 +0100 Subject: [PATCH 006/105] Fix create_item_permissions_check method signature Remove type hint from create_item_permissions_check() to match the parent WP_REST_Controller class signature. --- includes/rest/class-outbox-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 22644d0223..c876fa58c7 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -334,7 +334,7 @@ public function overload_total_items( $response, $request ) { * @param \WP_REST_Request $request Full details about the request. * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ - public function create_item_permissions_check( \WP_REST_Request $request ) { + public function create_item_permissions_check( $request ) { // Check if C2S is enabled. if ( ! OAuth_Server::is_c2s_enabled() ) { return new \WP_Error( From 8a584a1138d9d2705a11770f80edd620a05a58af Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 09:38:17 +0100 Subject: [PATCH 007/105] Fix create_item method signature compatibility Remove type hint from create_item() to match the parent WP_REST_Controller class signature. --- includes/rest/class-outbox-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index c876fa58c7..b5d7b65f90 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -375,7 +375,7 @@ public function create_item_permissions_check( $request ) { * @param \WP_REST_Request $request Full details about the request. * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error on failure. */ - public function create_item( \WP_REST_Request $request ) { + public function create_item( $request ) { $user_id = $request->get_param( 'user_id' ); $user = Actors::get_by_id( $user_id ); $data = $request->get_json_params(); From 7906d5120edf306e4e717f8dc7a42a85be50ace1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 09:42:20 +0100 Subject: [PATCH 008/105] Remove invalid @covers annotations for constants Constants cannot be covered by PHPUnit, only methods can. --- tests/phpunit/tests/includes/oauth/class-test-scope.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/phpunit/tests/includes/oauth/class-test-scope.php b/tests/phpunit/tests/includes/oauth/class-test-scope.php index 3a97f4e2b6..6a4111951a 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-scope.php +++ b/tests/phpunit/tests/includes/oauth/class-test-scope.php @@ -18,8 +18,6 @@ class Test_Scope extends \WP_UnitTestCase { /** * Test that all scope constants are defined. - * - * @covers ::ALL */ public function test_scope_constants_defined() { $this->assertEquals( 'read', Scope::READ ); @@ -31,8 +29,6 @@ public function test_scope_constants_defined() { /** * Test ALL constant contains all scopes. - * - * @covers ::ALL */ public function test_all_scopes_constant() { $this->assertContains( Scope::READ, Scope::ALL ); From c498877a911c398c1589d473670a05dd687423f5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 09:48:06 +0100 Subject: [PATCH 009/105] Add actor ownership validation for C2S outbox Validate that submitted activities have actor/attributedTo fields matching the authenticated user. This prevents clients from submitting activities with mismatched actor data. Checks: - activity.actor must match authenticated user (if present) - object.attributedTo must match authenticated user (if present) --- includes/rest/class-outbox-controller.php | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index b5d7b65f90..cf84aa98f0 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -388,6 +388,12 @@ public function create_item( $request ) { ); } + // Validate ownership - ensure submitted actor matches authenticated user. + $ownership_validation = $this->validate_ownership( $data, $user ); + if ( \is_wp_error( $ownership_validation ) ) { + return $ownership_validation; + } + // Determine if this is an Activity or a bare Object. $type = $data['type'] ?? ''; $is_activity = in_array( $type, Activity::TYPES, true ); @@ -500,6 +506,54 @@ private function wrap_in_create( $object_data, $user ) { ); } + /** + * Validate that activity actor matches the authenticated user. + * + * Ensures clients cannot submit activities with mismatched actor data. + * + * @param array $data The activity or object data. + * @param \Activitypub\Model\User|null $user The authenticated user. + * @return true|\WP_Error True if valid, WP_Error otherwise. + */ + private function validate_ownership( $data, $user ) { + if ( ! $user ) { + return new \WP_Error( + 'activitypub_invalid_user', + \__( 'Invalid user.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + $user_actor_id = $user->get_id(); + + // Check activity actor if present. + if ( ! empty( $data['actor'] ) ) { + $actor_id = object_to_uri( $data['actor'] ); + if ( $actor_id && $actor_id !== $user_actor_id ) { + return new \WP_Error( + 'activitypub_actor_mismatch', + \__( 'Activity actor does not match authenticated user.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + } + + // Check object.attributedTo if present. + $object = $data['object'] ?? $data; + if ( is_array( $object ) && ! empty( $object['attributedTo'] ) ) { + $attributed_to = object_to_uri( $object['attributedTo'] ); + if ( $attributed_to && $attributed_to !== $user_actor_id ) { + return new \WP_Error( + 'activitypub_attribution_mismatch', + \__( 'Object attributedTo does not match authenticated user.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + } + + return true; + } + /** * Determine content visibility from activity addressing. * From 5155bf479ffc03f3c120fdf4011e49a6585cc534 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 15:02:43 +0100 Subject: [PATCH 010/105] Migrate OAuth storage to transients and user meta - Authorization codes now use WordPress transients (auto-expire after 10 min) - Tokens now use user meta instead of CPT (efficient per-user lookup) - Keep only Client CPT for persistent client registration - Add token introspection endpoint (RFC 7662) - Add revoke_for_client() method for cleanup when deleting clients - Add OAuth consent form template - Fix linting issues in Server class - Update tests for new error codes --- activitypub.php | 7 +- includes/class-post-types.php | 183 +----- includes/oauth/class-authorization-code.php | 196 +++--- includes/oauth/class-client.php | 262 +++++++- includes/oauth/class-server.php | 162 +++++ includes/oauth/class-token.php | 570 ++++++++++++------ includes/rest/class-oauth-controller.php | 87 ++- includes/rest/class-outbox-controller.php | 40 ++ templates/oauth-authorize.php | 112 ++++ .../oauth/class-test-authorization-code.php | 2 +- .../tests/includes/oauth/class-test-token.php | 2 +- 11 files changed, 1080 insertions(+), 543 deletions(-) create mode 100644 templates/oauth-authorize.php diff --git a/activitypub.php b/activitypub.php index 91c129c596..2f722ae9d2 100644 --- a/activitypub.php +++ b/activitypub.php @@ -70,8 +70,7 @@ function rest_init() { ( new Rest\Nodeinfo_Controller() )->register_routes(); } - // Load OAuth endpoints if C2S is enabled. - OAuth\Server::init(); + // Load OAuth REST endpoints. ( new Rest\OAuth_Controller() )->register_routes(); } \add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' ); @@ -100,6 +99,7 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ), 0 ); \add_action( 'init', array( __NAMESPACE__ . '\Search', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Signature', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\OAuth\Server', 'init' ) ); if ( site_supports_blocks() ) { \add_action( 'init', array( __NAMESPACE__ . '\Blocks', 'init' ) ); @@ -177,3 +177,6 @@ function activation_redirect( $plugin ) { ) ); } + +// Register OAuth login form handler early (before wp-login.php processes). +\add_action( 'login_form_activitypub_authorize', array( __NAMESPACE__ . '\OAuth\Server', 'login_form_authorize' ) ); diff --git a/includes/class-post-types.php b/includes/class-post-types.php index 52c375f9ab..9a16ccb678 100644 --- a/includes/class-post-types.php +++ b/includes/class-post-types.php @@ -15,10 +15,8 @@ use Activitypub\Collection\Outbox; use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Actors; -use Activitypub\OAuth\Authorization_Code; use Activitypub\OAuth\Client; use Activitypub\OAuth\Scope; -use Activitypub\OAuth\Token; /** * Post Types class. @@ -464,85 +462,10 @@ public static function register_extra_fields_post_types() { /** * Register OAuth 2.0 post types for C2S support. * - * Registers post types for OAuth tokens, clients, and authorization codes. + * Registers post type for OAuth clients. + * Note: Tokens are stored in user meta and authorization codes in transients. */ public static function register_oauth_post_types() { - // OAuth Tokens post type. - \register_post_type( - Token::POST_TYPE, - array( - 'labels' => array( - 'name' => \_x( 'OAuth Tokens', 'post_type plural name', 'activitypub' ), - 'singular_name' => \_x( 'OAuth Token', 'post_type single name', 'activitypub' ), - ), - 'public' => false, - 'show_in_rest' => false, - 'hierarchical' => false, - 'rewrite' => false, - 'query_var' => false, - 'delete_with_user' => true, - 'can_export' => false, - 'supports' => array( 'author', 'custom-fields' ), - 'exclude_from_search' => true, - ) - ); - - // OAuth Token meta. - \register_post_meta( - Token::POST_TYPE, - '_activitypub_access_token_hash', - array( - 'type' => 'string', - 'single' => true, - 'description' => 'SHA-256 hash of the access token.', - 'sanitize_callback' => 'sanitize_text_field', - ) - ); - - \register_post_meta( - Token::POST_TYPE, - '_activitypub_refresh_token_hash', - array( - 'type' => 'string', - 'single' => true, - 'description' => 'SHA-256 hash of the refresh token.', - 'sanitize_callback' => 'sanitize_text_field', - ) - ); - - \register_post_meta( - Token::POST_TYPE, - '_activitypub_client_id', - array( - 'type' => 'string', - 'single' => true, - 'description' => 'The OAuth client ID associated with this token.', - 'sanitize_callback' => 'sanitize_text_field', - ) - ); - - \register_post_meta( - Token::POST_TYPE, - '_activitypub_scopes', - array( - 'type' => 'array', - 'single' => true, - 'description' => 'Granted OAuth scopes.', - 'sanitize_callback' => array( Scope::class, 'sanitize' ), - ) - ); - - \register_post_meta( - Token::POST_TYPE, - '_activitypub_expires_at', - array( - 'type' => 'integer', - 'single' => true, - 'description' => 'Unix timestamp when the access token expires.', - 'sanitize_callback' => 'absint', - ) - ); - // OAuth Clients post type. \register_post_type( Client::POST_TYPE, @@ -624,108 +547,6 @@ public static function register_oauth_post_types() { 'default' => true, ) ); - - // OAuth Authorization Codes post type. - \register_post_type( - Authorization_Code::POST_TYPE, - array( - 'labels' => array( - 'name' => \_x( 'OAuth Codes', 'post_type plural name', 'activitypub' ), - 'singular_name' => \_x( 'OAuth Code', 'post_type single name', 'activitypub' ), - ), - 'public' => false, - 'show_in_rest' => false, - 'hierarchical' => false, - 'rewrite' => false, - 'query_var' => false, - 'delete_with_user' => true, - 'can_export' => false, - 'supports' => array( 'author', 'custom-fields' ), - 'exclude_from_search' => true, - ) - ); - - // OAuth Authorization Code meta. - \register_post_meta( - Authorization_Code::POST_TYPE, - '_activitypub_code_hash', - array( - 'type' => 'string', - 'single' => true, - 'description' => 'SHA-256 hash of the authorization code.', - 'sanitize_callback' => 'sanitize_text_field', - ) - ); - - \register_post_meta( - Authorization_Code::POST_TYPE, - '_activitypub_client_id', - array( - 'type' => 'string', - 'single' => true, - 'description' => 'The OAuth client ID that requested this code.', - 'sanitize_callback' => 'sanitize_text_field', - ) - ); - - \register_post_meta( - Authorization_Code::POST_TYPE, - '_activitypub_redirect_uri', - array( - 'type' => 'string', - 'single' => true, - 'description' => 'The redirect URI used for this authorization.', - 'sanitize_callback' => 'sanitize_url', - ) - ); - - \register_post_meta( - Authorization_Code::POST_TYPE, - '_activitypub_scopes', - array( - 'type' => 'array', - 'single' => true, - 'description' => 'Requested OAuth scopes.', - 'sanitize_callback' => array( Scope::class, 'sanitize' ), - ) - ); - - \register_post_meta( - Authorization_Code::POST_TYPE, - '_activitypub_code_challenge', - array( - 'type' => 'string', - 'single' => true, - 'description' => 'PKCE code challenge.', - 'sanitize_callback' => 'sanitize_text_field', - ) - ); - - \register_post_meta( - Authorization_Code::POST_TYPE, - '_activitypub_code_challenge_method', - array( - 'type' => 'string', - 'single' => true, - 'description' => 'PKCE code challenge method (S256 or plain).', - 'sanitize_callback' => static function ( $value ) { - $allowed = array( 'S256', 'plain' ); - return in_array( $value, $allowed, true ) ? $value : 'S256'; - }, - 'default' => 'S256', - ) - ); - - \register_post_meta( - Authorization_Code::POST_TYPE, - '_activitypub_expires_at', - array( - 'type' => 'integer', - 'single' => true, - 'description' => 'Unix timestamp when the authorization code expires (10 minutes).', - 'sanitize_callback' => 'absint', - ) - ); } /** diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index da927eee67..829534af20 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -10,45 +10,20 @@ /** * Authorization_Code class for managing OAuth 2.0 authorization codes. * - * Authorization codes are short-lived (10 minutes) and support PKCE. + * Authorization codes are short-lived (10 minutes) and stored as transients. + * This is more efficient than CPT for temporary data. */ class Authorization_Code { /** - * Post type for OAuth authorization codes. + * Transient prefix for authorization codes. */ - const POST_TYPE = 'ap_oauth_code'; - - /** - * Post status for pending (unused) codes. - */ - const STATUS_PENDING = 'pending'; - - /** - * Post status for used codes. - */ - const STATUS_USED = 'draft'; + const TRANSIENT_PREFIX = 'activitypub_oauth_code_'; /** * Authorization code expiration in seconds (10 minutes). */ const EXPIRATION = 600; - /** - * The post ID of the authorization code. - * - * @var int - */ - private $post_id; - - /** - * Constructor. - * - * @param int $post_id The post ID of the authorization code. - */ - public function __construct( $post_id ) { - $this->post_id = $post_id; - } - /** * Create a new authorization code. * @@ -88,34 +63,33 @@ public static function create( // Generate the code. $code = self::generate_code(); + $code_hash = self::hash_code( $code ); $expires_at = time() + self::EXPIRATION; - // Create the authorization code post. - $post_id = \wp_insert_post( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => self::STATUS_PENDING, - 'post_author' => $user_id, - 'post_title' => sprintf( - /* translators: %1$s: client ID */ - \__( 'Auth code for %1$s', 'activitypub' ), - $client_id - ), - 'meta_input' => array( - '_activitypub_code_hash' => Token::hash_token( $code ), - '_activitypub_client_id' => $client_id, - '_activitypub_redirect_uri' => $redirect_uri, - '_activitypub_scopes' => $filtered_scopes, - '_activitypub_code_challenge' => $code_challenge, - '_activitypub_code_challenge_method' => $code_challenge_method, - '_activitypub_expires_at' => $expires_at, - ), - ), - true + // Store code data in transient. + $code_data = array( + 'user_id' => $user_id, + 'client_id' => $client_id, + 'redirect_uri' => $redirect_uri, + 'scopes' => $filtered_scopes, + 'code_challenge' => $code_challenge, + 'code_challenge_method' => $code_challenge_method, + 'expires_at' => $expires_at, + 'created_at' => time(), + ); + + $stored = \set_transient( + self::TRANSIENT_PREFIX . $code_hash, + $code_data, + self::EXPIRATION ); - if ( \is_wp_error( $post_id ) ) { - return $post_id; + if ( ! $stored ) { + return new \WP_Error( + 'activitypub_code_storage_failed', + \__( 'Failed to store authorization code.', 'activitypub' ), + array( 'status' => 500 ) + ); } return $code; @@ -131,49 +105,23 @@ public static function create( * @return array|\WP_Error Token data or error. */ public static function exchange( $code, $client_id, $redirect_uri, $code_verifier ) { - $hash = Token::hash_token( $code ); + $code_hash = self::hash_code( $code ); + $transient = self::TRANSIENT_PREFIX . $code_hash; + $code_data = \get_transient( $transient ); - // Find the authorization code. - $posts = \get_posts( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => self::STATUS_PENDING, - 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'relation' => 'AND', - array( - 'key' => '_activitypub_code_hash', - 'value' => $hash, - ), - array( - 'key' => '_activitypub_client_id', - 'value' => $client_id, - ), - ), - 'numberposts' => 1, - ) - ); - - if ( empty( $posts ) ) { + if ( false === $code_data ) { return new \WP_Error( 'activitypub_invalid_code', - \__( 'Invalid authorization code.', 'activitypub' ), + \__( 'Invalid or expired authorization code.', 'activitypub' ), array( 'status' => 400 ) ); } - $post = $posts[0]; - - // Check expiration. - $expires_at = (int) \get_post_meta( $post->ID, '_activitypub_expires_at', true ); - if ( $expires_at < time() ) { - // Mark as used to prevent further attempts. - \wp_update_post( - array( - 'ID' => $post->ID, - 'post_status' => self::STATUS_USED, - ) - ); + // Immediately delete the code (single use). + \delete_transient( $transient ); + // Check expiration (belt and suspenders - transient should auto-expire). + if ( isset( $code_data['expires_at'] ) && $code_data['expires_at'] < time() ) { return new \WP_Error( 'activitypub_code_expired', \__( 'Authorization code has expired.', 'activitypub' ), @@ -181,9 +129,17 @@ public static function exchange( $code, $client_id, $redirect_uri, $code_verifie ); } + // Verify client ID matches. + if ( $code_data['client_id'] !== $client_id ) { + return new \WP_Error( + 'activitypub_client_mismatch', + \__( 'Client ID does not match.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + // Verify redirect URI matches. - $stored_redirect_uri = \get_post_meta( $post->ID, '_activitypub_redirect_uri', true ); - if ( $redirect_uri !== $stored_redirect_uri ) { + if ( $code_data['redirect_uri'] !== $redirect_uri ) { return new \WP_Error( 'activitypub_redirect_uri_mismatch', \__( 'Redirect URI does not match.', 'activitypub' ), @@ -192,8 +148,8 @@ public static function exchange( $code, $client_id, $redirect_uri, $code_verifie } // Verify PKCE. - $code_challenge = \get_post_meta( $post->ID, '_activitypub_code_challenge', true ); - $code_challenge_method = \get_post_meta( $post->ID, '_activitypub_code_challenge_method', true ) ?: 'S256'; + $code_challenge = $code_data['code_challenge'] ?? ''; + $code_challenge_method = $code_data['code_challenge_method'] ?? 'S256'; if ( ! self::verify_pkce( $code_verifier, $code_challenge, $code_challenge_method ) ) { return new \WP_Error( @@ -203,20 +159,12 @@ public static function exchange( $code, $client_id, $redirect_uri, $code_verifie ); } - // Mark the code as used (single use). - \wp_update_post( - array( - 'ID' => $post->ID, - 'post_status' => self::STATUS_USED, - ) - ); - - // Get the user and scopes. - $user_id = $post->post_author; - $scopes = \get_post_meta( $post->ID, '_activitypub_scopes', true ); - // Create and return the tokens. - return Token::create( $user_id, $client_id, $scopes ); + return Token::create( + $code_data['user_id'], + $client_id, + $code_data['scopes'] + ); } /** @@ -260,12 +208,23 @@ public static function compute_code_challenge( $code_verifier ) { * @return string The authorization code. */ public static function generate_code() { - return Token::generate_token( 32 ); + return bin2hex( random_bytes( 32 ) ); + } + + /** + * Hash an authorization code for storage lookup. + * + * @param string $code The authorization code. + * @return string The SHA-256 hash. + */ + public static function hash_code( $code ) { + return hash( 'sha256', $code ); } /** * Clean up expired authorization codes. * + * Note: Transients auto-expire, but this cleans up any orphaned ones. * Should be called periodically via cron. * * @return int Number of codes deleted. @@ -273,25 +232,18 @@ public static function generate_code() { public static function cleanup() { global $wpdb; - $expired_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + // Clean up expired transients with our prefix. + // Transients should auto-expire, but this catches edge cases. + $count = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( - "SELECT 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_expires_at' - AND pm.meta_value < %d", - self::POST_TYPE, - time() + "DELETE FROM {$wpdb->options} + WHERE option_name LIKE %s + AND option_name LIKE %s", + $wpdb->esc_like( '_transient_' . self::TRANSIENT_PREFIX ) . '%', + $wpdb->esc_like( '_transient_timeout_' . self::TRANSIENT_PREFIX ) . '%' ) ); - $count = 0; - foreach ( $expired_ids as $post_id ) { - if ( \wp_delete_post( $post_id, true ) ) { - ++$count; - } - } - - return $count; + return $count ? (int) ( $count / 2 ) : 0; // Each transient has 2 rows. } } diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index c09628727a..3625c3de9b 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -125,6 +125,9 @@ public static function register( $data ) { /** * Get client by client_id. * + * Supports auto-discovery: if client_id is a URL and not found locally, + * fetches the Client ID Metadata Document (CIMD) and auto-registers. + * * @param string $client_id The client ID. * @return Client|\WP_Error The client or error. */ @@ -141,15 +144,198 @@ public static function get( $client_id ) { ); // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value - if ( empty( $posts ) ) { + if ( ! empty( $posts ) ) { + return new self( $posts[0]->ID ); + } + + // If client_id is a URL, try auto-discovery. + if ( filter_var( $client_id, FILTER_VALIDATE_URL ) ) { + return self::discover_and_register( $client_id ); + } + + return new \WP_Error( + 'activitypub_client_not_found', + \__( 'OAuth client not found.', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + /** + * Discover client metadata from URL and auto-register. + * + * Fetches the Client ID Metadata Document (CIMD) from the client_id URL. + * + * @param string $client_id The client ID URL. + * @return Client|\WP_Error The client or error. + */ + private static function discover_and_register( $client_id ) { + $metadata = self::fetch_client_metadata( $client_id ); + + if ( \is_wp_error( $metadata ) ) { + return $metadata; + } + + // Validate client_id matches. + if ( ! empty( $metadata['client_id'] ) && $metadata['client_id'] !== $client_id ) { + return new \WP_Error( + 'activitypub_client_id_mismatch', + \__( 'Client ID in metadata does not match request.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Get redirect URIs from metadata or derive from client_id origin. + $redirect_uris = array(); + if ( ! empty( $metadata['redirect_uris'] ) && is_array( $metadata['redirect_uris'] ) ) { + $redirect_uris = $metadata['redirect_uris']; + } + + // Register the discovered client. + $name = ! empty( $metadata['client_name'] ) ? $metadata['client_name'] : $client_id; + + $post_id = \wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $name, + 'post_content' => '', + 'meta_input' => array( + '_activitypub_client_id' => $client_id, + '_activitypub_client_secret_hash' => '', // Public client. + '_activitypub_redirect_uris' => array_map( 'sanitize_url', $redirect_uris ), + '_activitypub_allowed_scopes' => Scope::ALL, + '_activitypub_is_public' => true, + '_activitypub_discovered' => true, + '_activitypub_logo_uri' => ! empty( $metadata['logo_uri'] ) ? \sanitize_url( $metadata['logo_uri'] ) : '', + '_activitypub_client_uri' => ! empty( $metadata['client_uri'] ) ? \sanitize_url( $metadata['client_uri'] ) : '', + ), + ), + true + ); + + if ( \is_wp_error( $post_id ) ) { + return $post_id; + } + + return new self( $post_id ); + } + + /** + * Fetch client metadata from URL. + * + * Supports both CIMD JSON format and ActivityPub Application objects. + * + * @param string $url The client ID URL to fetch. + * @return array|\WP_Error Metadata array or error. + */ + private static function fetch_client_metadata( $url ) { + $response = \wp_safe_remote_get( + $url, + array( + 'timeout' => 10, + 'headers' => array( + 'Accept' => 'application/json, application/ld+json, application/activity+json', + ), + 'redirection' => 3, + ) + ); + + if ( \is_wp_error( $response ) ) { + return new \WP_Error( + 'activitypub_client_fetch_failed', + \__( 'Failed to fetch client metadata.', 'activitypub' ), + array( 'status' => 502 ) + ); + } + + $code = \wp_remote_retrieve_response_code( $response ); + if ( 200 !== $code ) { return new \WP_Error( - 'activitypub_client_not_found', - \__( 'OAuth client not found.', 'activitypub' ), - array( 'status' => 404 ) + 'activitypub_client_fetch_failed', + /* translators: %d: HTTP status code */ + sprintf( \__( 'Client metadata returned HTTP %d.', 'activitypub' ), $code ), + array( 'status' => 502 ) ); } - return new self( $posts[0]->ID ); + $body = \wp_remote_retrieve_body( $response ); + $data = \json_decode( $body, true ); + + if ( ! is_array( $data ) ) { + return new \WP_Error( + 'activitypub_client_invalid_metadata', + \__( 'Invalid client metadata format.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Normalize ActivityPub Application format to CIMD format. + return self::normalize_client_metadata( $data, $url ); + } + + /** + * Normalize client metadata from various formats to standard format. + * + * Supports: + * - CIMD (Client ID Metadata Document) + * - ActivityPub Application objects + * + * @param array $data The raw metadata. + * @param string $url The client ID URL. + * @return array Normalized metadata. + */ + private static function normalize_client_metadata( $data, $url ) { + $metadata = array( + 'client_id' => $url, + 'client_name' => '', + 'redirect_uris' => array(), + 'logo_uri' => '', + 'client_uri' => '', + ); + + // CIMD format fields. + if ( ! empty( $data['client_id'] ) ) { + $metadata['client_id'] = $data['client_id']; + } + if ( ! empty( $data['client_name'] ) ) { + $metadata['client_name'] = $data['client_name']; + } + if ( ! empty( $data['redirect_uris'] ) ) { + $metadata['redirect_uris'] = (array) $data['redirect_uris']; + } + if ( ! empty( $data['logo_uri'] ) ) { + $metadata['logo_uri'] = $data['logo_uri']; + } + if ( ! empty( $data['client_uri'] ) ) { + $metadata['client_uri'] = $data['client_uri']; + } + + // ActivityPub Application format fields. + if ( ! empty( $data['type'] ) && 'Application' === $data['type'] ) { + if ( ! empty( $data['id'] ) ) { + $metadata['client_id'] = $data['id']; + } + if ( ! empty( $data['name'] ) ) { + $metadata['client_name'] = $data['name']; + } + // Handle redirectURI (singular) as used by ap CLI. + if ( ! empty( $data['redirectURI'] ) ) { + $metadata['redirect_uris'] = (array) $data['redirectURI']; + } + // Handle icon object. + if ( ! empty( $data['icon'] ) ) { + if ( is_string( $data['icon'] ) ) { + $metadata['logo_uri'] = $data['icon']; + } elseif ( is_array( $data['icon'] ) && ! empty( $data['icon']['url'] ) ) { + $metadata['logo_uri'] = $data['icon']['url']; + } + } + if ( ! empty( $data['url'] ) ) { + $metadata['client_uri'] = is_array( $data['url'] ) ? $data['url'][0] : $data['url']; + } + } + + return $metadata; } /** @@ -184,14 +370,31 @@ public static function validate( $client_id, $client_secret = null ) { /** * Check if redirect URI is valid for this client. * + * If explicit redirect_uris are registered, requires exact match. + * For auto-discovered clients without redirect_uris, uses same-origin policy. + * * @param string $redirect_uri The redirect URI to validate. * @return bool True if valid. */ public function is_valid_redirect_uri( $redirect_uri ) { $allowed_uris = $this->get_redirect_uris(); - // Exact match required. - return in_array( $redirect_uri, $allowed_uris, true ); + // If explicit redirect URIs are registered, require exact match. + if ( ! empty( $allowed_uris ) ) { + return in_array( $redirect_uri, $allowed_uris, true ); + } + + // For auto-discovered clients without redirect_uris, use same-origin policy. + // The redirect_uri must be on the same host as the client_id. + $client_id = $this->get_client_id(); + if ( filter_var( $client_id, FILTER_VALIDATE_URL ) ) { + $client_host = \wp_parse_url( $client_id, PHP_URL_HOST ); + $redirect_host = \wp_parse_url( $redirect_uri, PHP_URL_HOST ); + + return $client_host === $redirect_host; + } + + return false; } /** @@ -243,6 +446,33 @@ public function get_allowed_scopes() { return is_array( $scopes ) ? $scopes : Scope::ALL; } + /** + * Get client logo URI. + * + * @return string The logo URI or empty string. + */ + public function get_logo_uri() { + return \get_post_meta( $this->post_id, '_activitypub_logo_uri', true ) ?: ''; + } + + /** + * Get client URI (homepage). + * + * @return string The client URI or empty string. + */ + public function get_client_uri() { + return \get_post_meta( $this->post_id, '_activitypub_client_uri', true ) ?: ''; + } + + /** + * Check if this client was auto-discovered. + * + * @return bool True if discovered. + */ + public function is_discovered() { + return (bool) \get_post_meta( $this->post_id, '_activitypub_discovered', true ); + } + /** * Check if this is a public client. * @@ -326,22 +556,8 @@ public static function delete( $client_id ) { return false; } - // Delete all tokens for this client. - // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Token cleanup by client ID is necessary. - $tokens = \get_posts( - array( - 'post_type' => Token::POST_TYPE, - 'meta_key' => '_activitypub_client_id', - 'meta_value' => $client_id, - 'numberposts' => -1, - 'fields' => 'ids', - ) - ); - // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value - - foreach ( $tokens as $token_id ) { - \wp_delete_post( $token_id, true ); - } + // Delete all tokens for this client (tokens are stored in user meta). + Token::revoke_for_client( $client_id ); // Delete the client. return (bool) \wp_delete_post( $client->post_id, true ); diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index af933df0d9..c37422acf8 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -242,14 +242,176 @@ public static function get_metadata() { 'authorization_endpoint' => $base_url . 'oauth/authorize', 'token_endpoint' => $base_url . 'oauth/token', 'revocation_endpoint' => $base_url . 'oauth/revoke', + 'introspection_endpoint' => $base_url . 'oauth/introspect', 'registration_endpoint' => $base_url . 'oauth/clients', 'scopes_supported' => Scope::ALL, 'response_types_supported' => array( 'code' ), 'response_modes_supported' => array( 'query' ), 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ), 'token_endpoint_auth_methods_supported' => array( 'none', 'client_secret_post' ), + 'introspection_endpoint_auth_methods_supported' => array( 'none' ), 'code_challenge_methods_supported' => array( 'S256', 'plain' ), 'service_documentation' => 'https://github.com/swicg/activitypub-api', ); } + + /** + * Handle OAuth authorization consent page via wp-login.php. + * + * This is triggered by wp-login.php?action=activitypub_authorize + */ + public static function login_form_authorize() { + // Require user to be logged in. + if ( ! \is_user_logged_in() ) { + \auth_redirect(); + } + + // Check if C2S is enabled. + if ( ! self::is_c2s_enabled() ) { + \wp_die( + \esc_html__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), + \esc_html__( 'Authorization Error', 'activitypub' ), + array( 'response' => 403 ) + ); + } + + $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; + + if ( 'GET' === $request_method ) { + self::render_authorize_form(); + } elseif ( 'POST' === $request_method ) { + self::process_authorize_form(); + } + + exit; + } + + /** + * Render the OAuth authorization consent form. + */ + private static function render_authorize_form() { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Initial form display, nonce checked on POST. + $client_id = isset( $_GET['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_GET['client_id'] ) ) : ''; + $redirect_uri = isset( $_GET['redirect_uri'] ) ? \esc_url_raw( \wp_unslash( $_GET['redirect_uri'] ) ) : ''; + $scope = isset( $_GET['scope'] ) ? \sanitize_text_field( \wp_unslash( $_GET['scope'] ) ) : ''; + $state = isset( $_GET['state'] ) ? \sanitize_text_field( \wp_unslash( $_GET['state'] ) ) : ''; + $code_challenge = isset( $_GET['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge'] ) ) : ''; + $code_challenge_method = isset( $_GET['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge_method'] ) ) : 'S256'; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + // Validate client. + $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { + \wp_die( + \esc_html( $client->get_error_message() ), + \esc_html__( 'Authorization Error', 'activitypub' ), + array( 'response' => 404 ) + ); + } + + // Validate redirect URI. + if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { + \wp_die( + \esc_html__( 'Invalid redirect URI for this client.', 'activitypub' ), + \esc_html__( 'Authorization Error', 'activitypub' ), + array( 'response' => 400 ) + ); + } + + // These variables are used in the template. + $current_user = \wp_get_current_user(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $scopes = Scope::validate( Scope::parse( $scope ) ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $client_name = $client->get_name(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + + // Build form action URL. + // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $form_url = \add_query_arg( + array( + 'action' => 'activitypub_authorize', + 'client_id' => $client_id, + 'redirect_uri' => $redirect_uri, + 'scope' => $scope, + 'state' => $state, + 'code_challenge' => $code_challenge, + 'code_challenge_method' => $code_challenge_method, + ), + \wp_login_url() + ); + + // Include the template. + include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-authorize.php'; + } + + /** + * Process the OAuth authorization consent form submission. + */ + private static function process_authorize_form() { + // Verify nonce. + if ( ! isset( $_POST['_wpnonce'] ) || ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ) ), 'activitypub_oauth_authorize' ) ) { + \wp_die( + \esc_html__( 'Security check failed. Please try again.', 'activitypub' ), + \esc_html__( 'Authorization Error', 'activitypub' ), + array( 'response' => 403 ) + ); + } + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified above. + $client_id = isset( $_POST['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_POST['client_id'] ) ) : ''; + $redirect_uri = isset( $_POST['redirect_uri'] ) ? \esc_url_raw( \wp_unslash( $_POST['redirect_uri'] ) ) : ''; + $scope = isset( $_POST['scope'] ) ? \sanitize_text_field( \wp_unslash( $_POST['scope'] ) ) : ''; + $state = isset( $_POST['state'] ) ? \sanitize_text_field( \wp_unslash( $_POST['state'] ) ) : ''; + $code_challenge = isset( $_POST['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge'] ) ) : ''; + $code_challenge_method = isset( $_POST['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge_method'] ) ) : 'S256'; + $approve = isset( $_POST['approve'] ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + // User denied authorization. + if ( ! $approve ) { + $error_url = \add_query_arg( + array( + 'error' => 'access_denied', + 'error_description' => \rawurlencode( 'The user denied the authorization request.' ), + 'state' => $state, + ), + $redirect_uri + ); + \wp_safe_redirect( $error_url ); + exit; + } + + // Create authorization code. + $scopes = Scope::validate( Scope::parse( $scope ) ); + $code = Authorization_Code::create( + \get_current_user_id(), + $client_id, + $redirect_uri, + $scopes, + $code_challenge, + $code_challenge_method + ); + + if ( \is_wp_error( $code ) ) { + $error_url = \add_query_arg( + array( + 'error' => 'server_error', + 'error_description' => \rawurlencode( $code->get_error_message() ), + 'state' => $state, + ), + $redirect_uri + ); + \wp_safe_redirect( $error_url ); + exit; + } + + // Redirect to client with authorization code. + $success_url = \add_query_arg( + array( + 'code' => $code, + 'state' => $state, + ), + $redirect_uri + ); + \wp_redirect( $success_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Redirecting to external client. + exit; + } } diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index 6eb0f33ab1..85b43b6eef 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -10,43 +10,62 @@ /** * Token class for managing OAuth 2.0 access and refresh tokens. * - * Tokens are stored as Custom Post Types with hashed values for security. + * Tokens are stored as user metadata with hashed values for security. + * This follows the IndieAuth pattern for efficient token management. */ class Token { /** - * Post type for OAuth tokens. + * User meta key prefix for OAuth tokens. */ - const POST_TYPE = 'ap_oauth_token'; + const META_PREFIX = '_activitypub_oauth_token_'; /** - * Post status for active tokens. + * Option key for tracking users with tokens (for cleanup). */ - const STATUS_ACTIVE = 'publish'; + const USERS_OPTION = 'activitypub_oauth_token_users'; /** - * Post status for revoked tokens. + * Default access token expiration in seconds (1 hour). */ - const STATUS_REVOKED = 'draft'; + const DEFAULT_EXPIRATION = 3600; /** - * Default access token expiration in seconds (1 hour). + * Refresh token expiration in seconds (30 days). */ - const DEFAULT_EXPIRATION = 3600; + const REFRESH_EXPIRATION = 2592000; + + /** + * The token data array. + * + * @var array + */ + private $data; /** - * The post ID of the token. + * The user ID this token belongs to. * * @var int */ - private $post_id; + private $user_id; + + /** + * The token key (hash) used for storage. + * + * @var string + */ + private $token_key; /** * Constructor. * - * @param int $post_id The post ID of the token. + * @param int $user_id The user ID. + * @param string $token_key The token key (hash). + * @param array $data The token data. */ - public function __construct( $post_id ) { - $this->post_id = $post_id; + public function __construct( $user_id, $token_key, $data ) { + $this->user_id = $user_id; + $this->token_key = $token_key; + $this->data = $data; } /** @@ -63,36 +82,37 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D $access_token = self::generate_token(); $refresh_token = self::generate_token(); - // Calculate expiration. - $expires_at = time() + $expires; - - // Create the token post. - $post_id = \wp_insert_post( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => self::STATUS_ACTIVE, - 'post_author' => $user_id, - 'post_title' => sprintf( - /* translators: %1$s: client ID, %2$s: user login */ - \__( 'Token for %1$s (%2$s)', 'activitypub' ), - $client_id, - \get_userdata( $user_id )->user_login ?? $user_id - ), - 'meta_input' => array( - '_activitypub_access_token_hash' => self::hash_token( $access_token ), - '_activitypub_refresh_token_hash' => self::hash_token( $refresh_token ), - '_activitypub_client_id' => $client_id, - '_activitypub_scopes' => Scope::validate( $scopes ), - '_activitypub_expires_at' => $expires_at, - ), - ), - true + // Calculate expirations. + $access_expires_at = time() + $expires; + $refresh_expires_at = time() + self::REFRESH_EXPIRATION; + + // Create token data. + $token_data = array( + 'access_token_hash' => self::hash_token( $access_token ), + 'refresh_token_hash' => self::hash_token( $refresh_token ), + 'client_id' => $client_id, + 'scopes' => Scope::validate( $scopes ), + 'expires_at' => $access_expires_at, + 'refresh_expires_at' => $refresh_expires_at, + 'created_at' => time(), + 'last_used_at' => null, ); - if ( \is_wp_error( $post_id ) ) { - return $post_id; + // Store in user meta with access token hash as key. + $meta_key = self::META_PREFIX . self::hash_token( $access_token ); + $result = \update_user_meta( $user_id, $meta_key, $token_data ); + + if ( false === $result ) { + return new \WP_Error( + 'activitypub_token_storage_failed', + \__( 'Failed to store access token.', 'activitypub' ), + array( 'status' => 500 ) + ); } + // Track user for cleanup. + self::track_user( $user_id ); + return array( 'access_token' => $access_token, 'token_type' => 'Bearer', @@ -109,40 +129,43 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D * @return Token|\WP_Error The token object or error. */ public static function validate( $token ) { - $hash = self::hash_token( $token ); - - // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Token lookup by hash is necessary. - $posts = \get_posts( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => self::STATUS_ACTIVE, - 'meta_key' => '_activitypub_access_token_hash', - 'meta_value' => $hash, - 'numberposts' => 1, - ) - ); - // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value - - if ( empty( $posts ) ) { - return new \WP_Error( - 'activitypub_invalid_token', - \__( 'Invalid or expired access token.', 'activitypub' ), - array( 'status' => 401 ) - ); - } - - $post = $posts[0]; - $expires_at = (int) \get_post_meta( $post->ID, '_activitypub_expires_at', true ); - - if ( $expires_at < time() ) { - return new \WP_Error( - 'activitypub_token_expired', - \__( 'Access token has expired.', 'activitypub' ), - array( 'status' => 401 ) - ); + $token_hash = self::hash_token( $token ); + $meta_key = self::META_PREFIX . $token_hash; + + // Search for the token across all users with tokens. + $users = self::get_tracked_users(); + + foreach ( $users as $user_id ) { + $token_data = \get_user_meta( $user_id, $meta_key, true ); + + if ( ! empty( $token_data ) && is_array( $token_data ) ) { + // Verify hash matches. + if ( isset( $token_data['access_token_hash'] ) && + hash_equals( $token_data['access_token_hash'], $token_hash ) ) { + + // Check expiration. + if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < time() ) { + return new \WP_Error( + 'activitypub_token_expired', + \__( 'Access token has expired.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Update last used timestamp. + $token_data['last_used_at'] = time(); + \update_user_meta( $user_id, $meta_key, $token_data ); + + return new self( $user_id, $token_hash, $token_data ); + } + } } - return new self( $post->ID ); + return new \WP_Error( + 'activitypub_invalid_token', + \__( 'Invalid access token.', 'activitypub' ), + array( 'status' => 401 ) + ); } /** @@ -153,95 +176,104 @@ public static function validate( $token ) { * @return array|\WP_Error New token data or error. */ public static function refresh( $refresh_token, $client_id ) { - $hash = self::hash_token( $refresh_token ); - - $posts = \get_posts( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => self::STATUS_ACTIVE, - 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'relation' => 'AND', - array( - 'key' => '_activitypub_refresh_token_hash', - 'value' => $hash, - ), - array( - 'key' => '_activitypub_client_id', - 'value' => $client_id, - ), - ), - 'numberposts' => 1, - ) - ); - - if ( empty( $posts ) ) { - return new \WP_Error( - 'activitypub_invalid_refresh_token', - \__( 'Invalid refresh token.', 'activitypub' ), - array( 'status' => 401 ) - ); + $refresh_hash = self::hash_token( $refresh_token ); + $users = self::get_tracked_users(); + + foreach ( $users as $user_id ) { + // Get all token meta for this user. + $all_meta = \get_user_meta( $user_id ); + + foreach ( $all_meta as $meta_key => $meta_values ) { + if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { + continue; + } + + $token_data = maybe_unserialize( $meta_values[0] ); + + if ( ! is_array( $token_data ) ) { + continue; + } + + // Check if this is our refresh token. + if ( isset( $token_data['refresh_token_hash'] ) && + hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) { + + // Verify client ID matches. + if ( $token_data['client_id'] !== $client_id ) { + return new \WP_Error( + 'activitypub_client_mismatch', + \__( 'Client ID does not match.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Check refresh token expiration. + if ( isset( $token_data['refresh_expires_at'] ) && + $token_data['refresh_expires_at'] < time() ) { + // Delete the expired token. + \delete_user_meta( $user_id, $meta_key ); + + return new \WP_Error( + 'activitypub_refresh_token_expired', + \__( 'Refresh token has expired.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Delete the old token. + \delete_user_meta( $user_id, $meta_key ); + + // Create a new token. + return self::create( $user_id, $client_id, $token_data['scopes'] ); + } + } } - $post = $posts[0]; - - // Get existing data. - $user_id = $post->post_author; - $scopes = \get_post_meta( $post->ID, '_activitypub_scopes', true ); - - // Revoke the old token. - \wp_update_post( - array( - 'ID' => $post->ID, - 'post_status' => self::STATUS_REVOKED, - ) + return new \WP_Error( + 'activitypub_invalid_refresh_token', + \__( 'Invalid refresh token.', 'activitypub' ), + array( 'status' => 401 ) ); - - // Create a new token. - return self::create( $user_id, $client_id, $scopes ); } /** * Revoke a token. * * @param string $token The token to revoke (access or refresh). - * @return bool True on success. + * @return bool True on success (always returns true per RFC 7009). */ public static function revoke( $token ) { - $hash = self::hash_token( $token ); - - // Check both access and refresh token hashes. - $posts = \get_posts( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => self::STATUS_ACTIVE, - 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'relation' => 'OR', - array( - 'key' => '_activitypub_access_token_hash', - 'value' => $hash, - ), - array( - 'key' => '_activitypub_refresh_token_hash', - 'value' => $hash, - ), - ), - 'numberposts' => 1, - ) - ); + $token_hash = self::hash_token( $token ); + $users = self::get_tracked_users(); - if ( empty( $posts ) ) { - // Token doesn't exist or already revoked - that's fine per RFC 7009. - return true; - } + foreach ( $users as $user_id ) { + $all_meta = \get_user_meta( $user_id ); - $result = \wp_update_post( - array( - 'ID' => $posts[0]->ID, - 'post_status' => self::STATUS_REVOKED, - ) - ); + foreach ( $all_meta as $meta_key => $meta_values ) { + if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { + continue; + } + + $token_data = maybe_unserialize( $meta_values[0] ); + + if ( ! is_array( $token_data ) ) { + continue; + } - return ! \is_wp_error( $result ); + // Check both access and refresh token hashes. + if ( ( isset( $token_data['access_token_hash'] ) && + hash_equals( $token_data['access_token_hash'], $token_hash ) ) || + ( isset( $token_data['refresh_token_hash'] ) && + hash_equals( $token_data['refresh_token_hash'], $token_hash ) ) ) { + + \delete_user_meta( $user_id, $meta_key ); + return true; + } + } + } + + // Token doesn't exist or already revoked - that's fine per RFC 7009. + return true; } /** @@ -251,31 +283,95 @@ public static function revoke( $token ) { * @return int Number of tokens revoked. */ public static function revoke_all_for_user( $user_id ) { - $posts = \get_posts( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => self::STATUS_ACTIVE, - 'author' => $user_id, - 'numberposts' => -1, - ) - ); + $all_meta = \get_user_meta( $user_id ); + $count = 0; - $count = 0; - foreach ( $posts as $post ) { - $result = \wp_update_post( - array( - 'ID' => $post->ID, - 'post_status' => self::STATUS_REVOKED, - ) - ); - if ( ! \is_wp_error( $result ) ) { + foreach ( $all_meta as $meta_key => $meta_values ) { + if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) { + \delete_user_meta( $user_id, $meta_key ); ++$count; } } + // Remove user from tracking if no more tokens. + if ( $count > 0 ) { + self::untrack_user( $user_id ); + } + + return $count; + } + + /** + * Revoke all tokens for a specific client. + * + * @param string $client_id OAuth client ID. + * @return int Number of tokens revoked. + */ + public static function revoke_for_client( $client_id ) { + $users = self::get_tracked_users(); + $count = 0; + + foreach ( $users as $user_id ) { + $all_meta = \get_user_meta( $user_id ); + $user_tokens = 0; + + foreach ( $all_meta as $meta_key => $meta_values ) { + if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { + continue; + } + + $token_data = maybe_unserialize( $meta_values[0] ); + + if ( ! is_array( $token_data ) ) { + continue; + } + + // Check if this token belongs to the client. + if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) { + \delete_user_meta( $user_id, $meta_key ); + ++$count; + } else { + ++$user_tokens; + } + } + + // Untrack user if no more tokens. + if ( 0 === $user_tokens ) { + self::untrack_user( $user_id ); + } + } + return $count; } + /** + * Get all tokens for a user. + * + * @param int $user_id WordPress user ID. + * @return array Array of token data. + */ + public static function get_all_for_user( $user_id ) { + $all_meta = \get_user_meta( $user_id ); + $tokens = array(); + + foreach ( $all_meta as $meta_key => $meta_values ) { + if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { + continue; + } + + $token_data = maybe_unserialize( $meta_values[0] ); + + if ( is_array( $token_data ) ) { + // Don't expose hashes. + unset( $token_data['access_token_hash'], $token_data['refresh_token_hash'] ); + $token_data['meta_key'] = $meta_key; + $tokens[] = $token_data; + } + } + + return $tokens; + } + /** * Check if token has a specific scope. * @@ -293,8 +389,7 @@ public function has_scope( $scope ) { * @return int The WordPress user ID. */ public function get_user_id() { - $post = \get_post( $this->post_id ); - return $post ? (int) $post->post_author : 0; + return $this->user_id; } /** @@ -303,7 +398,7 @@ public function get_user_id() { * @return string The OAuth client ID. */ public function get_client_id() { - return \get_post_meta( $this->post_id, '_activitypub_client_id', true ); + return $this->data['client_id'] ?? ''; } /** @@ -312,8 +407,7 @@ public function get_client_id() { * @return array The granted scopes. */ public function get_scopes() { - $scopes = \get_post_meta( $this->post_id, '_activitypub_scopes', true ); - return is_array( $scopes ) ? $scopes : array(); + return $this->data['scopes'] ?? array(); } /** @@ -322,7 +416,7 @@ public function get_scopes() { * @return int Unix timestamp. */ public function get_expires_at() { - return (int) \get_post_meta( $this->post_id, '_activitypub_expires_at', true ); + return $this->data['expires_at'] ?? 0; } /** @@ -334,6 +428,24 @@ public function is_expired() { return $this->get_expires_at() < time(); } + /** + * Get the creation timestamp. + * + * @return int Unix timestamp. + */ + public function get_created_at() { + return $this->data['created_at'] ?? 0; + } + + /** + * Get the last used timestamp. + * + * @return int|null Unix timestamp or null if never used. + */ + public function get_last_used_at() { + return $this->data['last_used_at'] ?? null; + } + /** * Generate a cryptographically secure random token. * @@ -354,6 +466,43 @@ public static function hash_token( $token ) { return hash( 'sha256', $token ); } + /** + * Track a user as having tokens. + * + * @param int $user_id The user ID. + */ + private static function track_user( $user_id ) { + $users = self::get_tracked_users(); + if ( ! in_array( $user_id, $users, true ) ) { + $users[] = $user_id; + \update_option( self::USERS_OPTION, $users, false ); + } + } + + /** + * Untrack a user (when they have no more tokens). + * + * @param int $user_id The user ID. + */ + private static function untrack_user( $user_id ) { + $users = self::get_tracked_users(); + $key = array_search( $user_id, $users, true ); + if ( false !== $key ) { + unset( $users[ $key ] ); + \update_option( self::USERS_OPTION, array_values( $users ), false ); + } + } + + /** + * Get all tracked users with tokens. + * + * @return array User IDs. + */ + private static function get_tracked_users() { + $users = \get_option( self::USERS_OPTION, array() ); + return is_array( $users ) ? $users : array(); + } + /** * Clean up expired tokens. * @@ -362,27 +511,74 @@ public static function hash_token( $token ) { * @return int Number of tokens deleted. */ public static function cleanup_expired() { - global $wpdb; - - $expired_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $wpdb->prepare( - "SELECT 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_expires_at' - AND pm.meta_value < %d", - self::POST_TYPE, - time() - DAY_IN_SECONDS // Grace period of 1 day. - ) - ); - + $users = self::get_tracked_users(); $count = 0; - foreach ( $expired_ids as $post_id ) { - if ( \wp_delete_post( $post_id, true ) ) { - ++$count; + + foreach ( $users as $user_id ) { + $all_meta = \get_user_meta( $user_id ); + $user_tokens = 0; + + foreach ( $all_meta as $meta_key => $meta_values ) { + if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { + continue; + } + + $token_data = maybe_unserialize( $meta_values[0] ); + + if ( ! is_array( $token_data ) ) { + \delete_user_meta( $user_id, $meta_key ); + ++$count; + continue; + } + + // Check if both access and refresh tokens are expired. + $access_expired = isset( $token_data['expires_at'] ) && + $token_data['expires_at'] < time() - DAY_IN_SECONDS; + $refresh_expired = isset( $token_data['refresh_expires_at'] ) && + $token_data['refresh_expires_at'] < time(); + + if ( $access_expired && $refresh_expired ) { + \delete_user_meta( $user_id, $meta_key ); + ++$count; + } else { + ++$user_tokens; + } + } + + // Untrack user if no more tokens. + if ( 0 === $user_tokens ) { + self::untrack_user( $user_id ); } } return $count; } + + /** + * Introspect a token (RFC 7662). + * + * @param string $token The token to introspect. + * @return array Token introspection response. + */ + public static function introspect( $token ) { + $validated = self::validate( $token ); + + if ( \is_wp_error( $validated ) ) { + // Return inactive for invalid/expired tokens. + return array( 'active' => false ); + } + + $user = \get_userdata( $validated->get_user_id() ); + + return array( + 'active' => true, + 'scope' => Scope::to_string( $validated->get_scopes() ), + 'client_id' => $validated->get_client_id(), + 'username' => $user ? $user->user_login : null, + 'token_type' => 'Bearer', + 'exp' => $validated->get_expires_at(), + 'iat' => $validated->get_created_at(), + 'sub' => (string) $validated->get_user_id(), + ); + } } diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index f45de9751d..c52b3d8f3b 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -114,6 +114,31 @@ public function register_routes() { ) ); + // Token introspection endpoint (RFC 7662). + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/introspect', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'introspect' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'token' => array( + 'description' => 'The token to introspect.', + 'type' => 'string', + 'required' => true, + ), + 'token_type_hint' => array( + 'description' => 'Hint about the token type.', + 'type' => 'string', + 'enum' => array( 'access_token', 'refresh_token' ), + ), + ), + ), + ) + ); + // Dynamic client registration (RFC 7591). \register_rest_route( $this->namespace, @@ -169,7 +194,7 @@ public function register_routes() { /** * Handle authorization request (GET /oauth/authorize). * - * Displays authorization page or redirects to WP login. + * Validates request parameters and redirects to wp-admin consent page. * * @param \WP_REST_Request $request The request object. * @return \WP_REST_Response|\WP_Error @@ -226,34 +251,27 @@ public function authorize( \WP_REST_Request $request ) { ); } - // If user is not logged in, redirect to login page. - if ( ! \is_user_logged_in() ) { - $login_url = \wp_login_url( $request->get_uri() ); - return new \WP_REST_Response( - null, - 302, - array( 'Location' => $login_url ) - ); - } - - // User is logged in - display consent page. - $scopes = Scope::validate( Scope::parse( $scope ) ); - $user = \wp_get_current_user(); - $nonce = \wp_create_nonce( 'activitypub_oauth_authorize' ); - - // Build consent page HTML. - $html = $this->render_consent_page( - $client, - $scopes, - $user, - $request->get_params(), - $nonce + // Redirect to wp-login.php with action=activitypub_authorize. + // This uses WordPress's login_form_{action} hook for proper cookie auth. + $login_url = \wp_login_url(); + $login_url = \add_query_arg( + array( + 'action' => 'activitypub_authorize', + 'client_id' => $client_id, + 'redirect_uri' => $redirect_uri, + 'response_type' => $response_type, + 'scope' => $scope, + 'state' => $state, + 'code_challenge' => $code_challenge, + 'code_challenge_method' => $request->get_param( 'code_challenge_method' ) ?: 'S256', + ), + $login_url ); return new \WP_REST_Response( - $html, - 200, - array( 'Content-Type' => 'text/html; charset=' . \get_option( 'blog_charset' ) ) + null, + 302, + array( 'Location' => $login_url ) ); } @@ -467,6 +485,23 @@ public function revoke( \WP_REST_Request $request ) { return new \WP_REST_Response( null, 200 ); } + /** + * Handle token introspection (POST /oauth/introspect). + * + * Implements RFC 7662 Token Introspection. + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response + */ + public function introspect( \WP_REST_Request $request ) { + $token = $request->get_param( 'token' ); + + // Introspect the token. + $response = Token::introspect( $token ); + + return new \WP_REST_Response( $response, 200 ); + } + /** * Handle dynamic client registration (POST /oauth/clients). * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index cf84aa98f0..d41e48d98e 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -403,6 +403,9 @@ public function create_item( $request ) { $data = $this->wrap_in_create( $data, $user ); } + // Ensure the object has an ID (required for outbox storage). + $data = $this->ensure_object_id( $data, $user ); + $activity_type = camel_to_snake_case( $data['type'] ?? '' ); // Determine visibility from addressing. @@ -578,4 +581,41 @@ private function determine_visibility( $activity ) { // Private (no public addressing). return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; } + + /** + * Ensure the activity object has an ID. + * + * For C2S activities, clients may not provide object IDs. + * The server must generate them. + * + * @param array $data The activity data. + * @param \Activitypub\Model\User|null $user The authenticated user. + * @return array The activity data with object ID ensured. + */ + private function ensure_object_id( $data, $user ) { + // Check if there's an embedded object that needs an ID. + if ( ! isset( $data['object'] ) || ! is_array( $data['object'] ) ) { + return $data; + } + + $object = &$data['object']; + + // Generate ID if missing. + if ( empty( $object['id'] ) ) { + $uuid = \wp_generate_uuid4(); + $object['id'] = get_rest_url_by_path( 'objects/' . $uuid ); + } + + // Set attributedTo if missing. + if ( empty( $object['attributedTo'] ) && $user ) { + $object['attributedTo'] = $user->get_id(); + } + + // Set published if missing. + if ( empty( $object['published'] ) ) { + $object['published'] = \gmdate( 'Y-m-d\TH:i:s\Z' ); + } + + return $data; + } } diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php new file mode 100644 index 0000000000..145fee0251 --- /dev/null +++ b/templates/oauth-authorize.php @@ -0,0 +1,112 @@ + + +
+
+

+ + ' . esc_html( $client_name ?: $client_id ) . '' + ), + array( 'a' => array( 'href' => array() ) ) + ); + ?> + +

+
+ +
+ ID, 48 ); ?> +

+ ' . esc_html( $current_user->display_name ) . '', + esc_html( $current_user->user_login ) + ); + ?> +

+
+ + +
+

+
    + +
  • + + +
  • + +
+
+ + +
+ ' . esc_html( $redirect_uri ) . '' + ); + ?> +
+ + + + + + + + + +

+ + + + +

+
+ +assertInstanceOf( \WP_Error::class, $result ); - $this->assertEquals( 'activitypub_invalid_code', $result->get_error_code() ); + $this->assertEquals( 'activitypub_client_mismatch', $result->get_error_code() ); } /** diff --git a/tests/phpunit/tests/includes/oauth/class-test-token.php b/tests/phpunit/tests/includes/oauth/class-test-token.php index 7428872d4d..1e08b3ef50 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-token.php +++ b/tests/phpunit/tests/includes/oauth/class-test-token.php @@ -270,7 +270,7 @@ public function test_refresh_wrong_client() { $result = Token::refresh( $original['refresh_token'], 'wrong_client_id' ); $this->assertInstanceOf( \WP_Error::class, $result ); - $this->assertEquals( 'activitypub_invalid_refresh_token', $result->get_error_code() ); + $this->assertEquals( 'activitypub_client_mismatch', $result->get_error_code() ); } /** From e20d0770c45980f56bbba77a586399040e2f4aae Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 17:51:45 +0100 Subject: [PATCH 011/105] Refactor handlers to use incoming/outgoing naming convention - Rename handler methods to `incoming()` for inbox and `outgoing()` for outbox - Add deprecated proxy functions for backward compatibility (handle_*) - Update Create handler to support outbox POST with WordPress post creation - Add Dispatcher hook to fire outbox handlers after add_to_outbox() - Skip scheduler for already-federated posts to prevent duplicates - Remove C2S terminology from comments, use incoming/outgoing instead Handlers updated: Create, Update, Announce, Like, Undo, Follow, Delete --- includes/class-dispatcher.php | 34 ++++ includes/handler/class-announce.php | 45 ++++- includes/handler/class-create.php | 223 ++++++++++++++-------- includes/handler/class-delete.php | 44 ++++- includes/handler/class-follow.php | 52 +++-- includes/handler/class-like.php | 44 ++++- includes/handler/class-undo.php | 44 ++++- includes/handler/class-update.php | 47 ++++- includes/rest/class-outbox-controller.php | 89 ++++----- includes/scheduler/class-post.php | 6 + 10 files changed, 446 insertions(+), 182 deletions(-) diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index c2f9793244..c89f8f1699 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -34,6 +34,7 @@ class Dispatcher { public static function init() { \add_action( 'activitypub_process_outbox', array( self::class, 'process_outbox' ) ); + \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'fire_outbox_handlers' ), 5, 2 ); \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'send_immediate_accept' ), 10, 2 ); // Default filters to add Inboxes to sent to. @@ -511,6 +512,39 @@ public static function add_inboxes_of_relays( $inboxes, $actor_id, $activity ) { return array_merge( $inboxes, $relays ); } + /** + * Fire outbox handlers for activities. + * + * Triggers activity type-specific handlers to process outbox activities, + * allowing handlers to create WordPress posts or perform other side effects. + * + * @param int $outbox_id The Outbox item ID. + * @param Activity $activity The Activity that was just added to the Outbox. + */ + public static function fire_outbox_handlers( $outbox_id, $activity ) { + $outbox_item = \get_post( $outbox_id ); + + if ( ! $outbox_item ) { + return; + } + + $type = $activity->get_type(); + $user_id = $outbox_item->post_author; + $data = $activity->to_array( false ); + + /** + * Fires when an activity has been added to the outbox. + * + * Handlers can implement side effects like creating WordPress posts. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_handled_outbox_' . \strtolower( $type ), $data, $user_id, $activity, $outbox_id ); + } + /** * Send an immediate Accept activity for the given Outbox item. * diff --git a/includes/handler/class-announce.php b/includes/handler/class-announce.php index 91d0c7f022..bcfdda39d0 100644 --- a/includes/handler/class-announce.php +++ b/includes/handler/class-announce.php @@ -24,18 +24,18 @@ class Announce { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_announce', array( self::class, 'handle_announce' ), 10, 3 ); - \add_action( 'activitypub_handled_outbox_announce', array( self::class, 'handle_outbox_announce' ), 10, 4 ); + \add_action( 'activitypub_inbox_announce', array( self::class, 'incoming' ), 10, 3 ); + \add_action( 'activitypub_handled_outbox_announce', array( self::class, 'outgoing' ), 10, 4 ); } /** - * Handles "Announce" requests. + * Handle incoming "Announce" requests from remote actors. * * @param array $announcement The activity-object. * @param int|int[] $user_ids The id(s) of the local blog-user(s). * @param \Activitypub\Activity\Activity $activity The activity object. */ - public static function handle_announce( $announcement, $user_ids, $activity = null ) { + public static function incoming( $announcement, $user_ids, $activity = null ) { // Check if Activity is public or not. if ( ! is_activity_public( $announcement ) ) { // @todo maybe send email @@ -133,7 +133,7 @@ public static function maybe_save_announce( $activity, $user_ids ) { } /** - * Handle outbox "Announce" activities (C2S). + * Handle outgoing "Announce" activities from local actors. * * Records an announce/boost from the local user on remote content. * @@ -142,7 +142,7 @@ public static function maybe_save_announce( $activity, $user_ids ) { * @param \Activitypub\Activity\Activity $activity The Activity object. * @param int $outbox_id The outbox post ID. */ - public static function handle_outbox_announce( $data, $user_id, $activity, $outbox_id ) { + public static function outgoing( $data, $user_id, $activity, $outbox_id ) { $object_url = object_to_uri( $data['object'] ?? '' ); if ( empty( $object_url ) ) { @@ -150,7 +150,7 @@ public static function handle_outbox_announce( $data, $user_id, $activity, $outb } /** - * Fires after an Announce activity has been sent via C2S. + * Fires after an outgoing Announce activity has been processed. * * @param string $object_url The URL of the announced object. * @param array $data The activity data. @@ -159,4 +159,35 @@ public static function handle_outbox_announce( $data, $user_id, $activity, $outb */ \do_action( 'activitypub_outbox_announce_sent', $object_url, $data, $user_id, $outbox_id ); } + + /** + * Handle "Announce" requests. + * + * @deprecated unreleased Use Announce::incoming() instead. + * + * @param array $announcement The activity-object. + * @param int|int[] $user_ids The id(s) of the local blog-user(s). + * @param \Activitypub\Activity\Activity $activity The activity object. + */ + public static function handle_announce( $announcement, $user_ids, $activity = null ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Announce::incoming()' ); + + return self::incoming( $announcement, $user_ids, $activity ); + } + + /** + * Handle outbox "Announce" activities. + * + * @deprecated unreleased Use Announce::outgoing() instead. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_announce( $data, $user_id, $activity, $outbox_id ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Announce::outgoing()' ); + + return self::outgoing( $data, $user_id, $activity, $outbox_id ); + } } diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index 33b34af56d..228efb4c08 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -11,7 +11,9 @@ use Activitypub\Collection\Posts; use Activitypub\Tombstone; +use function Activitypub\add_to_outbox; use function Activitypub\get_activity_visibility; +use function Activitypub\get_content_visibility; use function Activitypub\is_activity_reply; use function Activitypub\is_quote_activity; use function Activitypub\is_self_ping; @@ -25,127 +27,109 @@ class Create { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_handled_inbox_create', array( self::class, 'handle_create' ), 10, 3 ); - \add_action( 'activitypub_handled_outbox_create', array( self::class, 'handle_outbox_create' ), 10, 4 ); + // Incoming activities (from remote actors via inbox). + \add_action( 'activitypub_handled_inbox_create', array( self::class, 'incoming' ), 10, 3 ); + + // Outgoing activities (from local actors via outbox). + \add_filter( 'activitypub_outbox_create', array( self::class, 'outgoing' ), 10, 3 ); + \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'maybe_unbury' ), 10, 2 ); } /** - * Handles "Create" requests. + * Handle incoming "Create" activities from remote actors. + * + * @param array $activity The activity data. + * @param int[] $user_ids The local user IDs targeted. + * @param mixed $activity_object The activity object (unused, required by hook signature). * - * @param array $activity The activity-object. - * @param int|int[] $user_ids The id(s) of the local blog-user(s). - * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null. + * @return \WP_Post|\WP_Comment|\WP_Error|false The created content or error. */ - public static function handle_create( $activity, $user_ids, $activity_object = null ) { + public static function incoming( $activity, $user_ids = null, $activity_object = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable // Check for private and/or direct messages. if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) { - $result = false; - } elseif ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { // Check for replies and quotes. - $result = self::create_interaction( $activity, $user_ids, $activity_object ); - } else { // Handle non-interaction objects. - $result = self::create_post( $activity, $user_ids, $activity_object ); + return false; + } + + // Route to appropriate handler based on content type. + if ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { + $result = self::incoming_interaction( $activity, $user_ids ); + } else { + $result = self::incoming_post( $activity, $user_ids ); } if ( false === $result ) { - return; + return $result; } $success = ! \is_wp_error( $result ); /** - * Fires after an ActivityPub Create activity has been handled. + * Fires after an incoming ActivityPub Create activity has been handled. * * @param array $activity The ActivityPub activity data. * @param int[] $user_ids The local user IDs. * @param bool $success True on success, false otherwise. - * @param \WP_Comment|\WP_Post|\WP_Error $result The WP_Comment object of the created comment, or null if creation failed. + * @param \WP_Comment|\WP_Post|\WP_Error $result The created content or error. */ \do_action( 'activitypub_handled_create', $activity, (array) $user_ids, $success, $result ); + + return $result; } /** - * Handle outbox "Create" activities (C2S). + * Handle outgoing "Create" activities from local actors. * - * Creates a WordPress post from the ActivityPub object. + * Creates WordPress content and adds to outbox for federation. * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. + * @param array $activity The activity data. + * @param int $user_id The local user ID. + * @param string|null $visibility Content visibility. + * + * @return int|\WP_Error|null The outbox ID on success, WP_Error on failure, null if not handled. */ - public static function handle_outbox_create( $data, $user_id, $activity, $outbox_id ) { - $object = $data['object'] ?? array(); - - if ( ! is_array( $object ) ) { - return; + public static function outgoing( $activity, $user_id = null, $visibility = null ) { + // Check for private and/or direct messages. + if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) { + return false; } - $type = $object['type'] ?? ''; + $object = $activity['object'] ?? array(); - // Only handle Note and Article types. - if ( ! in_array( $type, array( 'Note', 'Article' ), true ) ) { - return; + if ( ! \is_array( $object ) ) { + return new \WP_Error( 'invalid_object', 'Invalid object in activity.' ); } - $content = $object['content'] ?? ''; - $name = $object['name'] ?? ''; - $summary = $object['summary'] ?? ''; + $object_type = $object['type'] ?? ''; - // Use name as title for Articles, or generate from content for Notes. - $title = $name; - if ( empty( $title ) && ! empty( $content ) ) { - $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); + // Only handle Note and Article types for now. + if ( ! \in_array( $object_type, array( 'Note', 'Article' ), true ) ) { + return null; } - // Determine visibility. - $visibility = \get_post_meta( $outbox_id, 'activitypub_content_visibility', true ); - - $post_data = array( - 'post_author' => $user_id > 0 ? $user_id : 0, - 'post_title' => $title, - 'post_content' => $content, - 'post_excerpt' => $summary, - 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish', - 'post_type' => 'post', - 'meta_input' => array( - 'activitypub_content_visibility' => $visibility, - ), - ); - - $post_id = \wp_insert_post( $post_data, true ); - - if ( \is_wp_error( $post_id ) ) { - return; + // TODO: Handle replies/interactions differently. + if ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { + return null; } - /** - * Fires after a post has been created from a C2S Create activity. - * - * @param int $post_id The created post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_outbox_created_post', $post_id, $data, $user_id, $outbox_id ); + return self::outgoing_post( $activity, $user_id, $visibility ); } /** - * Handle interactions like replies. + * Handle incoming interaction (reply/quote) from remote actor. * - * @param array $activity The activity-object. - * @param int[] $user_ids The ids of the local blog-users. - * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null. + * @param array $activity The activity data. + * @param int[] $user_ids The local user IDs targeted. * - * @return \WP_Comment|\WP_Error|false The created comment, WP_Error on failure, false if already exists or not processed. + * @return \WP_Comment|\WP_Error|false Comment, WP_Error, or false. */ - public static function create_interaction( $activity, $user_ids, $activity_object = null ) { + private static function incoming_interaction( $activity, $user_ids ) { $existing_comment = object_id_to_comment( $activity['object']['id'] ); // If comment exists, call update action. if ( $existing_comment ) { - Update::handle_update( $activity, (array) $user_ids, $activity_object ); + Update::incoming( $activity, (array) $user_ids, null ); return false; } @@ -164,15 +148,14 @@ public static function create_interaction( $activity, $user_ids, $activity_objec } /** - * Handle non-interaction posts like posts. + * Handle incoming post from remote actor. * - * @param array $activity The activity-object. - * @param int[] $user_ids The ids of the local blog-users. - * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null. + * @param array $activity The activity data. + * @param int[] $user_ids The local user IDs targeted. * - * @return \WP_Post|\WP_Error|false The post on success, WP_Error on failure, false if already exists. + * @return \WP_Post|\WP_Error|false Post, WP_Error, or false. */ - public static function create_post( $activity, $user_ids, $activity_object = null ) { + private static function incoming_post( $activity, $user_ids ) { if ( ! \get_option( 'activitypub_create_posts', false ) ) { return false; } @@ -181,7 +164,7 @@ public static function create_post( $activity, $user_ids, $activity_object = nul // If post exists, call update action. if ( $existing_post instanceof \WP_Post ) { - Update::handle_update( $activity, (array) $user_ids, $activity_object ); + Update::incoming( $activity, (array) $user_ids, null ); return false; } @@ -189,6 +172,71 @@ public static function create_post( $activity, $user_ids, $activity_object = nul return Posts::add( $activity, $user_ids ); } + /** + * Handle outgoing post from local actor. + * + * Creates a WordPress post and adds to outbox for federation. + * + * @param array $activity The activity data. + * @param int $user_id The local user ID. + * @param string|null $visibility Content visibility. + * + * @return int|\WP_Error The outbox ID on success, WP_Error on failure. + */ + private static function outgoing_post( $activity, $user_id, $visibility ) { + $object = $activity['object'] ?? array(); + + $content = $object['content'] ?? ''; + $name = $object['name'] ?? ''; + $summary = $object['summary'] ?? ''; + + // Use name as title for Articles, or generate from content for Notes. + $title = $name; + if ( empty( $title ) && ! empty( $content ) ) { + $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); + } + + // Determine visibility if not provided. + if ( null === $visibility ) { + $visibility = get_content_visibility( $activity ); + } + + $post_data = array( + 'post_author' => $user_id > 0 ? $user_id : 0, + 'post_title' => $title, + 'post_content' => $content, + 'post_excerpt' => $summary, + 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish', + 'post_type' => 'post', + 'meta_input' => array( + 'activitypub_content_visibility' => $visibility, + // Mark the post as federated to prevent the scheduler from also adding it to outbox. + 'activitypub_status' => ACTIVITYPUB_OBJECT_STATE_FEDERATED, + ), + ); + + $post_id = \wp_insert_post( $post_data, true ); + + if ( \is_wp_error( $post_id ) ) { + return $post_id; + } + + $post = \get_post( $post_id ); + + /** + * Fires after a post has been created from an outgoing Create activity. + * + * @param int $post_id The created post ID. + * @param array $activity The activity data. + * @param int $user_id The user ID. + * @param string $visibility The content visibility. + */ + \do_action( 'activitypub_outbox_created_post', $post_id, $activity, $user_id, $visibility ); + + // Add to outbox and return the outbox ID. + return add_to_outbox( $post, 'Create', $user_id, $visibility ); + } + /** * Validate the object. * @@ -242,4 +290,21 @@ public static function maybe_unbury( $outbox_id, $activity ) { Tombstone::remove( $object->get_url() ); } } + + /** + * Handle "Create" requests. + * + * @deprecated unreleased Use Create::incoming() instead. + * + * @param array $activity The activity data. + * @param int[] $user_ids The local user IDs targeted. + * @param mixed $activity_object The activity object. + * + * @return \WP_Post|\WP_Comment|\WP_Error|false The created content or error. + */ + public static function handle_create( $activity, $user_ids = null, $activity_object = null ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Create::incoming()' ); + + return self::incoming( $activity, $user_ids, $activity_object ); + } } diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 8e4f6f0495..6081181e4c 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -22,8 +22,8 @@ class Delete { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ), 10, 2 ); - \add_action( 'activitypub_handled_outbox_delete', array( self::class, 'handle_outbox_delete' ), 10, 4 ); + \add_action( 'activitypub_inbox_delete', array( self::class, 'incoming' ), 10, 2 ); + \add_action( 'activitypub_handled_outbox_delete', array( self::class, 'outgoing' ), 10, 4 ); \add_filter( 'activitypub_skip_inbox_storage', array( self::class, 'skip_inbox_storage' ), 10, 2 ); \add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 ); \add_action( 'activitypub_delete_remote_actor_interactions', array( self::class, 'delete_interactions' ) ); @@ -34,12 +34,12 @@ public static function init() { } /** - * Handles "Delete" requests. + * Handle incoming "Delete" requests from remote actors. * * @param array $activity The delete activity. * @param int|int[] $user_ids The local user ID(s). */ - public static function handle_delete( $activity, $user_ids ) { + public static function incoming( $activity, $user_ids ) { $object_type = $activity['object']['type'] ?? ''; switch ( $object_type ) { @@ -356,7 +356,7 @@ public static function maybe_bury( $outbox_id, $activity ) { } /** - * Handle outbox "Delete" activities (C2S). + * Handle outgoing "Delete" activities from local actors. * * Deletes a WordPress post. * @@ -365,7 +365,7 @@ public static function maybe_bury( $outbox_id, $activity ) { * @param \Activitypub\Activity\Activity $activity The Activity object. * @param int $outbox_id The outbox post ID. */ - public static function handle_outbox_delete( $data, $user_id, $activity, $outbox_id ) { + public static function outgoing( $data, $user_id, $activity, $outbox_id ) { $object = $data['object'] ?? ''; // Get the object ID (can be a string URL or an object with an id). @@ -395,7 +395,7 @@ public static function handle_outbox_delete( $data, $user_id, $activity, $outbox } /** - * Fires after a post has been deleted from a C2S Delete activity. + * Fires after a post has been deleted from an outgoing Delete activity. * * @param int $post_id The deleted post ID. * @param array $data The activity data. @@ -404,4 +404,34 @@ public static function handle_outbox_delete( $data, $user_id, $activity, $outbox */ \do_action( 'activitypub_outbox_deleted_post', $post->ID, $data, $user_id, $outbox_id ); } + + /** + * Handle "Delete" requests. + * + * @deprecated unreleased Use Delete::incoming() instead. + * + * @param array $activity The delete activity. + * @param int|int[] $user_ids The local user ID(s). + */ + public static function handle_delete( $activity, $user_ids ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Delete::incoming()' ); + + return self::incoming( $activity, $user_ids ); + } + + /** + * Handle outbox "Delete" activities. + * + * @deprecated unreleased Use Delete::outgoing() instead. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_delete( $data, $user_id, $activity, $outbox_id ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Delete::outgoing()' ); + + return self::outgoing( $data, $user_id, $activity, $outbox_id ); + } } diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php index db3e39b636..47faac2e4e 100644 --- a/includes/handler/class-follow.php +++ b/includes/handler/class-follow.php @@ -23,18 +23,18 @@ class Follow { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 ); + \add_action( 'activitypub_inbox_follow', array( self::class, 'incoming' ), 10, 2 ); \add_action( 'activitypub_handled_follow', array( self::class, 'queue_accept' ), 10, 4 ); - \add_action( 'activitypub_handled_outbox_follow', array( self::class, 'handle_outbox_follow' ), 10, 4 ); + \add_action( 'activitypub_handled_outbox_follow', array( self::class, 'outgoing' ), 10, 4 ); } /** - * Handle "Follow" requests. + * Handle incoming "Follow" requests from remote actors. * * @param array $activity The activity object. * @param int|int[] $user_ids The user ID(s). */ - public static function handle_follow( $activity, $user_ids ) { + public static function incoming( $activity, $user_ids ) { // Extract the user ID (follow requests are always for a single user). $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids; @@ -152,7 +152,7 @@ public static function queue_reject( $activity, $user_id ) { } /** - * Handle outbox "Follow" activities (C2S). + * Handle outgoing "Follow" activities from local actors. * * Adds the target actor to the user's following list (pending until accepted). * @@ -161,7 +161,7 @@ public static function queue_reject( $activity, $user_id ) { * @param \Activitypub\Activity\Activity $activity The Activity object. * @param int $outbox_id The outbox post ID. */ - public static function handle_outbox_follow( $data, $user_id, $activity, $outbox_id ) { + public static function outgoing( $data, $user_id, $activity, $outbox_id ) { $object = $data['object'] ?? ''; // The object should be the actor URL to follow. @@ -189,13 +189,43 @@ public static function handle_outbox_follow( $data, $user_id, $activity, $outbox \add_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, (string) $user_id ); /** - * Fires after a Follow activity has been sent via C2S. + * Fires after an outgoing Follow activity has been processed. * - * @param int $remote_actor_id The remote actor post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. + * @param int $remote_actor_id The remote actor post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. */ \do_action( 'activitypub_outbox_follow_sent', $remote_actor->ID, $data, $user_id, $outbox_id ); } + + /** + * Handle "Follow" requests. + * + * @deprecated unreleased Use Follow::incoming() instead. + * + * @param array $activity The activity object. + * @param int|int[] $user_ids The user ID(s). + */ + public static function handle_follow( $activity, $user_ids ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Follow::incoming()' ); + + return self::incoming( $activity, $user_ids ); + } + + /** + * Handle outbox "Follow" activities. + * + * @deprecated unreleased Use Follow::outgoing() instead. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_follow( $data, $user_id, $activity, $outbox_id ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Follow::outgoing()' ); + + return self::outgoing( $data, $user_id, $activity, $outbox_id ); + } } diff --git a/includes/handler/class-like.php b/includes/handler/class-like.php index fe77e36b94..837ff5de57 100644 --- a/includes/handler/class-like.php +++ b/includes/handler/class-like.php @@ -20,18 +20,18 @@ 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_action( 'activitypub_handled_outbox_like', array( self::class, 'handle_outbox_like' ), 10, 4 ); + \add_action( 'activitypub_inbox_like', array( self::class, 'incoming' ), 10, 2 ); + \add_action( 'activitypub_handled_outbox_like', array( self::class, 'outgoing' ), 10, 4 ); \add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) ); } /** - * Handles "Like" requests. + * Handle incoming "Like" requests from remote actors. * * @param array $like The Activity array. * @param int|int[] $user_ids The user ID(s). */ - public static function handle_like( $like, $user_ids ) { + public static function incoming( $like, $user_ids ) { if ( ! Comment::is_comment_type_enabled( 'like' ) ) { return; } @@ -67,7 +67,7 @@ public static function handle_like( $like, $user_ids ) { } /** - * Handle outbox "Like" activities (C2S). + * Handle outgoing "Like" activities from local actors. * * Records a like from the local user on remote content. * @@ -76,7 +76,7 @@ public static function handle_like( $like, $user_ids ) { * @param \Activitypub\Activity\Activity $activity The Activity object. * @param int $outbox_id The outbox post ID. */ - public static function handle_outbox_like( $data, $user_id, $activity, $outbox_id ) { + public static function outgoing( $data, $user_id, $activity, $outbox_id ) { $object_url = object_to_uri( $data['object'] ?? '' ); if ( empty( $object_url ) ) { @@ -84,7 +84,7 @@ public static function handle_outbox_like( $data, $user_id, $activity, $outbox_i } /** - * Fires after a Like activity has been sent via C2S. + * Fires after an outgoing Like activity has been processed. * * @param string $object_url The URL of the liked object. * @param array $data The activity data. @@ -107,4 +107,34 @@ public static function outbox_activity( $activity ) { return $activity; } + + /** + * Handle "Like" requests. + * + * @deprecated unreleased Use Like::incoming() instead. + * + * @param array $like The Activity array. + * @param int|int[] $user_ids The user ID(s). + */ + public static function handle_like( $like, $user_ids ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Like::incoming()' ); + + return self::incoming( $like, $user_ids ); + } + + /** + * Handle outbox "Like" activities. + * + * @deprecated unreleased Use Like::outgoing() instead. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_like( $data, $user_id, $activity, $outbox_id ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Like::outgoing()' ); + + return self::outgoing( $data, $user_id, $activity, $outbox_id ); + } } diff --git a/includes/handler/class-undo.php b/includes/handler/class-undo.php index d280b7f635..a920a98850 100644 --- a/includes/handler/class-undo.php +++ b/includes/handler/class-undo.php @@ -21,18 +21,18 @@ class Undo { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo' ), 10, 2 ); - \add_action( 'activitypub_handled_outbox_undo', array( self::class, 'handle_outbox_undo' ), 10, 4 ); + \add_action( 'activitypub_inbox_undo', array( self::class, 'incoming' ), 10, 2 ); + \add_action( 'activitypub_handled_outbox_undo', array( self::class, 'outgoing' ), 10, 4 ); \add_action( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); } /** - * Handle "Unfollow" requests. + * Handle incoming "Undo" requests from remote actors. * * @param array $activity The JSON "Undo" Activity. * @param int|int[]|null $user_ids The user ID(s). */ - public static function handle_undo( $activity, $user_ids ) { + public static function incoming( $activity, $user_ids ) { $success = false; $result = Inbox_Collection::undo( object_to_uri( $activity['object'] ) ); @@ -87,7 +87,7 @@ public static function validate_object( $valid, $param, $request ) { } /** - * Handle outbox "Undo" activities (C2S). + * Handle outgoing "Undo" activities from local actors. * * Handles Undo Follow (unfollow) activities. * @@ -96,7 +96,7 @@ public static function validate_object( $valid, $param, $request ) { * @param \Activitypub\Activity\Activity $activity The Activity object. * @param int $outbox_id The outbox post ID. */ - public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_id ) { + public static function outgoing( $data, $user_id, $activity, $outbox_id ) { $object = $data['object'] ?? array(); if ( ! \is_array( $object ) ) { @@ -129,7 +129,7 @@ public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_i \delete_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, $user_id ); /** - * Fires after an Undo Follow activity has been sent via C2S. + * Fires after an outgoing Undo Follow activity has been processed. * * @param int $remote_actor_id The remote actor post ID. * @param array $data The activity data. @@ -138,4 +138,34 @@ public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_i */ \do_action( 'activitypub_outbox_undo_follow_sent', $remote_actor->ID, $data, $user_id, $outbox_id ); } + + /** + * Handle "Undo" requests. + * + * @deprecated unreleased Use Undo::incoming() instead. + * + * @param array $activity The JSON "Undo" Activity. + * @param int|int[]|null $user_ids The user ID(s). + */ + public static function handle_undo( $activity, $user_ids ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Undo::incoming()' ); + + return self::incoming( $activity, $user_ids ); + } + + /** + * Handle outbox "Undo" activities. + * + * @deprecated unreleased Use Undo::outgoing() instead. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_id ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Undo::outgoing()' ); + + return self::outgoing( $data, $user_id, $activity, $outbox_id ); + } } diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index 19803a69f6..fc22d302e4 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -22,18 +22,18 @@ class Update { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_handled_inbox_update', array( self::class, 'handle_update' ), 10, 3 ); - \add_action( 'activitypub_handled_outbox_update', array( self::class, 'handle_outbox_update' ), 10, 4 ); + \add_action( 'activitypub_handled_inbox_update', array( self::class, 'incoming' ), 10, 3 ); + \add_action( 'activitypub_handled_outbox_update', array( self::class, 'outgoing' ), 10, 4 ); } /** - * Handle "Update" requests. + * Handle incoming "Update" requests from remote actors. * * @param array $activity The Activity object. * @param int[] $user_ids The user IDs. Always null for Update activities. * @param \Activitypub\Activity\Activity $activity_object The activity object. Default null. */ - public static function handle_update( $activity, $user_ids, $activity_object ) { + public static function incoming( $activity, $user_ids, $activity_object ) { $object_type = $activity['object']['type'] ?? ''; switch ( $object_type ) { @@ -105,7 +105,7 @@ public static function update_object( $activity, $user_ids, $activity_object ) { // There is no object to update, try to trigger create instead. if ( ! $updated ) { - return Create::handle_create( $activity, $user_ids, $activity_object ); + return Create::incoming( $activity, $user_ids, $activity_object ); } $success = ( $result && ! \is_wp_error( $result ) ); @@ -149,7 +149,7 @@ public static function update_actor( $activity, $user_ids ) { } /** - * Handle outbox "Update" activities (C2S). + * Handle outgoing "Update" activities from local actors. * * Updates a WordPress post from the ActivityPub object. * @@ -158,7 +158,7 @@ public static function update_actor( $activity, $user_ids ) { * @param \Activitypub\Activity\Activity $activity The Activity object. * @param int $outbox_id The outbox post ID. */ - public static function handle_outbox_update( $data, $user_id, $activity, $outbox_id ) { + public static function outgoing( $data, $user_id, $activity, $outbox_id ) { $object = $data['object'] ?? array(); if ( ! \is_array( $object ) ) { @@ -214,7 +214,7 @@ public static function handle_outbox_update( $data, $user_id, $activity, $outbox } /** - * Fires after a post has been updated from a C2S Update activity. + * Fires after a post has been updated from an outgoing Update activity. * * @param int $post_id The updated post ID. * @param array $data The activity data. @@ -223,4 +223,35 @@ public static function handle_outbox_update( $data, $user_id, $activity, $outbox */ \do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id ); } + + /** + * Handle "Update" requests. + * + * @deprecated unreleased Use Update::incoming() instead. + * + * @param array $activity The Activity object. + * @param int[] $user_ids The user IDs. + * @param \Activitypub\Activity\Activity $activity_object The activity object. + */ + public static function handle_update( $activity, $user_ids, $activity_object ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Update::incoming()' ); + + return self::incoming( $activity, $user_ids, $activity_object ); + } + + /** + * Handle outbox "Update" activities. + * + * @deprecated unreleased Use Update::outgoing() instead. + * + * @param array $data The activity data array. + * @param int $user_id The user ID. + * @param \Activitypub\Activity\Activity $activity The Activity object. + * @param int $outbox_id The outbox post ID. + */ + public static function handle_outbox_update( $data, $user_id, $activity, $outbox_id ) { + \_deprecated_function( __METHOD__, 'unreleased', 'Update::outgoing()' ); + + return self::outgoing( $data, $user_id, $activity, $outbox_id ); + } } diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index d41e48d98e..0a6476cfbc 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -15,7 +15,6 @@ use Activitypub\OAuth\Server as OAuth_Server; use function Activitypub\add_to_outbox; -use function Activitypub\camel_to_snake_case; use function Activitypub\get_masked_wp_version; use function Activitypub\get_rest_url_by_path; @@ -366,11 +365,10 @@ public function create_item_permissions_check( $request ) { } /** - * Create an item in the outbox (C2S). + * Create an item in the outbox. * - * Follows the same pattern as the Inbox controller: - * 1. Store the activity in the outbox - * 2. Trigger action hooks for handlers to process + * Fires handlers via filter to process the activity. Handlers are responsible + * for calling add_to_outbox() and returning the outbox_id. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error on failure. @@ -403,16 +401,38 @@ public function create_item( $request ) { $data = $this->wrap_in_create( $data, $user ); } - // Ensure the object has an ID (required for outbox storage). - $data = $this->ensure_object_id( $data, $user ); - - $activity_type = camel_to_snake_case( $data['type'] ?? '' ); - // Determine visibility from addressing. $visibility = $this->determine_visibility( $data ); - // Add to outbox - this handles storage and triggers federation. - $outbox_id = add_to_outbox( $data, null, $user_id, $visibility ); + $type = \strtolower( $data['type'] ?? 'create' ); + + /** + * Filters the activity to add to outbox. + * + * Handlers can process the activity and return: + * - int: The outbox post ID (handler called add_to_outbox) + * - WP_Error: Stop processing and return error + * - Other: No handler processed the activity (fallback to default) + * + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param string $visibility Content visibility. + */ + $result = \apply_filters( 'activitypub_outbox_' . $type, $data, $user_id, $visibility ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + // If handler returned an outbox ID, use it. + if ( \is_int( $result ) ) { + $outbox_id = $result; + } else { + // Default handling. + $data = \is_array( $result ) ? $result : $data; + $data = $this->ensure_object_id( $data, $user ); + $outbox_id = add_to_outbox( $data, null, $user_id, $visibility ); + } if ( ! $outbox_id || \is_wp_error( $outbox_id ) ) { return new \WP_Error( @@ -422,52 +442,9 @@ public function create_item( $request ) { ); } - // Get the stored activity for hooks. + // Get the stored activity. $activity = Outbox::get_activity( $outbox_id ); - /** - * Fires for each outbox activity. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param string $type The activity type (snake_case). - * @param \Activitypub\Activity\Activity $activity The Activity object. - */ - \do_action( 'activitypub_outbox', $data, $user_id, $activity_type, $activity ); - - /** - * Fires for specific outbox activity types. - * - * The dynamic portion of the hook name, `$activity_type`, refers to the - * activity type in snake_case (e.g., 'create', 'update', 'delete', 'like'). - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - */ - \do_action( 'activitypub_outbox_' . $activity_type, $data, $user_id, $activity ); - - /** - * Fires after an outbox activity has been stored. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param string $type The activity type (snake_case). - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_handled_outbox', $data, $user_id, $activity_type, $activity, $outbox_id ); - - /** - * Fires after a specific outbox activity type has been stored. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_handled_outbox_' . $activity_type, $data, $user_id, $activity, $outbox_id ); - if ( \is_wp_error( $activity ) ) { return $activity; } diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index e8ea1c32b0..171cf117ca 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -101,6 +101,12 @@ public static function triage( $post_id, $post, $update, $post_before ) { return; } + // If the post was already federated and this is a Create, skip. + // The outbox controller already added it to the outbox. + if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status && 'Create' === $type ) { + return; + } + // If the post was never federated before, it should be a Create activity. if ( empty( $object_status ) && 'Update' === $type ) { $type = 'Create'; From 744fbee4e6c66ad4851228dfab3a05d1039621ba Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 18:31:14 +0100 Subject: [PATCH 012/105] Simplify C2S outbox flow with synchronous add_to_outbox - Remove async scheduling from Post scheduler, call add_to_outbox directly - Create handler returns WP_Post instead of calling add_to_outbox - Add Outbox::get_by_object_id() to find outbox items by object ID and type - Controller handles WP_Post return from handlers and uses outbox_item directly --- includes/collection/class-outbox.php | 31 ++++++++++++++++ includes/handler/class-create.php | 10 ++---- includes/rest/class-outbox-controller.php | 24 +++++++------ includes/scheduler/class-post.php | 43 ++--------------------- 4 files changed, 49 insertions(+), 59 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 19a70c4d98..06753cca4d 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -190,6 +190,37 @@ public static function undo( $outbox_item ) { return add_to_outbox( $activity, $type, $outbox_item->post_author, $visibility ); } + /** + * Get an outbox item by object ID and activity type. + * + * @param string $object_id The ActivityPub object ID. + * @param string $activity_type The activity type (Create, Update, etc.). + * + * @return \WP_Post|null The outbox item or null if not found. + */ + public static function get_by_object_id( $object_id, $activity_type ) { + $outbox_items = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'any', + 'posts_per_page' => 1, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_object_id', + 'value' => $object_id, + ), + array( + 'key' => '_activitypub_activity_type', + 'value' => $activity_type, + ), + ), + ) + ); + + return ! empty( $outbox_items ) ? $outbox_items[0] : null; + } + /** * Get an outbox item by its GUID. * diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index 228efb4c08..ef344a8c32 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -11,7 +11,6 @@ use Activitypub\Collection\Posts; use Activitypub\Tombstone; -use function Activitypub\add_to_outbox; use function Activitypub\get_activity_visibility; use function Activitypub\get_content_visibility; use function Activitypub\is_activity_reply; @@ -175,13 +174,13 @@ private static function incoming_post( $activity, $user_ids ) { /** * Handle outgoing post from local actor. * - * Creates a WordPress post and adds to outbox for federation. + * Creates a WordPress post. The scheduler will add it to the outbox. * * @param array $activity The activity data. * @param int $user_id The local user ID. * @param string|null $visibility Content visibility. * - * @return int|\WP_Error The outbox ID on success, WP_Error on failure. + * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure. */ private static function outgoing_post( $activity, $user_id, $visibility ) { $object = $activity['object'] ?? array(); @@ -210,8 +209,6 @@ private static function outgoing_post( $activity, $user_id, $visibility ) { 'post_type' => 'post', 'meta_input' => array( 'activitypub_content_visibility' => $visibility, - // Mark the post as federated to prevent the scheduler from also adding it to outbox. - 'activitypub_status' => ACTIVITYPUB_OBJECT_STATE_FEDERATED, ), ); @@ -233,8 +230,7 @@ private static function outgoing_post( $activity, $user_id, $visibility ) { */ \do_action( 'activitypub_outbox_created_post', $post_id, $activity, $user_id, $visibility ); - // Add to outbox and return the outbox ID. - return add_to_outbox( $post, 'Create', $user_id, $visibility ); + return $post; } /** diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 0a6476cfbc..67f58073f7 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -410,7 +410,7 @@ public function create_item( $request ) { * Filters the activity to add to outbox. * * Handlers can process the activity and return: - * - int: The outbox post ID (handler called add_to_outbox) + * - WP_Post: A WordPress post was created (scheduler adds to outbox) * - WP_Error: Stop processing and return error * - Other: No handler processed the activity (fallback to default) * @@ -424,17 +424,19 @@ public function create_item( $request ) { return $result; } - // If handler returned an outbox ID, use it. - if ( \is_int( $result ) ) { - $outbox_id = $result; + // If handler returned a WP_Post, the scheduler already added it to outbox. + if ( $result instanceof \WP_Post ) { + $object_id = \Activitypub\get_post_id( $result->ID ); + $activity_type = \ucfirst( $data['type'] ?? 'Create' ); + $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type ); } else { - // Default handling. - $data = \is_array( $result ) ? $result : $data; - $data = $this->ensure_object_id( $data, $user ); - $outbox_id = add_to_outbox( $data, null, $user_id, $visibility ); + // Default handling for raw activities. + $data = \is_array( $result ) ? $result : $data; + $data = $this->ensure_object_id( $data, $user ); + $outbox_item = \get_post( add_to_outbox( $data, null, $user_id, $visibility ) ); } - if ( ! $outbox_id || \is_wp_error( $outbox_id ) ) { + if ( ! $outbox_item ) { return new \WP_Error( 'activitypub_outbox_error', \__( 'Failed to add activity to outbox.', 'activitypub' ), @@ -443,7 +445,7 @@ public function create_item( $request ) { } // Get the stored activity. - $activity = Outbox::get_activity( $outbox_id ); + $activity = Outbox::get_activity( $outbox_item ); if ( \is_wp_error( $activity ) ) { return $activity; @@ -453,7 +455,7 @@ public function create_item( $request ) { // Return 201 Created with Location header. $response = new \WP_REST_Response( $result, 201 ); - $response->header( 'Location', $result['id'] ?? \get_the_guid( $outbox_id ) ); + $response->header( 'Location', $result['id'] ?? $outbox_item->guid ); $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); return $response; diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 171cf117ca..236d5ea0a1 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -27,9 +27,6 @@ public static function init() { // Post transitions. \add_action( 'wp_after_insert_post', array( self::class, 'triage' ), 33, 4 ); - // Async handler for add_to_outbox. - \add_action( 'activitypub_add_to_outbox', array( self::class, 'add_to_outbox' ), 10, 3 ); - // Attachment transitions. \add_action( 'add_attachment', array( self::class, 'transition_attachment_status' ) ); \add_action( 'edit_attachment', array( self::class, 'transition_attachment_status' ) ); @@ -117,37 +114,7 @@ public static function triage( $post_id, $post, $update, $post_before ) { $type = 'Delete'; } - // Schedule async add to outbox to avoid blocking post save. - $scheduled = \wp_schedule_single_event( time(), 'activitypub_add_to_outbox', array( $post_id, $type, $post->post_author ) ); - - // Fall back to synchronous execution if scheduling fails (e.g., in tests or when cron is disabled). - if ( true !== $scheduled ) { - add_to_outbox( $post, $type, $post->post_author ); - } - } - - /** - * Async handler for adding a post to the outbox. - * - * This runs asynchronously via WP Cron to avoid blocking the post save process. - * - * @param int $post_id Post ID. - * @param string $type Activity type (Create, Update, Delete). - * @param int $user_id User ID. - */ - public static function add_to_outbox( $post_id, $type, $user_id ) { - $post = \get_post( $post_id ); - - if ( ! $post ) { - return; - } - - // Re-validate that the post is still eligible for federation. - if ( is_post_disabled( $post ) ) { - return; - } - - add_to_outbox( $post, $type, $user_id ); + add_to_outbox( $post, $type, $post->post_author ); } /** @@ -188,13 +155,7 @@ public static function transition_attachment_status( $post_id ) { return; } - // Schedule async add to outbox to avoid blocking attachment save. - $scheduled = \wp_schedule_single_event( time(), 'activitypub_add_to_outbox', array( $post_id, $type, $post->post_author ) ); - - // Fall back to synchronous execution if scheduling fails (e.g., in tests or when cron is disabled). - if ( true !== $scheduled ) { - add_to_outbox( $post, $type, $post->post_author ); - } + add_to_outbox( $post, $type, $post->post_author ); } /** From 63055f1eeb50f6c9dc1cbb061f37b144d82390f8 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 1 Feb 2026 18:54:47 +0100 Subject: [PATCH 013/105] Improve post lookup and OAuth handling for C2S Update delete and update handlers to first resolve posts by permalink for C2S-created posts, falling back to GUID lookup for remote posts. Enhance OAuth server to respect previous auth errors and only process OAuth if C2S is enabled. Add type safety for user_id in REST controllers. Update template variable documentation and add PHPCS ignore comment in token class. --- includes/handler/class-delete.php | 9 ++++++++- includes/handler/class-update.php | 9 ++++++++- includes/oauth/class-server.php | 10 ++++++++++ includes/oauth/class-token.php | 2 +- includes/rest/class-inbox-controller.php | 3 ++- includes/rest/class-outbox-controller.php | 3 ++- templates/oauth-authorize.php | 22 ++++++++++++---------- 7 files changed, 43 insertions(+), 15 deletions(-) diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 6081181e4c..d66bc82b96 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -376,7 +376,14 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { } // Find the post by its ActivityPub ID. - $post = Posts::get_by_guid( $object_id ); + // First try to find a local post by permalink (for C2S-created posts). + $post_id = \url_to_postid( $object_id ); + $post = $post_id ? \get_post( $post_id ) : null; + + // Fall back to Posts collection for remote posts (ap_post type). + if ( ! $post instanceof \WP_Post ) { + $post = Posts::get_by_guid( $object_id ); + } if ( ! $post instanceof \WP_Post ) { return; diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index fc22d302e4..d80ff3c9ad 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -179,7 +179,14 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { } // Find the post by its ActivityPub ID. - $post = Posts::get_by_guid( $object_id ); + // First try to find a local post by permalink (for C2S-created posts). + $post_id = \url_to_postid( $object_id ); + $post = $post_id ? \get_post( $post_id ) : null; + + // Fall back to Posts collection for remote posts (ap_post type). + if ( ! $post instanceof \WP_Post ) { + $post = Posts::get_by_guid( $object_id ); + } if ( ! $post instanceof \WP_Post ) { return; diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index c37422acf8..0718e7202f 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -48,6 +48,11 @@ public static function authenticate_oauth( $result ) { return $result; } + // If a previous auth filter returned an error, respect it. + if ( \is_wp_error( $result ) ) { + return $result; + } + // Check for Bearer token. $token = self::get_bearer_token(); @@ -56,6 +61,11 @@ public static function authenticate_oauth( $result ) { return $result; } + // Only process OAuth if C2S is enabled. + if ( ! self::is_c2s_enabled() ) { + return $result; + } + // Validate the token. $validated = Token::validate( $token ); diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index 85b43b6eef..cc44ec3822 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -364,7 +364,7 @@ public static function get_all_for_user( $user_id ) { if ( is_array( $token_data ) ) { // Don't expose hashes. unset( $token_data['access_token_hash'], $token_data['refresh_token_hash'] ); - $token_data['meta_key'] = $meta_key; + $token_data['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a DB query, just array key. $tokens[] = $token_data; } } diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 2c3bc5cecd..dd668197e2 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -242,7 +242,8 @@ public function get_items_permissions_check( $request ) { } // Verify the token belongs to the requested user. - $token = OAuth_Server::get_current_token(); + $token = OAuth_Server::get_current_token(); + $user_id = absint( $user_id ); if ( ! $token || $token->get_user_id() !== $user_id ) { return new \WP_Error( diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 67f58073f7..b959a7a87b 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -17,6 +17,7 @@ use function Activitypub\add_to_outbox; use function Activitypub\get_masked_wp_version; use function Activitypub\get_rest_url_by_path; +use function Activitypub\object_to_uri; /** * ActivityPub Outbox Controller. @@ -350,7 +351,7 @@ public function create_item_permissions_check( $request ) { } // Token user must match actor in URL. - $user_id = $request->get_param( 'user_id' ); + $user_id = absint( $request->get_param( 'user_id' ) ); $token = OAuth_Server::get_current_token(); if ( ! $token || $token->get_user_id() !== $user_id ) { diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php index 145fee0251..13324adb5d 100644 --- a/templates/oauth-authorize.php +++ b/templates/oauth-authorize.php @@ -4,19 +4,21 @@ * * @package Activitypub * - * Variables available: - * @var WP_User $current_user The current logged-in user. - * @var array $scopes Array of requested scopes. - * @var string $client_id The client ID. - * @var string $client_name The client name. - * @var string $redirect_uri The redirect URI. - * @var string $state The state parameter. - * @var string $code_challenge The PKCE code challenge. + * Variables available (passed via include from class-server.php): + * @var WP_User $current_user The current logged-in user. + * @var array $scopes Array of requested scopes. + * @var string $client_id The client ID. + * @var string $client_name The client name. + * @var string $redirect_uri The redirect URI. + * @var string $state The state parameter. + * @var string $code_challenge The PKCE code challenge. * @var string $code_challenge_method The PKCE method. - * @var string $form_url The form action URL. - * @var string $scope The original scope string. + * @var string $form_url The form action URL. + * @var string $scope The original scope string. */ +// phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable -- Variables passed via include. + use Activitypub\OAuth\Scope; // Use WordPress login page header. From c10894c739008457bc58303a0d2a1f5343aeff43 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 2 Feb 2026 08:36:14 +0100 Subject: [PATCH 014/105] Fix argument passed to send_to_inboxes in test Pass the outbox item's ID instead of the object itself to the send_to_inboxes method in the test case. This aligns the test with the expected method signature. --- tests/phpunit/tests/includes/class-test-dispatcher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/includes/class-test-dispatcher.php b/tests/phpunit/tests/includes/class-test-dispatcher.php index 1deee4b33d..6f3d8f67bd 100644 --- a/tests/phpunit/tests/includes/class-test-dispatcher.php +++ b/tests/phpunit/tests/includes/class-test-dispatcher.php @@ -127,7 +127,7 @@ public function test_send_to_inboxes_schedules_retry( $code, $message, $inboxes, // Invoke the method. try { - $retries = $send_to_inboxes->invoke( null, $inboxes, $outbox_item ); // null for static methods. + $retries = $send_to_inboxes->invoke( null, $inboxes, $outbox_item->ID ); // null for static methods. } catch ( \Exception $e ) { $this->fail( 'Invoke failed: ' . $e->getMessage() ); } From 76b2e028ac28b1be6661c89cb679dfbf5238cd50 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 2 Feb 2026 08:58:43 +0100 Subject: [PATCH 015/105] Add proxyUrl endpoint and enable C2S by default - Add proxyUrl endpoint for C2S clients to fetch remote ActivityPub objects through the server's HTTP Signatures - Remove activitypub_enable_c2s option - C2S is now always enabled - Remove settings field for C2S toggle from advanced settings - Always include OAuth and C2S endpoints in actor profiles - Add security checks for proxy: HTTPS-only, block private networks - Use Remote_Actors::fetch_by_various() for efficient actor caching --- activitypub.php | 1 + includes/class-options.php | 10 - includes/model/class-blog.php | 15 +- includes/model/class-user.php | 15 +- includes/oauth/class-server.php | 20 +- includes/rest/class-inbox-controller.php | 9 - includes/rest/class-oauth-controller.php | 36 ---- includes/rest/class-outbox-controller.php | 9 - includes/rest/class-proxy-controller.php | 197 ++++++++++++++++++ .../class-advanced-settings-fields.php | 40 ---- .../rest/class-test-proxy-controller.php | 186 +++++++++++++++++ 11 files changed, 398 insertions(+), 140 deletions(-) create mode 100644 includes/rest/class-proxy-controller.php create mode 100644 tests/phpunit/tests/rest/class-test-proxy-controller.php diff --git a/activitypub.php b/activitypub.php index 2f722ae9d2..33279c69f8 100644 --- a/activitypub.php +++ b/activitypub.php @@ -72,6 +72,7 @@ function rest_init() { // Load OAuth REST endpoints. ( new Rest\OAuth_Controller() )->register_routes(); + ( new Rest\Proxy_Controller() )->register_routes(); } \add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' ); diff --git a/includes/class-options.php b/includes/class-options.php index 42f3714b03..54b04da7c1 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -330,16 +330,6 @@ public static function register_settings() { ) ); - \register_setting( - 'activitypub_advanced', - 'activitypub_enable_c2s', - array( - 'type' => 'boolean', - 'description' => 'Enable Client-to-Server (C2S) support for third-party ActivityPub clients.', - 'default' => false, - ) - ); - /* * Options Group: activitypub_blog */ diff --git a/includes/model/class-blog.php b/includes/model/class-blog.php index 7dedf2377e..9e4631caca 100644 --- a/includes/model/class-blog.php +++ b/includes/model/class-blog.php @@ -396,17 +396,12 @@ public function get_following() { * @return string[]|null The endpoints. */ public function get_endpoints() { - $endpoints = array( - 'sharedInbox' => get_rest_url_by_path( 'inbox' ), + return array( + 'sharedInbox' => get_rest_url_by_path( 'inbox' ), + 'oauthAuthorizationEndpoint' => get_rest_url_by_path( 'oauth/authorize' ), + 'oauthTokenEndpoint' => get_rest_url_by_path( 'oauth/token' ), + 'proxyUrl' => get_rest_url_by_path( 'proxy' ), ); - - // Add OAuth endpoints if C2S is enabled. - if ( \get_option( 'activitypub_enable_c2s', false ) ) { - $endpoints['oauthAuthorizationEndpoint'] = get_rest_url_by_path( 'oauth/authorize' ); - $endpoints['oauthTokenEndpoint'] = get_rest_url_by_path( 'oauth/token' ); - } - - return $endpoints; } /** diff --git a/includes/model/class-user.php b/includes/model/class-user.php index 57ae3b127c..8f85ea5532 100644 --- a/includes/model/class-user.php +++ b/includes/model/class-user.php @@ -317,17 +317,12 @@ public function get_featured_tags() { * @return string[]|null The endpoints. */ public function get_endpoints() { - $endpoints = array( - 'sharedInbox' => get_rest_url_by_path( 'inbox' ), + return array( + 'sharedInbox' => get_rest_url_by_path( 'inbox' ), + 'oauthAuthorizationEndpoint' => get_rest_url_by_path( 'oauth/authorize' ), + 'oauthTokenEndpoint' => get_rest_url_by_path( 'oauth/token' ), + 'proxyUrl' => get_rest_url_by_path( 'proxy' ), ); - - // Add OAuth endpoints if C2S is enabled. - if ( \get_option( 'activitypub_enable_c2s', false ) ) { - $endpoints['oauthAuthorizationEndpoint'] = get_rest_url_by_path( 'oauth/authorize' ); - $endpoints['oauthTokenEndpoint'] = get_rest_url_by_path( 'oauth/token' ); - } - - return $endpoints; } /** diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 0718e7202f..23e5382537 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -61,11 +61,6 @@ public static function authenticate_oauth( $result ) { return $result; } - // Only process OAuth if C2S is enabled. - if ( ! self::is_c2s_enabled() ) { - return $result; - } - // Validate the token. $validated = Token::validate( $token ); @@ -222,10 +217,12 @@ public static function check_oauth_permission( $request, $scope = null ) { /** * Check if C2S (Client-to-Server) is enabled. * - * @return bool True if C2S is enabled. + * @deprecated C2S is now always enabled. + * + * @return bool Always returns true. */ public static function is_c2s_enabled() { - return (bool) \get_option( 'activitypub_enable_c2s', false ); + return true; } /** @@ -276,15 +273,6 @@ public static function login_form_authorize() { \auth_redirect(); } - // Check if C2S is enabled. - if ( ! self::is_c2s_enabled() ) { - \wp_die( - \esc_html__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), - \esc_html__( 'Authorization Error', 'activitypub' ), - array( 'response' => 403 ) - ); - } - $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; if ( 'GET' === $request_method ) { diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index dd668197e2..9e71439acf 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -217,15 +217,6 @@ public function validate_user_id( $user_id ) { * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public function get_items_permissions_check( $request ) { - // Check if C2S is enabled. - if ( ! OAuth_Server::is_c2s_enabled() ) { - return new \WP_Error( - 'activitypub_c2s_disabled', - \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - $user_id = $request->get_param( 'user_id' ); // Validate the user. diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index c52b3d8f3b..c6d91561ee 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -200,15 +200,6 @@ public function register_routes() { * @return \WP_REST_Response|\WP_Error */ public function authorize( \WP_REST_Request $request ) { - // Check if C2S is enabled. - if ( ! OAuth_Server::is_c2s_enabled() ) { - return new \WP_Error( - 'activitypub_c2s_disabled', - \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - $client_id = $request->get_param( 'client_id' ); $redirect_uri = $request->get_param( 'redirect_uri' ); $response_type = $request->get_param( 'response_type' ); @@ -282,15 +273,6 @@ public function authorize( \WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public function authorize_submit( \WP_REST_Request $request ) { - // Check if C2S is enabled. - if ( ! OAuth_Server::is_c2s_enabled() ) { - return new \WP_Error( - 'activitypub_c2s_disabled', - \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - $client_id = $request->get_param( 'client_id' ); $redirect_uri = $request->get_param( 'redirect_uri' ); $scope = $request->get_param( 'scope' ); @@ -380,15 +362,6 @@ public function authorize_submit_permissions_check( \WP_REST_Request $request ) * @return \WP_REST_Response|\WP_Error */ public function token( \WP_REST_Request $request ) { - // Check if C2S is enabled. - if ( ! OAuth_Server::is_c2s_enabled() ) { - return new \WP_Error( - 'activitypub_c2s_disabled', - \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - $grant_type = $request->get_param( 'grant_type' ); $client_id = $request->get_param( 'client_id' ); @@ -509,15 +482,6 @@ public function introspect( \WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public function register_client( \WP_REST_Request $request ) { - // Check if C2S is enabled. - if ( ! OAuth_Server::is_c2s_enabled() ) { - return new \WP_Error( - 'activitypub_c2s_disabled', - \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - // Check if dynamic registration is allowed. if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', true ) ) { return new \WP_Error( diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index b959a7a87b..d9713e7438 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -335,15 +335,6 @@ public function overload_total_items( $response, $request ) { * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public function create_item_permissions_check( $request ) { - // Check if C2S is enabled. - if ( ! OAuth_Server::is_c2s_enabled() ) { - return new \WP_Error( - 'activitypub_c2s_disabled', - \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - // Must be authenticated via OAuth with 'write' scope. $permission = OAuth_Server::check_oauth_permission( $request, Scope::WRITE ); if ( \is_wp_error( $permission ) ) { diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php new file mode 100644 index 0000000000..6824d1c0c5 --- /dev/null +++ b/includes/rest/class-proxy-controller.php @@ -0,0 +1,197 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => 'The URI of the remote ActivityPub object to fetch.', + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_url', + ), + ), + ), + 'schema' => array( $this, 'get_item_schema' ), + ) + ); + } + + /** + * Check if the request has permission to use the proxy. + * + * @param \WP_REST_Request $request Full details about the request. + * @return true|\WP_Error True if the request has permission, WP_Error otherwise. + */ + public function get_item_permissions_check( $request ) { + // Must be authenticated via OAuth with 'read' scope. + $permission = OAuth_Server::check_oauth_permission( $request, Scope::READ ); + if ( \is_wp_error( $permission ) ) { + return $permission; + } + + // Validate the URL to prevent abuse. + $url = $request->get_param( 'id' ); + + // Must be HTTPS. + if ( 'https' !== \wp_parse_url( $url, PHP_URL_SCHEME ) ) { + return new \WP_Error( + 'activitypub_invalid_url', + \__( 'Only HTTPS URLs are allowed.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Block local/private network addresses. + $host = \wp_parse_url( $url, PHP_URL_HOST ); + if ( $this->is_private_host( $host ) ) { + return new \WP_Error( + 'activitypub_invalid_url', + \__( 'Private network addresses are not allowed.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * Fetch a remote ActivityPub object via the proxy. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, WP_Error on failure. + */ + public function get_item( $request ) { + $url = $request->get_param( 'id' ); + + // Try to fetch as an actor first using Remote_Actors which handles caching. + $post = Remote_Actors::fetch_by_various( $url ); + + if ( ! \is_wp_error( $post ) ) { + $actor = Remote_Actors::get_actor( $post ); + + if ( ! \is_wp_error( $actor ) ) { + $response = new \WP_REST_Response( $actor->to_array(), 200 ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + } + + // Fall back to fetching as a generic object. + $object = Http::get_remote_object( $url ); + + if ( \is_wp_error( $object ) ) { + return new \WP_Error( + 'activitypub_fetch_failed', + \__( 'Failed to fetch the remote object.', 'activitypub' ), + array( 'status' => 502 ) + ); + } + + // If it's an actor, store it for future use. + if ( is_actor( $object ) ) { + Remote_Actors::upsert( $object ); + } + + $response = new \WP_REST_Response( $object, 200 ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Check if a host is a private/local network address. + * + * @param string $host The hostname to check. + * @return bool True if the host is private, false otherwise. + */ + private function is_private_host( $host ) { + // Check for localhost. + if ( 'localhost' === $host || '127.0.0.1' === $host || '::1' === $host ) { + return true; + } + + // Check for private IP ranges. + $ip = \gethostbyname( $host ); + if ( $ip === $host ) { + // DNS resolution failed, allow it (will fail on fetch anyway). + return false; + } + + // Use filter_var to check for private/reserved IPs. + if ( false === \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + return true; + } + + return false; + } + + /** + * Get the schema for the proxy endpoint. + * + * @return array Schema array. + */ + public function get_item_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'proxy', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => \__( 'The URI of the remote ActivityPub object.', 'activitypub' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view' ), + ), + ), + ); + } +} diff --git a/includes/wp-admin/class-advanced-settings-fields.php b/includes/wp-admin/class-advanced-settings-fields.php index 20772f73c5..5efa7aff77 100644 --- a/includes/wp-admin/class-advanced-settings-fields.php +++ b/includes/wp-admin/class-advanced-settings-fields.php @@ -98,15 +98,6 @@ public static function register_advanced_fields() { 'activitypub_advanced_settings', array( 'label_for' => 'activitypub_object_type' ) ); - - \add_settings_field( - 'activitypub_enable_c2s', - \__( 'Client-to-Server (C2S)', 'activitypub' ), - array( self::class, 'render_enable_c2s_field' ), - 'activitypub_advanced_settings', - 'activitypub_advanced_settings', - array( 'label_for' => 'activitypub_enable_c2s' ) - ); } /** @@ -262,35 +253,4 @@ public static function render_object_type_field() {

-

- -

-

- -

-

- SWICG ActivityPub API specification, which is still under development. Some features may change in future versions.', 'activitypub' ), - array( - 'a' => array( - 'href' => true, - 'target' => true, - ), - ) - ); - ?> -

- user->create( array( 'role' => 'author' ) ); + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Set up test fixtures. + */ + public function set_up() { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new \WP_REST_Server(); + $this->server = $wp_rest_server; + + ( new Proxy_Controller() )->register_routes(); + } + + /** + * Clean up test resources. + */ + public static function tear_down_after_class() { + \wp_delete_user( self::$user_id ); + parent::tear_down_after_class(); + } + + /** + * Test that the proxy route is registered. + * + * @covers ::register_routes + */ + public function test_route_registered() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy', $routes ); + } + + /** + * Test that proxy rejects non-HTTPS URLs. + * + * @covers ::get_item_permissions_check + */ + public function test_http_url_rejected() { + // Mock OAuth authentication. + $this->mock_oauth_auth(); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_body_params( array( 'id' => 'http://example.com/users/test' ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'activitypub_invalid_url', $response->get_data()['code'] ); + + $this->unmock_oauth_auth(); + } + + /** + * Test that proxy rejects localhost URLs. + * + * @covers ::get_item_permissions_check + */ + public function test_localhost_rejected() { + // Mock OAuth authentication. + $this->mock_oauth_auth(); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_body_params( array( 'id' => 'https://localhost/users/test' ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'activitypub_invalid_url', $response->get_data()['code'] ); + + $this->unmock_oauth_auth(); + } + + /** + * Test proxy requires OAuth authentication. + * + * @covers ::get_item_permissions_check + */ + public function test_requires_oauth() { + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) ); + + $response = $this->server->dispatch( $request ); + + // Should fail with 401 or similar since no OAuth token is provided. + $this->assertNotEquals( 200, $response->get_status() ); + } + + /** + * Test successful proxy fetch of an actor. + * + * @covers ::get_item + */ + public function test_successful_actor_fetch() { + // Mock OAuth authentication. + $this->mock_oauth_auth(); + + // Mock the HTTP response. + $actor_data = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Person', + 'id' => 'https://example.com/users/test', + 'inbox' => 'https://example.com/users/test/inbox', + 'preferredUsername' => 'test', + 'name' => 'Test User', + ); + + \add_filter( + 'pre_http_request', + function () use ( $actor_data ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => \wp_json_encode( $actor_data ), + 'headers' => array( 'content-type' => 'application/activity+json' ), + ); + } + ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'Person', $data['type'] ); + $this->assertEquals( 'https://example.com/users/test', $data['id'] ); + + $this->unmock_oauth_auth(); + } + + /** + * Mock OAuth authentication for testing. + */ + private function mock_oauth_auth() { + \add_filter( 'activitypub_oauth_check_permission', '__return_true' ); + } + + /** + * Remove OAuth mock. + */ + private function unmock_oauth_auth() { + \remove_filter( 'activitypub_oauth_check_permission', '__return_true' ); + } +} From 60bc09d7c9897dbeea2938c740072ba2acbfd433 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 2 Feb 2026 09:10:51 +0100 Subject: [PATCH 016/105] Move OAuth verification to Server class - Add verify_oauth_read() and verify_oauth_write() methods to Server - Add verify_owner() to check token matches user_id parameter - Simplify permission checks in Inbox, Outbox, and Proxy controllers - Remove direct OAuth imports from controllers --- includes/rest/class-inbox-controller.php | 28 ++--------- includes/rest/class-outbox-controller.php | 25 +++------- includes/rest/class-proxy-controller.php | 10 ++-- includes/rest/class-server.php | 59 +++++++++++++++++++++++ 4 files changed, 72 insertions(+), 50 deletions(-) diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 9e71439acf..0ec0b373a9 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -14,8 +14,6 @@ use Activitypub\Collection\Inbox; use Activitypub\Http; use Activitypub\Moderation; -use Activitypub\OAuth\Scope; -use Activitypub\OAuth\Server as OAuth_Server; use function Activitypub\camel_to_snake_case; use function Activitypub\extract_recipients_from_activity; @@ -217,34 +215,14 @@ public function validate_user_id( $user_id ) { * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public function get_items_permissions_check( $request ) { - $user_id = $request->get_param( 'user_id' ); - - // Validate the user. - $user = Actors::get_by_id( $user_id ); - if ( \is_wp_error( $user ) ) { - return $user; - } - - // Validate OAuth token and scope. - $result = OAuth_Server::check_oauth_permission( $request, Scope::READ ); - + // Verify OAuth with read scope. + $result = Server::verify_oauth_read( $request ); if ( \is_wp_error( $result ) ) { return $result; } // Verify the token belongs to the requested user. - $token = OAuth_Server::get_current_token(); - $user_id = absint( $user_id ); - - if ( ! $token || $token->get_user_id() !== $user_id ) { - return new \WP_Error( - 'activitypub_unauthorized', - \__( 'You can only read your own inbox.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - - return true; + return Server::verify_owner( $request ); } /** diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index d9713e7438..9a5d50d108 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -11,8 +11,6 @@ use Activitypub\Activity\Base_Object; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; -use Activitypub\OAuth\Scope; -use Activitypub\OAuth\Server as OAuth_Server; use function Activitypub\add_to_outbox; use function Activitypub\get_masked_wp_version; @@ -335,25 +333,14 @@ public function overload_total_items( $response, $request ) { * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public function create_item_permissions_check( $request ) { - // Must be authenticated via OAuth with 'write' scope. - $permission = OAuth_Server::check_oauth_permission( $request, Scope::WRITE ); - if ( \is_wp_error( $permission ) ) { - return $permission; - } - - // Token user must match actor in URL. - $user_id = absint( $request->get_param( 'user_id' ) ); - $token = OAuth_Server::get_current_token(); - - if ( ! $token || $token->get_user_id() !== $user_id ) { - return new \WP_Error( - 'activitypub_forbidden', - \__( 'You can only post to your own outbox.', 'activitypub' ), - array( 'status' => 403 ) - ); + // Verify OAuth with write scope. + $result = Server::verify_oauth_write( $request ); + if ( \is_wp_error( $result ) ) { + return $result; } - return true; + // Verify the token belongs to the requested user. + return Server::verify_owner( $request ); } /** diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php index 6824d1c0c5..a5f84e1595 100644 --- a/includes/rest/class-proxy-controller.php +++ b/includes/rest/class-proxy-controller.php @@ -12,8 +12,6 @@ use Activitypub\Collection\Remote_Actors; use Activitypub\Http; -use Activitypub\OAuth\Scope; -use Activitypub\OAuth\Server as OAuth_Server; use function Activitypub\is_actor; @@ -71,10 +69,10 @@ public function register_routes() { * @return true|\WP_Error True if the request has permission, WP_Error otherwise. */ public function get_item_permissions_check( $request ) { - // Must be authenticated via OAuth with 'read' scope. - $permission = OAuth_Server::check_oauth_permission( $request, Scope::READ ); - if ( \is_wp_error( $permission ) ) { - return $permission; + // Verify OAuth with read scope. + $result = Server::verify_oauth_read( $request ); + if ( \is_wp_error( $result ) ) { + return $result; } // Validate the URL to prevent abuse. diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php index 3309eb5953..70a4f8a80f 100644 --- a/includes/rest/class-server.php +++ b/includes/rest/class-server.php @@ -7,6 +7,9 @@ namespace Activitypub\Rest; +use Activitypub\Collection\Actors; +use Activitypub\OAuth\Scope; +use Activitypub\OAuth\Server as OAuth_Server; use Activitypub\Signature; use function Activitypub\use_authorized_fetch; @@ -82,6 +85,62 @@ public static function verify_signature( $request ) { return true; } + /** + * Verify OAuth authentication with 'read' scope. + * + * Use this as a permission_callback for endpoints requiring OAuth read access. + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public static function verify_oauth_read( $request ) { + return OAuth_Server::check_oauth_permission( $request, Scope::READ ); + } + + /** + * Verify OAuth authentication with 'write' scope. + * + * Use this as a permission_callback for endpoints requiring OAuth write access. + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public static function verify_oauth_write( $request ) { + return OAuth_Server::check_oauth_permission( $request, Scope::WRITE ); + } + + /** + * Verify that the OAuth token belongs to the actor specified in the request. + * + * This checks that the user_id parameter matches the token's user. + * Should be called after verify_oauth_read or verify_oauth_write. + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if the token user matches, WP_Error otherwise. + */ + public static function verify_owner( $request ) { + $user_id = $request->get_param( 'user_id' ); + + // Validate the user exists. + $user = Actors::get_by_id( $user_id ); + if ( \is_wp_error( $user ) ) { + return $user; + } + + // Verify the token belongs to this user. + $token = OAuth_Server::get_current_token(); + + if ( ! $token || $token->get_user_id() !== absint( $user_id ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You can only access your own resources.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; + } + /** * Callback function to validate incoming ActivityPub requests * From efd8a5c6c748f7440f1cd0dbd1254b67b5c142d5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 2 Feb 2026 09:17:56 +0100 Subject: [PATCH 017/105] Add Verification trait for centralized auth checks - Create trait-verification.php with verify_signature, verify_oauth_read, verify_oauth_write, and verify_owner methods - Update controllers to use the trait instead of static Server methods - Maintain backwards compatibility by keeping static methods in Server class --- includes/rest/class-actors-controller.php | 4 +- .../rest/class-actors-inbox-controller.php | 2 +- includes/rest/class-followers-controller.php | 4 +- includes/rest/class-following-controller.php | 2 +- includes/rest/class-inbox-controller.php | 9 +- includes/rest/class-outbox-controller.php | 7 +- includes/rest/class-proxy-controller.php | 4 +- includes/rest/trait-verification.php | 128 ++++++++++++++++++ 8 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 includes/rest/trait-verification.php diff --git a/includes/rest/class-actors-controller.php b/includes/rest/class-actors-controller.php index 070ff1cf51..b4e8d2accd 100644 --- a/includes/rest/class-actors-controller.php +++ b/includes/rest/class-actors-controller.php @@ -18,6 +18,8 @@ * @see https://www.w3.org/TR/activitypub/#followers */ class Actors_Controller extends \WP_REST_Controller { + use Verification; + /** * The namespace of this controller's route. * @@ -51,7 +53,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'permission_callback' => array( $this, 'verify_signature' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 0836dae688..bb277e1a99 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -65,7 +65,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'permission_callback' => array( $this, 'verify_signature' ), 'args' => array( 'id' => array( 'description' => 'The unique identifier for the activity.', diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index b53e53e52b..7ae337716e 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -44,7 +44,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'permission_callback' => array( $this, 'verify_signature' ), 'args' => array( 'page' => array( 'description' => 'Current page of the collection.', @@ -92,7 +92,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_partial_followers' ), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'permission_callback' => array( $this, 'verify_signature' ), 'args' => array( 'authority' => array( 'description' => 'The host to filter followers by.', diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php index 3d4a438701..1e6300066a 100644 --- a/includes/rest/class-following-controller.php +++ b/includes/rest/class-following-controller.php @@ -44,7 +44,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'permission_callback' => array( $this, 'verify_signature' ), 'args' => array( 'page' => array( 'description' => 'Current page of the collection.', diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 0ec0b373a9..cab4304f79 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -33,6 +33,7 @@ */ class Inbox_Controller extends \WP_REST_Controller { use Collection; + use Verification; /** * The namespace of this controller's route. @@ -67,7 +68,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'permission_callback' => array( $this, 'verify_signature' ), 'args' => $this->get_create_item_args(), ), 'schema' => array( $this, 'get_item_schema' ), @@ -109,7 +110,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'permission_callback' => array( $this, 'verify_signature' ), 'args' => $this->get_create_item_args(), ), 'schema' => array( $this, 'get_item_schema' ), @@ -216,13 +217,13 @@ public function validate_user_id( $user_id ) { */ public function get_items_permissions_check( $request ) { // Verify OAuth with read scope. - $result = Server::verify_oauth_read( $request ); + $result = $this->verify_oauth_read( $request ); if ( \is_wp_error( $result ) ) { return $result; } // Verify the token belongs to the requested user. - return Server::verify_owner( $request ); + return $this->verify_owner( $request ); } /** diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 9a5d50d108..8d2720a835 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -26,6 +26,7 @@ */ class Outbox_Controller extends \WP_REST_Controller { use Collection; + use Verification; /** * The namespace of this controller's route. @@ -59,7 +60,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'permission_callback' => array( $this, 'verify_signature' ), 'args' => array( 'page' => array( 'description' => 'Current page of the collection.', @@ -334,13 +335,13 @@ public function overload_total_items( $response, $request ) { */ public function create_item_permissions_check( $request ) { // Verify OAuth with write scope. - $result = Server::verify_oauth_write( $request ); + $result = $this->verify_oauth_write( $request ); if ( \is_wp_error( $result ) ) { return $result; } // Verify the token belongs to the requested user. - return Server::verify_owner( $request ); + return $this->verify_owner( $request ); } /** diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php index a5f84e1595..570bb2fb03 100644 --- a/includes/rest/class-proxy-controller.php +++ b/includes/rest/class-proxy-controller.php @@ -22,6 +22,8 @@ * Allows C2S clients to fetch remote ActivityPub objects through their home server. */ class Proxy_Controller extends \WP_REST_Controller { + use Verification; + /** * The namespace of this controller's route. * @@ -70,7 +72,7 @@ public function register_routes() { */ public function get_item_permissions_check( $request ) { // Verify OAuth with read scope. - $result = Server::verify_oauth_read( $request ); + $result = $this->verify_oauth_read( $request ); if ( \is_wp_error( $result ) ) { return $result; } diff --git a/includes/rest/trait-verification.php b/includes/rest/trait-verification.php new file mode 100644 index 0000000000..1e2f7386fa --- /dev/null +++ b/includes/rest/trait-verification.php @@ -0,0 +1,128 @@ +get_method() ) { + return true; + } + + /** + * Filter to defer signature verification. + * + * Skip signature verification for debugging purposes or to reduce load for + * certain Activity-Types, like "Delete". + * + * @param bool $defer Whether to defer signature verification. + * @param \WP_REST_Request $request The request used to generate the response. + * @return bool Whether to defer signature verification. + */ + $defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request ); + + if ( $defer ) { + return true; + } + + // POST-Requests always have to be signed, GET-Requests only require a signature in secure mode. + if ( 'GET' !== $request->get_method() || use_authorized_fetch() ) { + $verified_request = Signature::verify_http_signature( $request ); + if ( \is_wp_error( $verified_request ) ) { + return new \WP_Error( + 'activitypub_signature_verification', + $verified_request->get_error_message(), + array( 'status' => 401 ) + ); + } + } + + return true; + } + + /** + * Verify OAuth authentication with 'read' scope. + * + * Use this for endpoints requiring OAuth read access (C2S). + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public function verify_oauth_read( $request ) { + return OAuth_Server::check_oauth_permission( $request, Scope::READ ); + } + + /** + * Verify OAuth authentication with 'write' scope. + * + * Use this for endpoints requiring OAuth write access (C2S). + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public function verify_oauth_write( $request ) { + return OAuth_Server::check_oauth_permission( $request, Scope::WRITE ); + } + + /** + * Verify that the OAuth token belongs to the actor specified in the request. + * + * This checks that the user_id parameter matches the token's user. + * Should be called after verify_oauth_read or verify_oauth_write. + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if the token user matches, WP_Error otherwise. + */ + public function verify_owner( $request ) { + $user_id = $request->get_param( 'user_id' ); + + // Validate the user exists. + $user = Actors::get_by_id( $user_id ); + if ( \is_wp_error( $user ) ) { + return $user; + } + + // Verify the token belongs to this user. + $token = OAuth_Server::get_current_token(); + + if ( ! $token || $token->get_user_id() !== absint( $user_id ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You can only access your own resources.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; + } +} From 01de894d3be17782dfad294e492210115f9b7d5d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 2 Feb 2026 09:33:44 +0100 Subject: [PATCH 018/105] Fix tests for deprecated handler methods and OAuth mock - Update handler tests to use incoming() instead of deprecated handle_* methods - Add activitypub_oauth_check_permission filter for test mocking - Fix proxy controller tests to use rest_api_init for route registration - Update assertions to match actual return values (false vs null) --- includes/oauth/class-server.php | 14 ++++ .../includes/handler/class-test-announce.php | 20 +++--- .../includes/handler/class-test-create.php | 64 +++++++++---------- .../includes/handler/class-test-follow.php | 18 +++--- .../includes/handler/class-test-like.php | 18 +++--- .../includes/handler/class-test-undo.php | 21 +++--- .../rest/class-test-proxy-controller.php | 2 +- 7 files changed, 85 insertions(+), 72 deletions(-) diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 23e5382537..d6900a0484 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -192,6 +192,20 @@ public static function generate_token( $length = 32 ) { * @return bool|\WP_Error True if authorized, error otherwise. */ public static function check_oauth_permission( $request, $scope = null ) { + /** + * Filter to override OAuth permission check. + * + * Useful for testing. Return true to bypass OAuth check, false to continue. + * + * @param bool|null $result The permission result. Null to continue normal check. + * @param \WP_REST_Request $request The REST request. + * @param string|null $scope Required scope. + */ + $override = \apply_filters( 'activitypub_oauth_check_permission', null, $request, $scope ); + if ( null !== $override ) { + return $override; + } + // Must be authenticated via OAuth. if ( ! self::is_oauth_request() ) { return new \WP_Error( diff --git a/tests/phpunit/tests/includes/handler/class-test-announce.php b/tests/phpunit/tests/includes/handler/class-test-announce.php index 1c6ec81bf9..f43d5ebcad 100644 --- a/tests/phpunit/tests/includes/handler/class-test-announce.php +++ b/tests/phpunit/tests/includes/handler/class-test-announce.php @@ -102,7 +102,7 @@ public static function create_test_object() { /** * Test handle announce. * - * @covers ::handle_announce + * @covers ::incoming */ public function test_handle_announce() { $external_actor = 'https://example.com/users/testuser'; @@ -116,7 +116,7 @@ public function test_handle_announce() { 'object' => $this->post_permalink, ); - Announce::handle_announce( $object, $this->user_id ); + Announce::incoming( $object, $this->user_id ); $args = array( 'type' => 'repost', @@ -132,7 +132,7 @@ public function test_handle_announce() { /** * Test handle announces. * - * @covers ::handle_announce + * @covers ::incoming * * @dataProvider data_handle_announces * @@ -146,7 +146,7 @@ public function test_handle_announces( $announce, $recursion, $message ) { \add_action( 'activitypub_inbox', array( $inbox_action, 'action' ) ); $activity = Activity::init_from_array( $announce ); - Announce::handle_announce( $announce, $this->user_id, $activity ); + Announce::incoming( $announce, $this->user_id, $activity ); $this->assertEquals( $recursion, $inbox_action->get_call_count(), $message ); } @@ -226,7 +226,7 @@ public static function data_handle_announces() { /** * Test that announces from the blog actor are ignored. * - * @covers ::handle_announce + * @covers ::incoming */ public function test_ignore_blog_actor_announce() { $blog = new Blog(); @@ -246,7 +246,7 @@ public function test_ignore_blog_actor_announce() { \add_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) ); // Call with blog actor as sender - should be ignored. - Announce::handle_announce( $object, $this->user_id ); + Announce::incoming( $object, $this->user_id ); // Verify the announce was NOT handled. $this->assertEquals( 0, $handled_action->get_call_count() ); @@ -268,7 +268,7 @@ public function test_ignore_blog_actor_announce() { /** * Test that announces from external actors are not ignored. * - * @covers ::handle_announce + * @covers ::incoming */ public function test_external_actor_announce_not_ignored() { $external_actor = 'https://external.example.com/users/someone'; @@ -287,7 +287,7 @@ public function test_external_actor_announce_not_ignored() { \add_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) ); // Call with external actor - should be processed. - Announce::handle_announce( $object, $this->user_id ); + Announce::incoming( $object, $this->user_id ); // Verify the announce WAS handled. $this->assertEquals( 1, $handled_action->get_call_count() ); @@ -310,7 +310,7 @@ public function test_external_actor_announce_not_ignored() { /** * Test that announces from same domain but different actor are not ignored. * - * @covers ::handle_announce + * @covers ::incoming */ public function test_same_domain_different_actor_not_ignored() { // Get a regular user actor URL (not the blog actor). @@ -330,7 +330,7 @@ public function test_same_domain_different_actor_not_ignored() { \add_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) ); // Call with same domain but user actor - should be processed. - Announce::handle_announce( $object, $this->user_id ); + Announce::incoming( $object, $this->user_id ); // Verify the announce WAS handled. $this->assertEquals( 1, $handled_action->get_call_count() ); diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index 6c80d9c699..25c95fc1fe 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -126,23 +126,23 @@ public function create_test_object( $id = 'https://example.com/123' ) { /** * Test handle create. * - * @covers ::handle_create + * @covers ::incoming */ public function test_handle_create_non_public_rejected() { $object = $this->create_test_object(); $object['cc'] = array(); - $converted = Create::handle_create( $object, $this->user_id ); - $this->assertNull( $converted ); + $converted = Create::incoming( $object, $this->user_id ); + $this->assertFalse( $converted ); } /** * Test handle create. * - * @covers ::handle_create + * @covers ::incoming */ public function test_handle_create_public_accepted() { $object = $this->create_test_object(); - Create::handle_create( $object, $this->user_id ); + Create::incoming( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -160,13 +160,13 @@ public function test_handle_create_public_accepted() { /** * Test handle create. * - * @covers ::handle_create + * @covers ::incoming */ public function test_handle_create_public_accepted_without_type() { $object = $this->create_test_object( 'https://example.com/123456' ); unset( $object['type'] ); - Create::handle_create( $object, $this->user_id ); + Create::incoming( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -183,12 +183,12 @@ public function test_handle_create_public_accepted_without_type() { /** * Test handle create check duplicate ID. * - * @covers ::handle_create + * @covers ::incoming */ public function test_handle_create_check_duplicate_id() { $id = 'https://example.com/id/' . microtime( true ); $object = $this->create_test_object( $id ); - Create::handle_create( $object, $this->user_id ); + Create::incoming( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -203,7 +203,7 @@ public function test_handle_create_check_duplicate_id() { $this->assertCount( 1, $result ); $object['object']['content'] = 'example2'; - Create::handle_create( $object, $this->user_id ); + Create::incoming( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -219,12 +219,12 @@ public function test_handle_create_check_duplicate_id() { /** * Test handle create check duplicate content. * - * @covers ::handle_create + * @covers ::incoming */ public function test_handle_create_check_duplicate_content() { $id = 'https://example.com/id/' . microtime( true ); $object = $this->create_test_object( $id ); - Create::handle_create( $object, $this->user_id ); + Create::incoming( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -240,7 +240,7 @@ public function test_handle_create_check_duplicate_content() { $id = 'https://example.com/id/' . microtime( true ); $object = $this->create_test_object( $id ); - Create::handle_create( $object, $this->user_id ); + Create::incoming( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -256,12 +256,12 @@ public function test_handle_create_check_duplicate_content() { /** * Test handle create multiple comments. * - * @covers ::handle_create + * @covers ::incoming */ public function test_handle_create_check_multiple_comments() { $id = 'https://example.com/id/4711'; $object = $this->create_test_object( $id ); - Create::handle_create( $object, $this->user_id ); + Create::incoming( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -278,7 +278,7 @@ public function test_handle_create_check_multiple_comments() { $id = 'https://example.com/id/23'; $object = $this->create_test_object( $id ); $object['object']['content'] = 'example2'; - Create::handle_create( $object, $this->user_id ); + Create::incoming( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -298,7 +298,7 @@ public function test_handle_create_check_multiple_comments() { /** * Test handling create activity for objects with content sanitization. * - * @covers ::handle_create + * @covers ::incoming * @covers ::create_post */ public function test_handle_create_object_with_sanitization() { @@ -340,7 +340,7 @@ public function test_handle_create_object_with_sanitization() { \update_option( 'activitypub_create_posts', true ); - Create::handle_create( $activity, $this->user_id ); + Create::incoming( $activity, $this->user_id ); // Verify the object was created with sanitized content. $created_object = Posts::get_by_guid( 'https://example.com/objects/note_sanitize' ); @@ -360,7 +360,7 @@ public function test_handle_create_object_with_sanitization() { /** * Test handling private create activity. * - * @covers ::handle_create + * @covers ::incoming */ public function test_handle_create_private_activity() { $private_activity = array( @@ -384,7 +384,7 @@ public function test_handle_create_private_activity() { ) ); - Create::handle_create( $private_activity, $this->user_id ); + Create::incoming( $private_activity, $this->user_id ); // Count objects after. $objects_after = get_posts( @@ -402,7 +402,7 @@ public function test_handle_create_private_activity() { /** * Test create activity with malformed object data. * - * @covers ::handle_create + * @covers ::incoming */ public function test_handle_create_malformed_object() { $malformed_activity = array( @@ -425,7 +425,7 @@ public function test_handle_create_malformed_object() { ) ); - Create::handle_create( $malformed_activity, $this->user_id ); + Create::incoming( $malformed_activity, $this->user_id ); // Count objects after. $objects_after = get_posts( @@ -441,11 +441,11 @@ public function test_handle_create_malformed_object() { } /** - * Test create_post returns false when activitypub_create_posts option is disabled. + * Test incoming returns false when activitypub_create_posts option is disabled. * - * @covers ::create_post + * @covers ::incoming */ - public function test_create_post_disabled_by_option() { + public function test_incoming_post_disabled_by_option() { // Ensure option is not set. \delete_option( 'activitypub_create_posts' ); @@ -481,7 +481,7 @@ public function test_create_post_disabled_by_option() { ), ); - $result = Create::create_post( $activity, array( $this->user_id ) ); + $result = Create::incoming( $activity, array( $this->user_id ) ); $this->assertFalse( $result ); @@ -493,11 +493,11 @@ public function test_create_post_disabled_by_option() { } /** - * Test create_post works when activitypub_create_posts option is enabled. + * Test incoming works when activitypub_create_posts option is enabled. * - * @covers ::create_post + * @covers ::incoming */ - public function test_create_post_enabled_by_option() { + public function test_incoming_post_enabled_by_option() { // Enable the option. \update_option( 'activitypub_create_posts', '1' ); @@ -533,7 +533,7 @@ public function test_create_post_enabled_by_option() { ), ); - $result = Create::create_post( $activity, array( $this->user_id ) ); + $result = Create::incoming( $activity, array( $this->user_id ) ); $this->assertInstanceOf( 'WP_Post', $result ); @@ -566,9 +566,9 @@ public function test_reply_to_non_existent_post_returns_false() { ), ); - $result = Create::handle_create( $object, $this->user_id ); + $result = Create::incoming( $object, $this->user_id ); - $this->assertNull( $result ); + $this->assertFalse( $result ); // Verify no comment was created. $args = array( diff --git a/tests/phpunit/tests/includes/handler/class-test-follow.php b/tests/phpunit/tests/includes/handler/class-test-follow.php index ab2de4b296..aa5ca1139a 100644 --- a/tests/phpunit/tests/includes/handler/class-test-follow.php +++ b/tests/phpunit/tests/includes/handler/class-test-follow.php @@ -42,7 +42,7 @@ public static function wpSetUpBeforeClass( $factory ) { * Test handle_follow method with different scenarios. * * @dataProvider handle_follow_provider - * @covers ::handle_follow + * @covers ::incoming * * @param mixed $target_user_id The user ID being followed (int or 'test_user'). * @param string $actor_url The actor URL following. @@ -81,7 +81,7 @@ public function test_handle_follow( $target_user_id, $actor_url, $expected_respo $followers_before = Followers::get_many( $target_user_id ); $followers_count_before = count( $followers_before ); - Follow::handle_follow( $activity_object, $target_user_id ); + Follow::incoming( $activity_object, $target_user_id ); // Check if follower was added. $followers_after = Followers::get_many( $target_user_id ); @@ -240,7 +240,7 @@ public function test_queue_accept() { /** * Test that duplicate follow requests don't trigger notifications. * - * @covers ::handle_follow + * @covers ::incoming */ public function test_duplicate_follow_no_notification() { $actor_url = 'https://example.com/duplicate-actor'; @@ -278,7 +278,7 @@ public function test_duplicate_follow_no_notification() { \add_action( 'activitypub_handled_follow', $test_callback, 10, 4 ); // First follow request - should succeed. - Follow::handle_follow( $activity_object, self::$user_id ); + Follow::incoming( $activity_object, self::$user_id ); // Verify first follow was successful. $this->assertCount( 1, $handled_follow_calls, 'First follow should trigger the action' ); @@ -291,7 +291,7 @@ public function test_duplicate_follow_no_notification() { // Second follow request with a different activity ID (simulating a retry). $activity_object['id'] = $actor_url . '/activity/follow-2'; - Follow::handle_follow( $activity_object, self::$user_id ); + Follow::incoming( $activity_object, self::$user_id ); // Verify second follow was not successful (to prevent duplicate notification). $this->assertCount( 2, $handled_follow_calls, 'Second follow should also trigger the action' ); @@ -356,7 +356,7 @@ public function test_queue_reject() { /** * Test that deprecated hook still fires for backward compatibility. * - * @covers ::handle_follow + * @covers ::incoming */ public function test_deprecated_hook_fires() { // Expect the deprecation notice. @@ -398,7 +398,7 @@ public function test_deprecated_hook_fires() { 'object' => Actors::get_by_id( self::$user_id )->get_id(), ); - Follow::handle_follow( $activity_object, self::$user_id ); + Follow::incoming( $activity_object, self::$user_id ); // Verify deprecated hook fired. $this->assertTrue( $hook_fired, 'Deprecated hook should fire' ); @@ -415,7 +415,7 @@ public function test_deprecated_hook_fires() { /** * Test new hook fires correctly. * - * @covers ::handle_follow + * @covers ::incoming */ public function test_new_hook_fires() { $hook_fired = false; @@ -455,7 +455,7 @@ public function test_new_hook_fires() { 'object' => Actors::get_by_id( self::$user_id )->get_id(), ); - Follow::handle_follow( $activity_object, self::$user_id ); + Follow::incoming( $activity_object, self::$user_id ); // Verify new hook fired. $this->assertTrue( $hook_fired, 'New hook should fire' ); diff --git a/tests/phpunit/tests/includes/handler/class-test-like.php b/tests/phpunit/tests/includes/handler/class-test-like.php index 7ae710e426..595ddd575c 100644 --- a/tests/phpunit/tests/includes/handler/class-test-like.php +++ b/tests/phpunit/tests/includes/handler/class-test-like.php @@ -110,7 +110,7 @@ public function create_test_object() { * Test handle_like with different scenarios. * * @dataProvider handle_like_provider - * @covers ::handle_like + * @covers ::incoming * * @param array $activity_data The like activity data. * @param bool $should_create_comment Whether a comment should be created. @@ -130,7 +130,7 @@ public function test_handle_like( $activity_data, $should_create_comment, $descr $count_before = count( $comments_before ); // Process the like. - Like::handle_like( $activity, $this->user_id ); + Like::incoming( $activity, $this->user_id ); // Check comment count after. $comments_after = \get_comments( @@ -191,7 +191,7 @@ public function handle_like_provider() { * This test verifies that Like activities from Pixelfed and other platforms * that include trailing slashes in object URLs are processed correctly. * - * @covers ::handle_like + * @covers ::incoming * @covers \Activitypub\Collection\Interactions::add_reaction */ public function test_handle_like_with_trailing_slash() { @@ -214,7 +214,7 @@ public function test_handle_like_with_trailing_slash() { $count_before = count( $comments_before ); // Process the like. - Like::handle_like( $activity, $this->user_id ); + Like::incoming( $activity, $this->user_id ); // Check that comment was created despite trailing slash. $comments_after = \get_comments( @@ -232,7 +232,7 @@ public function test_handle_like_with_trailing_slash() { /** * Test duplicate like handling. * - * @covers ::handle_like + * @covers ::incoming */ public function test_handle_like_duplicate() { $activity = array_merge( @@ -241,7 +241,7 @@ public function test_handle_like_duplicate() { ); // Process the like first time. - Like::handle_like( $activity, $this->user_id ); + Like::incoming( $activity, $this->user_id ); $comments_after_first = \get_comments( array( @@ -252,7 +252,7 @@ public function test_handle_like_duplicate() { $count_after_first = count( $comments_after_first ); // Process the same like again. - Like::handle_like( $activity, $this->user_id ); + Like::incoming( $activity, $this->user_id ); $comments_after_second = \get_comments( array( @@ -268,7 +268,7 @@ public function test_handle_like_duplicate() { /** * Test handle_like action hook fires. * - * @covers ::handle_like + * @covers ::incoming */ public function test_handle_like_action_hook() { $hook_fired = false; @@ -287,7 +287,7 @@ public function test_handle_like_action_hook() { \add_action( 'activitypub_handled_like', $handled_like_callback, 10, 4 ); $activity = $this->create_test_object(); - Like::handle_like( $activity, $this->user_id ); + Like::incoming( $activity, $this->user_id ); // Verify hook was fired. $this->assertTrue( $hook_fired, 'Action hook should be fired' ); diff --git a/tests/phpunit/tests/includes/handler/class-test-undo.php b/tests/phpunit/tests/includes/handler/class-test-undo.php index 2b419f8763..d8d8ad71c4 100644 --- a/tests/phpunit/tests/includes/handler/class-test-undo.php +++ b/tests/phpunit/tests/includes/handler/class-test-undo.php @@ -55,7 +55,7 @@ public function set_up() { * Test handle_undo with follow activities. * * @dataProvider follow_undo_provider - * @covers ::handle_undo + * @covers ::incoming * * @param string $actor_url The actor URL to test with. * @param string $description Description of the test case. @@ -96,7 +96,7 @@ public function test_handle_undo_follow( $actor_url, $description ) { Inbox_Collection::add( $activity_object, self::$user_id ); // Call the Follow handler directly to add the follower. - \Activitypub\Handler\Follow::handle_follow( $follow_activity, self::$user_id ); + \Activitypub\Handler\Follow::incoming( $follow_activity, self::$user_id ); // Verify follower was added. $followers = Followers::get_many( self::$user_id ); @@ -117,7 +117,7 @@ public function test_handle_undo_follow( $actor_url, $description ) { ); // Call the Undo handler directly. - Undo::handle_undo( $undo_activity, self::$user_id ); + Undo::incoming( $undo_activity, self::$user_id ); // Verify follower was removed. $followers_after = Followers::get_many( self::$user_id ); @@ -153,7 +153,7 @@ public function follow_undo_provider() { * Test handle_undo with comment-related activities (Like, Create, Announce). * * @dataProvider comment_activities_undo_provider - * @covers ::handle_undo + * @covers ::incoming * * @param string $actor_url The actor URL to test with. * @param string $activity_type The type of activity being undone. @@ -199,9 +199,8 @@ public function test_handle_undo_comment_activities( $actor_url, $activity_type, Inbox_Collection::add( $activity_object, self::$user_id ); // Call the appropriate handler directly to create the comment. - $handler_class = '\\Activitypub\\Handler\\' . $activity_type; - $handler_method = 'handle_' . strtolower( $activity_type ); - $handler_class::$handler_method( $create_activity, self::$user_id ); + $handler_class = '\\Activitypub\\Handler\\' . $activity_type; + $handler_class::incoming( $create_activity, self::$user_id ); // Find the comment that was created. $found_comment = Comment::object_id_to_comment( $activity_id ); @@ -221,7 +220,7 @@ public function test_handle_undo_comment_activities( $actor_url, $activity_type, ); // Call the Undo handler directly. - Undo::handle_undo( $undo_activity, self::$user_id ); + Undo::incoming( $undo_activity, self::$user_id ); // Verify comment was deleted. $comment_after = \get_comment( $comment_id ); @@ -249,7 +248,7 @@ public function comment_activities_undo_provider() { /** * Test handle_undo action hook is fired. * - * @covers ::handle_undo + * @covers ::incoming */ public function test_handle_undo_action_hook() { $action_fired = false; @@ -298,7 +297,7 @@ public function test_handle_undo_action_hook() { $activity_object = Activity::init_from_array( $follow_activity ); Inbox_Collection::add( $activity_object, self::$user_id ); - \Activitypub\Handler\Follow::handle_follow( $follow_activity, self::$user_id ); + \Activitypub\Handler\Follow::incoming( $follow_activity, self::$user_id ); // Create Undo activity. $activity = array( @@ -315,7 +314,7 @@ public function test_handle_undo_action_hook() { ); // Call the Undo handler directly. - Undo::handle_undo( $activity, self::$user_id ); + Undo::incoming( $activity, self::$user_id ); $this->assertTrue( $action_fired ); $this->assertEquals( $activity, $activity_data ); diff --git a/tests/phpunit/tests/rest/class-test-proxy-controller.php b/tests/phpunit/tests/rest/class-test-proxy-controller.php index 86a046f924..df386c05fb 100644 --- a/tests/phpunit/tests/rest/class-test-proxy-controller.php +++ b/tests/phpunit/tests/rest/class-test-proxy-controller.php @@ -50,7 +50,7 @@ public function set_up() { $wp_rest_server = new \WP_REST_Server(); $this->server = $wp_rest_server; - ( new Proxy_Controller() )->register_routes(); + \do_action( 'rest_api_init' ); } /** From 3784ed84cc64749149c294f94ff8642f20edec7e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 2 Feb 2026 09:44:03 +0100 Subject: [PATCH 019/105] Remove invalid @covers annotation for non-existent create_post method --- tests/phpunit/tests/includes/handler/class-test-create.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index 25c95fc1fe..ba16ca92d3 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -299,7 +299,6 @@ public function test_handle_create_check_multiple_comments() { * Test handling create activity for objects with content sanitization. * * @covers ::incoming - * @covers ::create_post */ public function test_handle_create_object_with_sanitization() { // Mock HTTP request for Remote_Actors::fetch_by_uri. From c9e083484e1a920933c7232f41aa2fd6ce8a2459 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 3 Feb 2026 12:21:31 +0100 Subject: [PATCH 020/105] Move C2S user inbox logic from Inbox_Controller to Actors_Inbox_Controller Consolidates user inbox handling in the appropriate controller: - Actors_Inbox_Controller now handles user inbox GET (C2S) and POST (S2S) - Inbox_Controller now only handles shared inbox POST (S2S) --- .../rest/class-actors-inbox-controller.php | 110 ++++++++-- includes/rest/class-inbox-controller.php | 189 ------------------ 2 files changed, 94 insertions(+), 205 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index bb277e1a99..70aaf74ef7 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -8,6 +8,8 @@ namespace Activitypub\Rest; use Activitypub\Activity\Activity; +use Activitypub\Activity\Base_Object; +use Activitypub\Collection\Actors; use Activitypub\Collection\Inbox; use Activitypub\Moderation; @@ -45,7 +47,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => '__return_true', + 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => array( 'page' => array( 'description' => 'Current page of the collection.', @@ -58,6 +60,7 @@ public function register_routes() { 'type' => 'integer', 'default' => 20, 'minimum' => 1, + 'maximum' => 100, ), ), 'schema' => array( $this, 'get_collection_schema' ), @@ -109,33 +112,85 @@ public function register_routes() { } /** - * Renders the user-inbox. + * Permission check for reading inbox items (C2S). * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response|\WP_Error Response object or WP_Error. + * @param \WP_REST_Request $request Full details about the request. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public function get_items_permissions_check( $request ) { + // Verify OAuth with read scope. + $result = $this->verify_oauth_read( $request ); + if ( \is_wp_error( $result ) ) { + return $result; + } + + // Verify the token belongs to the requested user. + return $this->verify_owner( $request ); + } + + /** + * Retrieves a collection of inbox items. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { + $page = $request->get_param( 'page' ) ?? 1; $user_id = $request->get_param( 'user_id' ); + $user = Actors::get_by_id( $user_id ); /** - * Fires before the ActivityPub inbox is created and sent to the client. + * Action triggered prior to the ActivityPub inbox being created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_inbox_pre', $request ); + + $args = array( + 'posts_per_page' => $request->get_param( 'per_page' ), + 'paged' => $page, + 'post_type' => Inbox::POST_TYPE, + 'post_status' => 'publish', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_user_id', + 'value' => $user_id, + ), + ), + ); + + /** + * Filters WP_Query arguments when querying Inbox items via the REST API. + * + * Enables adding extra arguments or setting defaults for an inbox collection request. + * + * @param array $args Array of arguments for WP_Query. + * @param \WP_REST_Request $request The REST API request. */ - \do_action( 'activitypub_rest_inbox_pre' ); + $args = \apply_filters( 'activitypub_rest_inbox_query', $args, $request ); + + $inbox_query = new \WP_Query(); + $query_result = $inbox_query->query( $args ); $response = array( - 'id' => get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $user_id ) ), + '@context' => Base_Object::JSON_LD_CONTEXT, + 'id' => get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user_id ) ), 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(), + 'actor' => $user->get_id(), 'type' => 'OrderedCollection', - 'totalItems' => 0, + 'totalItems' => (int) $inbox_query->found_posts, 'orderedItems' => array(), ); - /** - * Filters the ActivityPub inbox data before it is sent to the client. - * - * @param array $response The ActivityPub inbox array. - */ - $response = \apply_filters( 'activitypub_rest_inbox_array', $response ); + \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); + foreach ( $query_result as $inbox_item ) { + if ( ! $inbox_item instanceof \WP_Post ) { + continue; + } + + $response['orderedItems'][] = $this->prepare_item_for_response( $inbox_item, $request ); + } $response = $this->prepare_collection_response( $response, $request ); if ( \is_wp_error( $response ) ) { @@ -143,9 +198,19 @@ public function get_items( $request ) { } /** - * Fires after the ActivityPub inbox has been created and sent to the client. + * Filter the ActivityPub inbox array. + * + * @param array $response The ActivityPub inbox array. + * @param \WP_REST_Request $request The request object. + */ + $response = \apply_filters( 'activitypub_rest_inbox_array', $response, $request ); + + /** + * Action triggered after the ActivityPub inbox has been created and sent to the client. + * + * @param \WP_REST_Request $request The request object. */ - \do_action( 'activitypub_inbox_post' ); + \do_action( 'activitypub_rest_inbox_post', $request ); $response = \rest_ensure_response( $response ); $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); @@ -153,6 +218,19 @@ public function get_items( $request ) { return $response; } + /** + * Prepares the item for the REST response. + * + * @param mixed $item WordPress representation of the item. + * @param \WP_REST_Request $request Request object. + * @return array Response object on success. + */ + public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $activity = \json_decode( $item->post_content, true ); + + return $activity; + } + /** * Handles user-inbox requests. * diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index cab4304f79..d7faa14467 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -8,7 +8,6 @@ namespace Activitypub\Rest; use Activitypub\Activity\Activity; -use Activitypub\Activity\Base_Object; use Activitypub\Collection\Actors; use Activitypub\Collection\Following; use Activitypub\Collection\Inbox; @@ -17,8 +16,6 @@ use function Activitypub\camel_to_snake_case; use function Activitypub\extract_recipients_from_activity; -use function Activitypub\get_masked_wp_version; -use function Activitypub\get_rest_url_by_path; use function Activitypub\is_activity_public; use function Activitypub\is_collection; use function Activitypub\is_same_domain; @@ -32,7 +29,6 @@ * @see https://www.w3.org/TR/activitypub/#inbox */ class Inbox_Controller extends \WP_REST_Controller { - use Collection; use Verification; /** @@ -49,18 +45,10 @@ class Inbox_Controller extends \WP_REST_Controller { */ protected $rest_base = 'inbox'; - /** - * The base for user-specific inbox routes. - * - * @var string - */ - protected $user_rest_base = '(?:users|actors)/(?P[\-]?\d+)/inbox'; - /** * Register routes. */ public function register_routes() { - // Shared inbox (POST only). \register_rest_route( $this->namespace, '/' . $this->rest_base, @@ -74,48 +62,6 @@ public function register_routes() { 'schema' => array( $this, 'get_item_schema' ), ) ); - - // User-specific inbox (GET for C2S, POST for S2S). - \register_rest_route( - $this->namespace, - '/' . $this->user_rest_base, - array( - 'args' => array( - 'user_id' => array( - 'description' => 'The ID of the user or actor.', - 'type' => 'integer', - 'validate_callback' => array( $this, 'validate_user_id' ), - ), - ), - array( - 'methods' => \WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_items_permissions_check' ), - 'args' => array( - 'page' => array( - 'description' => 'Current page of the collection.', - 'type' => 'integer', - 'minimum' => 1, - // No default so we can differentiate between Collection and CollectionPage requests. - ), - 'per_page' => array( - 'description' => 'Maximum number of items to be returned in result set.', - 'type' => 'integer', - 'default' => 20, - 'minimum' => 1, - 'maximum' => 100, - ), - ), - ), - array( - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'create_item' ), - 'permission_callback' => array( $this, 'verify_signature' ), - 'args' => $this->get_create_item_args(), - ), - 'schema' => array( $this, 'get_item_schema' ), - ) - ); } /** @@ -194,141 +140,6 @@ private function get_create_item_args() { ); } - /** - * Validates the user_id parameter. - * - * @param mixed $user_id The user_id parameter. - * @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise. - */ - public function validate_user_id( $user_id ) { - $user = Actors::get_by_id( $user_id ); - if ( \is_wp_error( $user ) ) { - return $user; - } - - return true; - } - - /** - * Permission check for reading inbox items (C2S). - * - * @param \WP_REST_Request $request Full details about the request. - * @return bool|\WP_Error True if authorized, WP_Error otherwise. - */ - public function get_items_permissions_check( $request ) { - // Verify OAuth with read scope. - $result = $this->verify_oauth_read( $request ); - if ( \is_wp_error( $result ) ) { - return $result; - } - - // Verify the token belongs to the requested user. - return $this->verify_owner( $request ); - } - - /** - * Retrieves a collection of inbox items. - * - * @param \WP_REST_Request $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. - */ - public function get_items( $request ) { - $page = $request->get_param( 'page' ) ?? 1; - $user_id = $request->get_param( 'user_id' ); - $user = Actors::get_by_id( $user_id ); - - /** - * Action triggered prior to the ActivityPub inbox being created and sent to the client. - * - * @param \WP_REST_Request $request The request object. - */ - \do_action( 'activitypub_rest_inbox_pre', $request ); - - $args = array( - 'posts_per_page' => $request->get_param( 'per_page' ), - 'paged' => $page, - 'post_type' => Inbox::POST_TYPE, - 'post_status' => 'publish', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - array( - 'key' => '_activitypub_user_id', - 'value' => $user_id, - ), - ), - ); - - /** - * Filters WP_Query arguments when querying Inbox items via the REST API. - * - * Enables adding extra arguments or setting defaults for an inbox collection request. - * - * @param array $args Array of arguments for WP_Query. - * @param \WP_REST_Request $request The REST API request. - */ - $args = \apply_filters( 'activitypub_rest_inbox_query', $args, $request ); - - $inbox_query = new \WP_Query(); - $query_result = $inbox_query->query( $args ); - - $response = array( - '@context' => Base_Object::JSON_LD_CONTEXT, - 'id' => get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user_id ) ), - 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(), - 'actor' => $user->get_id(), - 'type' => 'OrderedCollection', - 'totalItems' => (int) $inbox_query->found_posts, - 'orderedItems' => array(), - ); - - \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); - foreach ( $query_result as $inbox_item ) { - if ( ! $inbox_item instanceof \WP_Post ) { - continue; - } - - $response['orderedItems'][] = $this->prepare_item_for_response( $inbox_item, $request ); - } - - $response = $this->prepare_collection_response( $response, $request ); - if ( \is_wp_error( $response ) ) { - return $response; - } - - /** - * Filter the ActivityPub inbox array. - * - * @param array $response The ActivityPub inbox array. - * @param \WP_REST_Request $request The request object. - */ - $response = \apply_filters( 'activitypub_rest_inbox_array', $response, $request ); - - /** - * Action triggered after the ActivityPub inbox has been created and sent to the client. - * - * @param \WP_REST_Request $request The request object. - */ - \do_action( 'activitypub_rest_inbox_post', $request ); - - $response = \rest_ensure_response( $response ); - $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); - - return $response; - } - - /** - * Prepares the item for the REST response. - * - * @param mixed $item WordPress representation of the item. - * @param \WP_REST_Request $request Request object. - * @return array Response object on success. - */ - public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - $activity = \json_decode( $item->post_content, true ); - - return $activity; - } - /** * The shared inbox. * From c627795ef87969c61dd9a06d1661655b983493ae Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 3 Feb 2026 12:25:31 +0100 Subject: [PATCH 021/105] Remove duplicate verify_* methods from Server class These methods are now provided by the Verification trait which controllers use directly. Removes 278 lines of duplicated code. --- includes/rest/class-server.php | 116 ------------- .../tests/includes/rest/class-test-server.php | 162 ------------------ 2 files changed, 278 deletions(-) diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php index 70a4f8a80f..1c5b90c47b 100644 --- a/includes/rest/class-server.php +++ b/includes/rest/class-server.php @@ -7,13 +7,6 @@ namespace Activitypub\Rest; -use Activitypub\Collection\Actors; -use Activitypub\OAuth\Scope; -use Activitypub\OAuth\Server as OAuth_Server; -use Activitypub\Signature; - -use function Activitypub\use_authorized_fetch; - /** * ActivityPub Server REST-Class. * @@ -32,115 +25,6 @@ public static function init() { \add_filter( 'rest_post_dispatch', array( self::class, 'filter_output' ), 10, 3 ); } - /** - * Callback function to authorize an api request. - * - * The function is meant to be used as part of permission callbacks for rest api endpoints. - * - * It verifies the signature of POST, PUT, PATCH, and DELETE requests, as well as GET requests in secure mode. - * You can use the filter 'activitypub_defer_signature_verification' to defer the signature verification. - * HEAD requests are always bypassed. - * - * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch - * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch - * - * @param \WP_REST_Request $request The request object. - * - * @return bool|\WP_Error True if the request is authorized, WP_Error if not. - */ - public static function verify_signature( $request ) { - if ( 'HEAD' === $request->get_method() ) { - return true; - } - - /** - * Filter to defer signature verification. - * - * Skip signature verification for debugging purposes or to reduce load for - * certain Activity-Types, like "Delete". - * - * @param bool $defer Whether to defer signature verification. - * @param \WP_REST_Request $request The request used to generate the response. - * - * @return bool Whether to defer signature verification. - */ - $defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request ); - - if ( $defer ) { - return true; - } - - // POST-Requests always have to be signed, GET-Requests only require a signature in secure mode. - if ( 'GET' !== $request->get_method() || use_authorized_fetch() ) { - $verified_request = Signature::verify_http_signature( $request ); - if ( \is_wp_error( $verified_request ) ) { - return new \WP_Error( - 'activitypub_signature_verification', - $verified_request->get_error_message(), - array( 'status' => 401 ) - ); - } - } - - return true; - } - - /** - * Verify OAuth authentication with 'read' scope. - * - * Use this as a permission_callback for endpoints requiring OAuth read access. - * - * @param \WP_REST_Request $request The request object. - * @return bool|\WP_Error True if authorized, WP_Error otherwise. - */ - public static function verify_oauth_read( $request ) { - return OAuth_Server::check_oauth_permission( $request, Scope::READ ); - } - - /** - * Verify OAuth authentication with 'write' scope. - * - * Use this as a permission_callback for endpoints requiring OAuth write access. - * - * @param \WP_REST_Request $request The request object. - * @return bool|\WP_Error True if authorized, WP_Error otherwise. - */ - public static function verify_oauth_write( $request ) { - return OAuth_Server::check_oauth_permission( $request, Scope::WRITE ); - } - - /** - * Verify that the OAuth token belongs to the actor specified in the request. - * - * This checks that the user_id parameter matches the token's user. - * Should be called after verify_oauth_read or verify_oauth_write. - * - * @param \WP_REST_Request $request The request object. - * @return bool|\WP_Error True if the token user matches, WP_Error otherwise. - */ - public static function verify_owner( $request ) { - $user_id = $request->get_param( 'user_id' ); - - // Validate the user exists. - $user = Actors::get_by_id( $user_id ); - if ( \is_wp_error( $user ) ) { - return $user; - } - - // Verify the token belongs to this user. - $token = OAuth_Server::get_current_token(); - - if ( ! $token || $token->get_user_id() !== absint( $user_id ) ) { - return new \WP_Error( - 'activitypub_forbidden', - \__( 'You can only access your own resources.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - - return true; - } - /** * Callback function to validate incoming ActivityPub requests * diff --git a/tests/phpunit/tests/includes/rest/class-test-server.php b/tests/phpunit/tests/includes/rest/class-test-server.php index 4e7c5585b5..bd3b455502 100644 --- a/tests/phpunit/tests/includes/rest/class-test-server.php +++ b/tests/phpunit/tests/includes/rest/class-test-server.php @@ -36,168 +36,6 @@ public function test_init() { $this->assertEquals( 10, \has_filter( 'rest_post_dispatch', array( Server::class, 'filter_output' ) ) ); } - /** - * Test verify_signature method with HEAD request. - * - * @covers ::verify_signature - */ - public function test_verify_signature_head_request() { - $request = new \WP_REST_Request( 'HEAD', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $this->assertTrue( Server::verify_signature( $request ) ); - } - - /** - * Test verify_signature method with deferred verification. - * - * @covers ::verify_signature - */ - public function test_verify_signature_deferred() { - \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); - - $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $this->assertTrue( Server::verify_signature( $request ) ); - - \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); - } - - /** - * Data provider for HTTP methods that require signature verification. - * - * @return array - */ - public function signature_required_methods_provider() { - return array( - 'POST request' => array( 'POST', true ), - 'PUT request' => array( 'PUT', false ), - 'PATCH request' => array( 'PATCH', false ), - 'DELETE request' => array( 'DELETE', false ), - ); - } - - /** - * Test verify_signature method with requests requiring signature. - * - * @dataProvider signature_required_methods_provider - * @covers ::verify_signature - * - * @param string $method HTTP method. - * @param bool $expect_status Whether to expect status in error data. - */ - public function test_verify_signature_methods_requiring_signature( $method, $expect_status ) { - $request = new \WP_REST_Request( $method, '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $result = Server::verify_signature( $request ); - $this->assertInstanceOf( '\WP_Error', $result ); - $this->assertEquals( 'activitypub_signature_verification', $result->get_error_code() ); - - if ( $expect_status ) { - $this->assertEquals( 401, $result->get_error_data()['status'] ); - } - } - - /** - * Test verify_signature method with GET request and authorized fetch enabled. - * - * @covers ::verify_signature - */ - public function test_verify_signature_get_request_authorized_fetch() { - \add_filter( 'activitypub_use_authorized_fetch', '__return_true' ); - - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $result = Server::verify_signature( $request ); - $this->assertInstanceOf( '\WP_Error', $result ); - $this->assertEquals( 'activitypub_signature_verification', $result->get_error_code() ); - - \remove_filter( 'activitypub_use_authorized_fetch', '__return_true' ); - } - - /** - * Test verify_signature method with GET request and authorized fetch disabled. - * - * @covers ::verify_signature - */ - public function test_verify_signature_get_request_no_authorized_fetch() { - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $this->assertTrue( Server::verify_signature( $request ) ); - } - - /** - * Test verify_signature method with custom filter callback. - * - * @covers ::verify_signature - */ - public function test_verify_signature_with_custom_filter() { - $filter_called = false; - $test_filter = function ( $defer, $request ) use ( &$filter_called ) { - $filter_called = true; - $this->assertFalse( $defer ); - $this->assertInstanceOf( '\WP_REST_Request', $request ); - return false; - }; - - \add_filter( 'activitypub_defer_signature_verification', $test_filter, 10, 2 ); - - $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $result = Server::verify_signature( $request ); - - $this->assertTrue( $filter_called ); - $this->assertInstanceOf( '\WP_Error', $result ); - - \remove_filter( 'activitypub_defer_signature_verification', $test_filter ); - } - - /** - * Test verify_signature method with filter that returns different values. - * - * @covers ::verify_signature - */ - public function test_verify_signature_filter_context() { - $defer_filter = function ( $defer, $request ) { - // Test that filter receives correct parameters. - if ( $request->get_method() === 'PUT' ) { - return true; // Defer for PUT. - } - return $defer; // Don't defer for others. - }; - - \add_filter( 'activitypub_defer_signature_verification', $defer_filter, 10, 2 ); - - // Test PUT request (should be deferred). - $put_request = new \WP_REST_Request( 'PUT', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $this->assertTrue( Server::verify_signature( $put_request ) ); - - // Test POST request (should not be deferred). - $post_request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $result = Server::verify_signature( $post_request ); - $this->assertInstanceOf( '\WP_Error', $result ); - - \remove_filter( 'activitypub_defer_signature_verification', $defer_filter ); - } - - /** - * Test verify_signature method. - * - * @covers ::verify_signature - */ - public function test_verify_signature() { - // HEAD requests are always bypassed. - $request = new \WP_REST_Request( 'HEAD', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $this->assertTrue( Server::verify_signature( $request ) ); - - // POST requests require a signature. - $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $this->assertErrorResponse( 'activitypub_signature_verification', Server::verify_signature( $request ) ); - - // GET requests with secure mode enabled require a signature. - \add_filter( 'activitypub_use_authorized_fetch', '__return_true' ); - - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' ); - $this->assertErrorResponse( 'activitypub_signature_verification', Server::verify_signature( $request ) ); - - // GET requests with secure mode disabled are bypassed. - \remove_filter( 'activitypub_use_authorized_fetch', '__return_true' ); - $this->assertTrue( Server::verify_signature( $request ) ); - } - /** * Data provider for validate_requests scenarios that return response unchanged. * From 1508dc35a64b10bb3104fe0934c857456fc4bad6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 3 Feb 2026 12:30:48 +0100 Subject: [PATCH 022/105] Remove inbox E2E tests that require OAuth The inbox GET endpoint now requires OAuth authentication (C2S). E2E tests cannot easily test OAuth-protected endpoints without a full OAuth flow. The functionality is covered by PHPUnit tests. --- .../includes/rest/inbox-controller.test.js | 154 ------------------ 1 file changed, 154 deletions(-) delete mode 100644 tests/e2e/specs/includes/rest/inbox-controller.test.js diff --git a/tests/e2e/specs/includes/rest/inbox-controller.test.js b/tests/e2e/specs/includes/rest/inbox-controller.test.js deleted file mode 100644 index a17543699c..0000000000 --- a/tests/e2e/specs/includes/rest/inbox-controller.test.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * WordPress dependencies - */ -import { test, expect } from '@wordpress/e2e-test-utils-playwright'; - -test.describe( 'ActivityPub Inbox REST API', () => { - let testUserId; - let inboxEndpoint; - - test.beforeAll( async () => { - // Use the default test user - testUserId = 1; - inboxEndpoint = `/activitypub/1.0/actors/${ testUserId }/inbox`; - } ); - - test( 'should return 200 status code for inbox GET endpoint', async ( { requestUtils } ) => { - const data = await requestUtils.rest( { - path: inboxEndpoint, - } ); - - expect( data ).toBeDefined(); - } ); - - test( 'should return ActivityStreams OrderedCollection', async ( { requestUtils } ) => { - const data = await requestUtils.rest( { - path: inboxEndpoint, - } ); - - // Check for ActivityStreams context - expect( data ).toHaveProperty( '@context' ); - expect( Array.isArray( data[ '@context' ] ) || typeof data[ '@context' ] === 'string' ).toBe( true ); - - // Verify it's an OrderedCollection - expect( data.type ).toBe( 'OrderedCollection' ); - - // Check for required collection properties - expect( data ).toHaveProperty( 'id' ); - expect( data.id ).toMatch( /^https?:\/\// ); - - expect( data ).toHaveProperty( 'totalItems' ); - expect( typeof data.totalItems ).toBe( 'number' ); - } ); - - test( 'should handle empty inbox collection', async ( { requestUtils } ) => { - const data = await requestUtils.rest( { - path: inboxEndpoint, - } ); - - // Inbox might be empty - if ( data.totalItems === 0 ) { - expect( data.orderedItems || [] ).toEqual( [] ); - } - } ); - - test( 'should include first property for pagination', async ( { requestUtils } ) => { - const data = await requestUtils.rest( { - path: inboxEndpoint, - } ); - - if ( data.totalItems > 0 ) { - expect( data ).toHaveProperty( 'first' ); - - if ( typeof data.first === 'string' ) { - expect( data.first ).toMatch( /^https?:\/\// ); - } else if ( typeof data.first === 'object' ) { - expect( data.first.type ).toBe( 'OrderedCollectionPage' ); - expect( data.first ).toHaveProperty( 'orderedItems' ); - } - } - } ); - - test( 'should return error for non-existent user', async ( { requestUtils } ) => { - try { - await requestUtils.rest( { - path: '/activitypub/1.0/users/999999/inbox', - } ); - // If we reach here, the test should fail - expect.fail(); - } catch ( error ) { - // Should return 400 or 404 for invalid/non-existent user - expect( [ 400, 404 ] ).toContain( error.status || error.code ); - } - } ); - - test( 'should return correct Content-Type header', async ( { requestUtils } ) => { - const data = await requestUtils.rest( { - path: inboxEndpoint, - } ); - - expect( data ).toBeDefined(); - expect( data ).toHaveProperty( 'type' ); - } ); - - test( 'should handle page parameter', async ( { requestUtils } ) => { - try { - const data = await requestUtils.rest( { - path: `${ inboxEndpoint }?page=1&per_page=10`, - } ); - - // If successful, verify the response structure - expect( data.type ).toBe( 'OrderedCollectionPage' ); - } catch ( error ) { - // Skip this test if pagination isn't available yet - expect( error.status || error.code ).toBeGreaterThanOrEqual( 400 ); - } - } ); - - test( 'should validate collection structure matches ActivityStreams spec', async ( { requestUtils } ) => { - const data = await requestUtils.rest( { - path: inboxEndpoint, - } ); - - // Check for required ActivityStreams properties - expect( data ).toHaveProperty( '@context' ); - expect( Array.isArray( data[ '@context' ] ) || typeof data[ '@context' ] === 'string' ).toBe( true ); - - // Verify ID is a valid URL - expect( data.id ).toMatch( /^https?:\/\// ); - - // Verify proper typing - expect( data.type ).toBe( 'OrderedCollection' ); - } ); - - test( 'should validate orderedItems contain activities when present', async ( { requestUtils } ) => { - const data = await requestUtils.rest( { - path: inboxEndpoint, - } ); - - if ( data.orderedItems && data.orderedItems.length > 0 ) { - // Each item should be an Activity or a URL to an Activity - data.orderedItems.forEach( ( item ) => { - if ( typeof item === 'string' ) { - expect( item ).toMatch( /^https?:\/\// ); - } else if ( typeof item === 'object' ) { - expect( item ).toHaveProperty( 'type' ); - // Common activity types for inbox - const activityTypes = [ - 'Create', - 'Update', - 'Delete', - 'Follow', - 'Like', - 'Announce', - 'Accept', - 'Reject', - 'Undo', - ]; - expect( activityTypes ).toContain( item.type ); - expect( item ).toHaveProperty( 'id' ); - } - } ); - } - } ); -} ); From d924ddf025bd4821816a0d20de6b6c7018aaeab2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 3 Feb 2026 18:53:06 +0100 Subject: [PATCH 023/105] Add Application Passwords support for C2S authentication - Add verify_application_password() for WordPress core Application Passwords - Add verify_authentication() that checks OAuth first, falls back to Application Passwords - Update verify_owner() to support both OAuth tokens and WordPress users - Simplify permission callbacks in controllers to use verify_authentication() --- .../rest/class-actors-inbox-controller.php | 19 +--- includes/rest/class-outbox-controller.php | 19 +--- includes/rest/class-proxy-controller.php | 70 +++----------- includes/rest/trait-verification.php | 94 ++++++++++++++----- .../rest/class-test-proxy-controller.php | 22 +++-- 5 files changed, 99 insertions(+), 125 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 70aaf74ef7..e81ab3eb2c 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -47,7 +47,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'permission_callback' => array( $this, 'verify_authentication' ), 'args' => array( 'page' => array( 'description' => 'Current page of the collection.', @@ -111,23 +111,6 @@ public function register_routes() { \add_action( 'activitypub_inbox_create_item', array( self::class, 'process_create_item' ) ); } - /** - * Permission check for reading inbox items (C2S). - * - * @param \WP_REST_Request $request Full details about the request. - * @return bool|\WP_Error True if authorized, WP_Error otherwise. - */ - public function get_items_permissions_check( $request ) { - // Verify OAuth with read scope. - $result = $this->verify_oauth_read( $request ); - if ( \is_wp_error( $result ) ) { - return $result; - } - - // Verify the token belongs to the requested user. - return $this->verify_owner( $request ); - } - /** * Retrieves a collection of inbox items. * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 8d2720a835..d391b3aa2c 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -80,7 +80,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), - 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'permission_callback' => array( $this, 'verify_authentication' ), ), 'schema' => array( $this, 'get_item_schema' ), ) @@ -327,23 +327,6 @@ public function overload_total_items( $response, $request ) { return $response; } - /** - * Permission check for creating items (C2S). - * - * @param \WP_REST_Request $request Full details about the request. - * @return bool|\WP_Error True if authorized, WP_Error otherwise. - */ - public function create_item_permissions_check( $request ) { - // Verify OAuth with write scope. - $result = $this->verify_oauth_write( $request ); - if ( \is_wp_error( $result ) ) { - return $result; - } - - // Verify the token belongs to the requested user. - return $this->verify_owner( $request ); - } - /** * Create an item in the outbox. * diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php index 570bb2fb03..12611a0f58 100644 --- a/includes/rest/class-proxy-controller.php +++ b/includes/rest/class-proxy-controller.php @@ -49,13 +49,14 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'permission_callback' => array( $this, 'verify_authentication' ), 'args' => array( 'id' => array( 'description' => 'The URI of the remote ActivityPub object to fetch.', 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_url', + 'validate_callback' => array( $this, 'validate_url' ), ), ), ), @@ -65,41 +66,23 @@ public function register_routes() { } /** - * Check if the request has permission to use the proxy. + * Validate the URL parameter. * - * @param \WP_REST_Request $request Full details about the request. - * @return true|\WP_Error True if the request has permission, WP_Error otherwise. + * Uses wp_http_validate_url() which blocks local/private IPs and restricts ports. + * + * @see https://developer.wordpress.org/reference/functions/wp_http_validate_url/ + * + * @param string $url The URL to validate. + * @return bool True if valid, false otherwise. */ - public function get_item_permissions_check( $request ) { - // Verify OAuth with read scope. - $result = $this->verify_oauth_read( $request ); - if ( \is_wp_error( $result ) ) { - return $result; - } - - // Validate the URL to prevent abuse. - $url = $request->get_param( 'id' ); - + public function validate_url( $url ) { // Must be HTTPS. if ( 'https' !== \wp_parse_url( $url, PHP_URL_SCHEME ) ) { - return new \WP_Error( - 'activitypub_invalid_url', - \__( 'Only HTTPS URLs are allowed.', 'activitypub' ), - array( 'status' => 400 ) - ); - } - - // Block local/private network addresses. - $host = \wp_parse_url( $url, PHP_URL_HOST ); - if ( $this->is_private_host( $host ) ) { - return new \WP_Error( - 'activitypub_invalid_url', - \__( 'Private network addresses are not allowed.', 'activitypub' ), - array( 'status' => 400 ) - ); + return false; } - return true; + // Use WordPress built-in validation (blocks local IPs, restricts ports). + return (bool) \wp_http_validate_url( $url ); } /** @@ -147,33 +130,6 @@ public function get_item( $request ) { return $response; } - /** - * Check if a host is a private/local network address. - * - * @param string $host The hostname to check. - * @return bool True if the host is private, false otherwise. - */ - private function is_private_host( $host ) { - // Check for localhost. - if ( 'localhost' === $host || '127.0.0.1' === $host || '::1' === $host ) { - return true; - } - - // Check for private IP ranges. - $ip = \gethostbyname( $host ); - if ( $ip === $host ) { - // DNS resolution failed, allow it (will fail on fetch anyway). - return false; - } - - // Use filter_var to check for private/reserved IPs. - if ( false === \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { - return true; - } - - return false; - } - /** * Get the schema for the proxy endpoint. * diff --git a/includes/rest/trait-verification.php b/includes/rest/trait-verification.php index 1e2f7386fa..48ab18344b 100644 --- a/includes/rest/trait-verification.php +++ b/includes/rest/trait-verification.php @@ -17,7 +17,7 @@ /** * Verification Trait. * - * Provides methods for verifying HTTP Signatures (S2S) and OAuth tokens (C2S). + * Provides methods for verifying HTTP Signatures (S2S) and OAuth/Application Passwords (C2S). * Controllers can use this trait for permission callbacks. */ trait Verification { @@ -71,37 +71,83 @@ public function verify_signature( $request ) { } /** - * Verify OAuth authentication with 'read' scope. + * Verify Application Passwords authentication. * - * Use this for endpoints requiring OAuth read access (C2S). + * Uses WordPress core Application Passwords via Basic Auth. + * + * @see https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/ * - * @param \WP_REST_Request $request The request object. * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ - public function verify_oauth_read( $request ) { - return OAuth_Server::check_oauth_permission( $request, Scope::READ ); + public function verify_application_password() { + if ( \is_user_logged_in() ) { + return true; + } + + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'Authentication required.', 'activitypub' ), + array( 'status' => 401 ) + ); } /** - * Verify OAuth authentication with 'write' scope. + * Verify user authentication via OAuth or Application Passwords. + * + * Automatically determines the required scope based on the HTTP method: + * - GET, HEAD: read scope + * - POST, PUT, PATCH, DELETE: write scope * - * Use this for endpoints requiring OAuth write access (C2S). + * If the request has a user_id parameter, also verifies that the + * authenticated user matches that actor. * * @param \WP_REST_Request $request The request object. * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ - public function verify_oauth_write( $request ) { - return OAuth_Server::check_oauth_permission( $request, Scope::WRITE ); + public function verify_authentication( $request ) { + // Determine scope based on HTTP method. + $method = $request->get_method(); + $read_methods = array( 'GET', 'HEAD' ); + $scope = \in_array( $method, $read_methods, true ) ? Scope::READ : Scope::WRITE; + + // Try OAuth first. + if ( true === OAuth_Server::check_oauth_permission( $request, $scope ) ) { + return $this->maybe_verify_owner( $request ); + } + + // Fall back to Application Passwords. + $result = $this->verify_application_password(); + if ( \is_wp_error( $result ) ) { + return $result; + } + + return $this->maybe_verify_owner( $request ); } /** - * Verify that the OAuth token belongs to the actor specified in the request. + * Verify owner if user_id parameter is present. * - * This checks that the user_id parameter matches the token's user. - * Should be called after verify_oauth_read or verify_oauth_write. + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + private function maybe_verify_owner( $request ) { + $user_id = $request->get_param( 'user_id' ); + + if ( null === $user_id ) { + return true; + } + + return $this->verify_owner( $request ); + } + + /** + * Verify that the authenticated user matches the actor specified in the request. + * + * Checks that the user_id parameter matches the OAuth token's user + * or the WordPress authenticated user (via Application Passwords). * * @param \WP_REST_Request $request The request object. - * @return bool|\WP_Error True if the token user matches, WP_Error otherwise. + * @return bool|\WP_Error True if the user matches, WP_Error otherwise. */ public function verify_owner( $request ) { $user_id = $request->get_param( 'user_id' ); @@ -112,17 +158,21 @@ public function verify_owner( $request ) { return $user; } - // Verify the token belongs to this user. + // Try OAuth token first. $token = OAuth_Server::get_current_token(); + if ( $token && $token->get_user_id() === \absint( $user_id ) ) { + return true; + } - if ( ! $token || $token->get_user_id() !== absint( $user_id ) ) { - return new \WP_Error( - 'activitypub_forbidden', - \__( 'You can only access your own resources.', 'activitypub' ), - array( 'status' => 403 ) - ); + // Fall back to WordPress authenticated user (Application Passwords). + if ( \is_user_logged_in() && \get_current_user_id() === \absint( $user_id ) ) { + return true; } - return true; + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You can only access your own resources.', 'activitypub' ), + array( 'status' => 403 ) + ); } } diff --git a/tests/phpunit/tests/rest/class-test-proxy-controller.php b/tests/phpunit/tests/rest/class-test-proxy-controller.php index df386c05fb..b9bb251f03 100644 --- a/tests/phpunit/tests/rest/class-test-proxy-controller.php +++ b/tests/phpunit/tests/rest/class-test-proxy-controller.php @@ -74,7 +74,7 @@ public function test_route_registered() { /** * Test that proxy rejects non-HTTPS URLs. * - * @covers ::get_item_permissions_check + * @covers ::validate_url */ public function test_http_url_rejected() { // Mock OAuth authentication. @@ -86,37 +86,39 @@ public function test_http_url_rejected() { $response = $this->server->dispatch( $request ); $this->assertEquals( 400, $response->get_status() ); - $this->assertEquals( 'activitypub_invalid_url', $response->get_data()['code'] ); + $this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] ); $this->unmock_oauth_auth(); } /** - * Test that proxy rejects localhost URLs. + * Test that proxy rejects private network URLs. * - * @covers ::get_item_permissions_check + * Uses wp_http_validate_url() which blocks private IP ranges. + * + * @covers ::validate_url */ - public function test_localhost_rejected() { + public function test_private_network_rejected() { // Mock OAuth authentication. $this->mock_oauth_auth(); $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); - $request->set_body_params( array( 'id' => 'https://localhost/users/test' ) ); + $request->set_body_params( array( 'id' => 'https://192.168.1.1/users/test' ) ); $response = $this->server->dispatch( $request ); $this->assertEquals( 400, $response->get_status() ); - $this->assertEquals( 'activitypub_invalid_url', $response->get_data()['code'] ); + $this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] ); $this->unmock_oauth_auth(); } /** - * Test proxy requires OAuth authentication. + * Test proxy requires authentication. * - * @covers ::get_item_permissions_check + * @covers ::verify_authentication */ - public function test_requires_oauth() { + public function test_requires_authentication() { $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) ); From e58ce16778d5b14dfbba5559465a4627b8204250 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 4 Feb 2026 13:24:57 +0100 Subject: [PATCH 024/105] Add CORS headers to C2S endpoints Enable browser-based C2S clients to interact with OAuth and ActivityPub endpoints without requiring a proxy server. Endpoints with CORS headers: - OAuth: /token, /revoke, /introspect, /clients, /.well-known/oauth-authorization-server - C2S: /proxy, /actors/{id}/outbox, /actors/{id}/inbox The /oauth/authorize endpoint is excluded as it redirects to the login page. --- includes/oauth/class-server.php | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index d6900a0484..9c5d83a560 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -29,6 +29,9 @@ public static function init() { // Hook into REST authentication - priority 20 to run after default auth. \add_filter( 'rest_authentication_errors', array( self::class, 'authenticate_oauth' ), 20 ); + // Add CORS headers to OAuth endpoints. + \add_filter( 'rest_post_dispatch', array( self::class, 'add_cors_headers' ), 10, 3 ); + // Schedule cleanup cron. if ( ! \wp_next_scheduled( 'activitypub_oauth_cleanup' ) ) { \wp_schedule_event( time(), 'daily', 'activitypub_oauth_cleanup' ); @@ -250,6 +253,63 @@ public static function cleanup() { Authorization_Code::cleanup(); } + /** + * Add CORS headers to C2S endpoint responses. + * + * Enables browser-based C2S clients to interact with OAuth and C2S endpoints. + * + * @param \WP_REST_Response $response The response object. + * @param \WP_REST_Server $server The REST server instance. + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response The modified response. + */ + public static function add_cors_headers( $response, $server, $request ) { + $route = $request->get_route(); + + // Check if route needs CORS headers. + if ( ! self::route_needs_cors( $route ) ) { + return $response; + } + + $response->header( 'Access-Control-Allow-Origin', '*' ); + $response->header( 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS' ); + $response->header( 'Access-Control-Allow-Headers', 'Content-Type, Authorization' ); + + return $response; + } + + /** + * Check if a route needs CORS headers. + * + * @param string $route The REST API route. + * @return bool True if the route needs CORS headers. + */ + private static function route_needs_cors( $route ) { + $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE; + + // OAuth endpoints (except authorize which redirects). + if ( 0 === strpos( $route, $namespace . '/oauth' ) ) { + return false === strpos( $route, '/oauth/authorize' ); + } + + // Proxy endpoint for fetching remote objects. + if ( $namespace . '/proxy' === $route ) { + return true; + } + + // C2S outbox endpoints (POST to create activities). + if ( preg_match( '#^' . preg_quote( $namespace, '#' ) . '/(?:users|actors)/\d+/outbox$#', $route ) ) { + return true; + } + + // C2S user inbox endpoints. + if ( preg_match( '#^' . preg_quote( $namespace, '#' ) . '/(?:users|actors)/\d+/inbox$#', $route ) ) { + return true; + } + + return false; + } + /** * Get OAuth server metadata for discovery. * From 5988e328050e6ef2cff14e7ec8b010392a62efc7 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 4 Feb 2026 13:32:20 +0100 Subject: [PATCH 025/105] Add rewrite rule for OAuth Authorization Server Metadata Register /.well-known/oauth-authorization-server to comply with RFC 8414. This enables OAuth clients to discover the server metadata at the standard location. --- includes/class-router.php | 7 +++++++ includes/rest/class-oauth-controller.php | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/includes/class-router.php b/includes/class-router.php index 7d14382528..f95b3c8622 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -57,6 +57,13 @@ public static function add_rewrite_rules() { ); } + // Authorization Server Metadata (RFC 8414). + \add_rewrite_rule( + '^.well-known/oauth-authorization-server', + 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorization-server-metadata', + 'top' + ); + \add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' ); \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES ); } diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index c6d91561ee..670f6c350f 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -177,10 +177,10 @@ public function register_routes() { ) ); - // OAuth server metadata (RFC 8414). + // Authorization Server Metadata (RFC 8414). \register_rest_route( $this->namespace, - '/' . $this->rest_base . '/.well-known/oauth-authorization-server', + '/' . $this->rest_base . '/authorization-server-metadata', array( array( 'methods' => \WP_REST_Server::READABLE, From 156b9a542e7d1ba28ee9d668675bf5adb484636b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 6 Feb 2026 07:51:44 +0100 Subject: [PATCH 026/105] Optional PKCE, ActivityPub actors, loopback ports Make PKCE optional and improve ActivityPub/client redirect handling: - Authorization_Code::verify_pkce: Treat absence of a stored code_challenge as PKCE not used (skip verification); fail only when a challenge exists but no code_verifier is supplied. - OAuth_Controller: Treat code_challenge as recommended (optional) and remove hard requirement/error for missing challenge/verifier to maintain compatibility with non-PKCE clients. - Client metadata parsing: Accept ActivityPub actor types (Application, Person, Service, Group, Organization), map id/name (or preferredUsername) to client metadata, handle redirectURI/url, and set an is_actor flag for actor-based clients. - Redirect validation: Allow RFC 8252 loopback redirect flexibility (ignore port differences for loopback hosts like 127.0.0.1, ::1, localhost) while preserving exact-match behavior for non-loopback URIs; added is_loopback_redirect_match helper. - Server response handling: Exclude OAuth endpoints from the generic REST error format so OAuth endpoints keep RFC 6749 error responses. These changes improve interoperability with ActivityPub actors, support permissive loopback redirect ports per RFC 8252, and maintain backward compatibility for clients that do not use PKCE. --- includes/oauth/class-authorization-code.php | 8 ++- includes/oauth/class-client.php | 68 +++++++++++++++++++-- includes/rest/class-oauth-controller.php | 17 +----- includes/rest/class-server.php | 5 ++ 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index 829534af20..91a6d648ac 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -176,7 +176,13 @@ public static function exchange( $code, $client_id, $redirect_uri, $code_verifie * @return bool True if valid. */ public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) { - if ( empty( $code_verifier ) || empty( $code_challenge ) ) { + // If PKCE wasn't used during authorization (no challenge stored), skip verification. + if ( empty( $code_challenge ) ) { + return true; + } + + // If challenge was provided but verifier is missing, fail. + if ( empty( $code_verifier ) ) { return false; } diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index 3625c3de9b..aa54eeb199 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -310,13 +310,16 @@ private static function normalize_client_metadata( $data, $url ) { $metadata['client_uri'] = $data['client_uri']; } - // ActivityPub Application format fields. - if ( ! empty( $data['type'] ) && 'Application' === $data['type'] ) { + // ActivityPub actor format fields (Application, Person, Service, etc.). + $actor_types = array( 'Application', 'Person', 'Service', 'Group', 'Organization' ); + if ( ! empty( $data['type'] ) && in_array( $data['type'], $actor_types, true ) ) { if ( ! empty( $data['id'] ) ) { $metadata['client_id'] = $data['id']; } if ( ! empty( $data['name'] ) ) { $metadata['client_name'] = $data['name']; + } elseif ( ! empty( $data['preferredUsername'] ) ) { + $metadata['client_name'] = $data['preferredUsername']; } // Handle redirectURI (singular) as used by ap CLI. if ( ! empty( $data['redirectURI'] ) ) { @@ -333,6 +336,8 @@ private static function normalize_client_metadata( $data, $url ) { if ( ! empty( $data['url'] ) ) { $metadata['client_uri'] = is_array( $data['url'] ) ? $data['url'][0] : $data['url']; } + // Mark as actor-based client for lenient redirect validation. + $metadata['is_actor'] = true; } return $metadata; @@ -370,7 +375,7 @@ public static function validate( $client_id, $client_secret = null ) { /** * Check if redirect URI is valid for this client. * - * If explicit redirect_uris are registered, requires exact match. + * If explicit redirect_uris are registered, requires match (with RFC 8252 loopback handling). * For auto-discovered clients without redirect_uris, uses same-origin policy. * * @param string $redirect_uri The redirect URI to validate. @@ -379,9 +384,22 @@ public static function validate( $client_id, $client_secret = null ) { public function is_valid_redirect_uri( $redirect_uri ) { $allowed_uris = $this->get_redirect_uris(); - // If explicit redirect URIs are registered, require exact match. + // If explicit redirect URIs are registered, check for match. if ( ! empty( $allowed_uris ) ) { - return in_array( $redirect_uri, $allowed_uris, true ); + // Exact match first. + if ( in_array( $redirect_uri, $allowed_uris, true ) ) { + return true; + } + + // RFC 8252 Section 7.3: For loopback redirects, allow any port. + // Compare scheme, host, and path - ignore port for 127.0.0.1 and localhost. + foreach ( $allowed_uris as $allowed_uri ) { + if ( self::is_loopback_redirect_match( $allowed_uri, $redirect_uri ) ) { + return true; + } + } + + return false; } // For auto-discovered clients without redirect_uris, use same-origin policy. @@ -397,6 +415,46 @@ public function is_valid_redirect_uri( $redirect_uri ) { return false; } + /** + * Check if two URIs match under RFC 8252 loopback rules. + * + * For loopback addresses (127.0.0.1, localhost), the port is ignored. + * + * @param string $allowed_uri The registered redirect URI. + * @param string $redirect_uri The requested redirect URI. + * @return bool True if they match under loopback rules. + */ + private static function is_loopback_redirect_match( $allowed_uri, $redirect_uri ) { + $allowed_parts = \wp_parse_url( $allowed_uri ); + $redirect_parts = \wp_parse_url( $redirect_uri ); + + // Must have same scheme. + if ( ( $allowed_parts['scheme'] ?? '' ) !== ( $redirect_parts['scheme'] ?? '' ) ) { + return false; + } + + $allowed_host = $allowed_parts['host'] ?? ''; + $redirect_host = $redirect_parts['host'] ?? ''; + + // Must have same host. + if ( $allowed_host !== $redirect_host ) { + return false; + } + + // Only apply port flexibility for loopback addresses. + $loopback_hosts = array( '127.0.0.1', 'localhost', '::1' ); + if ( ! in_array( $allowed_host, $loopback_hosts, true ) ) { + // Not loopback - require exact match including port. + return $allowed_uri === $redirect_uri; + } + + // For loopback, compare path (ignore port). + $allowed_path = $allowed_parts['path'] ?? '/'; + $redirect_path = $redirect_parts['path'] ?? '/'; + + return $allowed_path === $redirect_path; + } + /** * Get client name. * diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index 670f6c350f..6a22878f86 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -231,16 +231,8 @@ public function authorize( \WP_REST_Request $request ) { ); } - // Check for PKCE (required). + // Check for PKCE (recommended but optional for compatibility). $code_challenge = $request->get_param( 'code_challenge' ); - if ( empty( $code_challenge ) ) { - return $this->redirect_with_error( - $redirect_uri, - 'invalid_request', - 'PKCE code_challenge is required.', - $state - ); - } // Redirect to wp-login.php with action=activitypub_authorize. // This uses WordPress's login_form_{action} hook for proper cookie auth. @@ -407,10 +399,6 @@ private function handle_authorization_code_grant( \WP_REST_Request $request, $cl return $this->token_error( 'invalid_request', 'Authorization code is required.' ); } - if ( empty( $code_verifier ) ) { - return $this->token_error( 'invalid_request', 'PKCE code_verifier is required.' ); - } - $result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier ); if ( \is_wp_error( $result ) ) { @@ -571,9 +559,8 @@ private function get_authorize_args() { 'type' => 'string', ), 'code_challenge' => array( - 'description' => 'PKCE code challenge.', + 'description' => 'PKCE code challenge (recommended).', 'type' => 'string', - 'required' => true, ), 'code_challenge_method' => array( 'description' => 'PKCE code challenge method.', diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php index 1c5b90c47b..1d1c64e29b 100644 --- a/includes/rest/class-server.php +++ b/includes/rest/class-server.php @@ -121,6 +121,11 @@ public static function filter_output( $response, $server, $request ) { return $response; } + // Exclude OAuth endpoints - they have their own error format per RFC 6749. + if ( \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth' ) ) { + return $response; + } + // Only alter responses that return an error status code. if ( $response->get_status() < 400 ) { return $response; From e17869f16accad2dc4ca2bc60d0d933e7312602b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sat, 7 Feb 2026 19:13:56 +0100 Subject: [PATCH 027/105] Address C2S client feedback from PR review - Add `me` parameter to OAuth token and introspect responses with actor URI (IndieAuth convention) to help clients discover actor identity after auth - Skip outbox totalItems override for authenticated requests so C2S clients get accurate collection counts matching orderedItems - Add migration to flush rewrite rules for .well-known/oauth-authorization-server - Update PKCE test to reflect optional PKCE behavior (empty challenge = skip) --- includes/class-migration.php | 4 ++++ includes/oauth/class-token.php | 17 +++++++++++++++-- includes/rest/class-outbox-controller.php | 15 ++++++++++++--- .../oauth/class-test-authorization-code.php | 11 +++++++++-- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/includes/class-migration.php b/includes/class-migration.php index 1ed946f9a4..93ee91eb87 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -219,6 +219,10 @@ public static function maybe_migrate() { if ( \version_compare( $version_from_db, '7.9.0', '<' ) ) { \wp_schedule_single_event( \time(), 'activitypub_migrate_actor_emoji' ); } + if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + // Flush rewrite rules for OAuth Authorization Server Metadata endpoint. + \add_action( 'init', 'flush_rewrite_rules', 20 ); + } // Ensure all required cron schedules are registered. Scheduler::register_schedules(); diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index cc44ec3822..bdd708021f 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -7,6 +7,8 @@ namespace Activitypub\OAuth; +use Activitypub\Collection\Actors; + /** * Token class for managing OAuth 2.0 access and refresh tokens. * @@ -113,12 +115,17 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D // Track user for cleanup. self::track_user( $user_id ); + // Get the actor URI for the 'me' parameter (IndieAuth convention). + $actor = Actors::get_by_id( $user_id ); + $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null; + return array( 'access_token' => $access_token, 'token_type' => 'Bearer', 'expires_in' => $expires, 'refresh_token' => $refresh_token, 'scope' => Scope::to_string( $scopes ), + 'me' => $me, ); } @@ -568,7 +575,12 @@ public static function introspect( $token ) { return array( 'active' => false ); } - $user = \get_userdata( $validated->get_user_id() ); + $user_id = $validated->get_user_id(); + $user = \get_userdata( $user_id ); + + // Get the actor URI for the 'me' parameter (IndieAuth convention). + $actor = Actors::get_by_id( $user_id ); + $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null; return array( 'active' => true, @@ -578,7 +590,8 @@ public static function introspect( $token ) { 'token_type' => 'Bearer', 'exp' => $validated->get_expires_at(), 'iat' => $validated->get_created_at(), - 'sub' => (string) $validated->get_user_id(), + 'sub' => (string) $user_id, + 'me' => $me, ); } } diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index d391b3aa2c..d4d45883b8 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -282,10 +282,14 @@ public function get_item_schema() { } /** - * Overload total items. + * Overload total items for public requests. * - * The `totalItems` property is used by Mastodon to show the overall - * number of federated posts and comments. + * For unauthenticated (public) requests, the `totalItems` property shows + * the overall number of federated posts and comments, which is what + * Mastodon expects for display purposes. + * + * For authenticated C2S requests, we skip this override so that totalItems + * accurately reflects the actual outbox collection size. * * @param array $response The response array. * @param \WP_REST_Request $request The request object. @@ -293,6 +297,11 @@ public function get_item_schema() { * @return array The modified response array. */ public function overload_total_items( $response, $request ) { + // For authenticated requests, return accurate totalItems matching orderedItems. + if ( \get_current_user_id() ) { + return $response; + } + $posts = new \WP_Query( array( 'post_status' => 'publish', diff --git a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php index 1a4d7929c8..1b98ca9ece 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php +++ b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php @@ -132,12 +132,19 @@ public function test_verify_pkce_plain() { /** * Test verify_pkce method with empty values. * + * PKCE is optional: if no code_challenge was stored during authorization, + * verification is skipped (returns true). Only fail when a challenge exists + * but no verifier is provided. + * * @covers ::verify_pkce */ public function test_verify_pkce_empty() { + // Challenge exists but verifier missing: should fail. $this->assertFalse( Authorization_Code::verify_pkce( '', 'challenge', 'S256' ) ); - $this->assertFalse( Authorization_Code::verify_pkce( 'verifier', '', 'S256' ) ); - $this->assertFalse( Authorization_Code::verify_pkce( '', '', 'S256' ) ); + + // No challenge stored (PKCE not used): should pass (skip verification). + $this->assertTrue( Authorization_Code::verify_pkce( 'verifier', '', 'S256' ) ); + $this->assertTrue( Authorization_Code::verify_pkce( '', '', 'S256' ) ); } /** From c897cef03bd9711ffb09cbdb142025dca0b6edf2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sat, 7 Feb 2026 19:21:56 +0100 Subject: [PATCH 028/105] Simplify CORS route matching for outbox and inbox endpoints Replace complex regex patterns with simple str_ends_with checks. Also fixes missing CORS for negative user IDs (e.g., Application actor). --- includes/oauth/class-server.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 9c5d83a560..dc5865308d 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -297,13 +297,8 @@ private static function route_needs_cors( $route ) { return true; } - // C2S outbox endpoints (POST to create activities). - if ( preg_match( '#^' . preg_quote( $namespace, '#' ) . '/(?:users|actors)/\d+/outbox$#', $route ) ) { - return true; - } - - // C2S user inbox endpoints. - if ( preg_match( '#^' . preg_quote( $namespace, '#' ) . '/(?:users|actors)/\d+/inbox$#', $route ) ) { + // C2S outbox and inbox endpoints. + if ( \str_ends_with( $route, '/outbox' ) || \str_ends_with( $route, '/inbox' ) ) { return true; } From 550da0236efea8fc54a392aaf86cd38fa499d99e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 8 Feb 2026 09:49:15 +0100 Subject: [PATCH 029/105] Add CORS headers to ActivityPub JSON responses for profile URLs Enable C2S clients to fetch actor profiles directly from the browser by adding CORS headers when serving ActivityPub JSON via template rendering (e.g., /?author=1 or /author/username/). --- includes/class-router.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/includes/class-router.php b/includes/class-router.php index f95b3c8622..43d71bdad9 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -97,6 +97,7 @@ public static function render_activitypub_template( $template ) { if ( ! $activitypub_object ) { \status_header( 410 ); } + self::add_cors_headers(); return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php'; } @@ -142,6 +143,7 @@ public static function render_activitypub_template( $template ) { \status_header( 200 ); } + self::add_cors_headers(); return $activitypub_template; } @@ -175,6 +177,22 @@ static function () use ( $id ) { ); } + /** + * Add CORS headers for ActivityPub JSON responses. + * + * This enables C2S clients to fetch actor profiles and other + * ActivityPub objects directly from the browser. + */ + private static function add_cors_headers() { + if ( \headers_sent() ) { + return; + } + + \header( 'Access-Control-Allow-Origin: *' ); + \header( 'Access-Control-Allow-Methods: GET, OPTIONS' ); + \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' ); + } + /** * Remove trailing slash from ActivityPub @username requests. * From 8e70b689d333197ecc7e27302273f40a9a128c90 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 8 Feb 2026 09:55:08 +0100 Subject: [PATCH 030/105] Address Copilot code review feedback for C2S OAuth implementation - Fix OAuth scope bypass: Don't fall back to Application Passwords when OAuth auth succeeds but scope check fails (security fix) - Fix SQL cleanup query: Use OR instead of AND for transient/timeout prefixes since a single option_name can't match both - Fix Cancel button: Submit form as denial to return proper OAuth error to client's redirect_uri instead of navigating to home - Escape dot in .well-known regex: Use \\. to match literal dot - Fix IPv6 loopback check: Add '::1' since parse_url strips brackets --- includes/class-router.php | 2 +- includes/oauth/class-authorization-code.php | 2 +- includes/oauth/class-client.php | 4 +++- includes/rest/trait-verification.php | 11 +++++++++-- templates/oauth-authorize.php | 4 ++-- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/includes/class-router.php b/includes/class-router.php index 43d71bdad9..956e88d641 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -59,7 +59,7 @@ public static function add_rewrite_rules() { // Authorization Server Metadata (RFC 8414). \add_rewrite_rule( - '^.well-known/oauth-authorization-server', + '^\\.well-known/oauth-authorization-server', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorization-server-metadata', 'top' ); diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index 91a6d648ac..3878ced081 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -244,7 +244,7 @@ public static function cleanup() { $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s - AND option_name LIKE %s", + OR option_name LIKE %s", $wpdb->esc_like( '_transient_' . self::TRANSIENT_PREFIX ) . '%', $wpdb->esc_like( '_transient_timeout_' . self::TRANSIENT_PREFIX ) . '%' ) diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index aa54eeb199..bda2237479 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -589,7 +589,9 @@ private static function validate_uri_format( $uri ) { // Allow http for localhost development. if ( 'http' === $parsed['scheme'] ) { - $localhost_hosts = array( 'localhost', '127.0.0.1', '[::1]' ); + // Include both bracketed and unbracketed IPv6 loopback since parse_url + // may return either format depending on PHP version. + $localhost_hosts = array( 'localhost', '127.0.0.1', '[::1]', '::1' ); if ( ! in_array( $parsed['host'], $localhost_hosts, true ) ) { return false; } diff --git a/includes/rest/trait-verification.php b/includes/rest/trait-verification.php index 48ab18344b..5eba04ee37 100644 --- a/includes/rest/trait-verification.php +++ b/includes/rest/trait-verification.php @@ -111,11 +111,18 @@ public function verify_authentication( $request ) { $scope = \in_array( $method, $read_methods, true ) ? Scope::READ : Scope::WRITE; // Try OAuth first. - if ( true === OAuth_Server::check_oauth_permission( $request, $scope ) ) { + $oauth_result = OAuth_Server::check_oauth_permission( $request, $scope ); + if ( true === $oauth_result ) { return $this->maybe_verify_owner( $request ); } - // Fall back to Application Passwords. + // If OAuth was attempted (Bearer token present), don't fall back to Application Passwords. + // This prevents scope bypass when OAuth auth succeeds but scope check fails. + if ( \is_wp_error( $oauth_result ) && OAuth_Server::is_oauth_request() ) { + return $oauth_result; + } + + // Fall back to Application Passwords only when no OAuth token was used. $result = $this->verify_application_password(); if ( \is_wp_error( $result ) ) { return $result; diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php index 13324adb5d..b7fea45ed2 100644 --- a/templates/oauth-authorize.php +++ b/templates/oauth-authorize.php @@ -104,9 +104,9 @@ - +

From 1caa499d1eec7688bee5f3235acf3be2ed0c99e2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 8 Feb 2026 09:56:39 +0100 Subject: [PATCH 031/105] Escape dots in webfinger and nodeinfo rewrite rules for consistency --- includes/class-router.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/class-router.php b/includes/class-router.php index 956e88d641..16fd886306 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -43,7 +43,7 @@ public static function add_rewrite_rules() { if ( ! \class_exists( 'Webfinger' ) ) { \add_rewrite_rule( - '^.well-known/webfinger', + '^\\.well-known/webfinger', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger', 'top' ); @@ -51,7 +51,7 @@ public static function add_rewrite_rules() { if ( ! \class_exists( 'Nodeinfo_Endpoint' ) && true === (bool) \get_option( 'blog_public', 1 ) ) { \add_rewrite_rule( - '^.well-known/nodeinfo', + '^\\.well-known/nodeinfo', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo', 'top' ); From 3e8ae7002c6057f561337e20ee7d7ac7198edf6f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 8 Feb 2026 10:14:47 +0100 Subject: [PATCH 032/105] Address additional Copilot review feedback - Token validation: Use direct DB lookup by meta_key (O(1) instead of O(n)) - Token revoke: Clean up tracked users list when last token is revoked - Inbox controller: Add WP_Error check before using $user - Outbox controller: Add WP_Error check before using $user - Outbox controller: Clarify handler return value contract (int, false, array) - OAuth server: Reset $current_token at start of auth to prevent state leak - Auth code cleanup: Only delete expired transients, not in-progress ones --- includes/oauth/class-authorization-code.php | 44 ++++++++-- includes/oauth/class-server.php | 4 + includes/oauth/class-token.php | 88 +++++++++++++------ .../rest/class-actors-inbox-controller.php | 4 + includes/rest/class-outbox-controller.php | 22 ++++- 5 files changed, 124 insertions(+), 38 deletions(-) diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index 3878ced081..a14345f1b3 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -230,6 +230,9 @@ public static function hash_code( $code ) { /** * Clean up expired authorization codes. * + * Only deletes transients that have actually expired, to avoid breaking + * in-progress authorization flows. + * * Note: Transients auto-expire, but this cleans up any orphaned ones. * Should be called periodically via cron. * @@ -238,18 +241,43 @@ public static function hash_code( $code ) { public static function cleanup() { global $wpdb; - // Clean up expired transients with our prefix. - // Transients should auto-expire, but this catches edge cases. - $count = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $timeout_prefix = '_transient_timeout_' . self::TRANSIENT_PREFIX; + $now = time(); + + // Find expired timeout rows for this prefix. + $timeout_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( - "DELETE FROM {$wpdb->options} + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s - OR option_name LIKE %s", - $wpdb->esc_like( '_transient_' . self::TRANSIENT_PREFIX ) . '%', - $wpdb->esc_like( '_transient_timeout_' . self::TRANSIENT_PREFIX ) . '%' + AND option_value < %d", + $wpdb->esc_like( $timeout_prefix ) . '%', + $now + ) + ); + + if ( empty( $timeout_option_names ) ) { + return 0; + } + + // Build list of timeout and corresponding value option names to delete. + $option_names_to_delete = array(); + foreach ( $timeout_option_names as $timeout_name ) { + $option_names_to_delete[] = $timeout_name; + $option_names_to_delete[] = str_replace( '_transient_timeout_', '_transient_', $timeout_name ); + } + + $placeholders = implode( ', ', array_fill( 0, count( $option_names_to_delete ), '%s' ) ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery + $count = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name IN ( {$placeholders} )", + $option_names_to_delete ) ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery - return $count ? (int) ( $count / 2 ) : 0; // Each transient has 2 rows. + // Each transient has 2 rows (value + timeout). + return $count ? (int) ( $count / 2 ) : 0; } } diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index dc5865308d..9d5819c060 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -46,6 +46,10 @@ public static function init() { * @return \WP_Error|null|bool Authentication result. */ public static function authenticate_oauth( $result ) { + // Reset OAuth state at the start of each authentication to prevent + // leaking state between multiple REST dispatches in the same process. + self::$current_token = null; + // If another authentication method already succeeded, use that. if ( true === $result || \is_user_logged_in() ) { return $result; diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index bdd708021f..dca44cbb79 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -136,43 +136,61 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D * @return Token|\WP_Error The token object or error. */ public static function validate( $token ) { + global $wpdb; + $token_hash = self::hash_token( $token ); $meta_key = self::META_PREFIX . $token_hash; - // Search for the token across all users with tokens. - $users = self::get_tracked_users(); + // Direct DB lookup by meta_key - O(1) instead of O(n) users. + $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1", + $meta_key + ) + ); - foreach ( $users as $user_id ) { - $token_data = \get_user_meta( $user_id, $meta_key, true ); + if ( empty( $user_id ) ) { + return new \WP_Error( + 'activitypub_invalid_token', + \__( 'Invalid access token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } - if ( ! empty( $token_data ) && is_array( $token_data ) ) { - // Verify hash matches. - if ( isset( $token_data['access_token_hash'] ) && - hash_equals( $token_data['access_token_hash'], $token_hash ) ) { + $token_data = \get_user_meta( (int) $user_id, $meta_key, true ); - // Check expiration. - if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < time() ) { - return new \WP_Error( - 'activitypub_token_expired', - \__( 'Access token has expired.', 'activitypub' ), - array( 'status' => 401 ) - ); - } + if ( empty( $token_data ) || ! is_array( $token_data ) ) { + return new \WP_Error( + 'activitypub_invalid_token', + \__( 'Invalid access token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } - // Update last used timestamp. - $token_data['last_used_at'] = time(); - \update_user_meta( $user_id, $meta_key, $token_data ); + // Verify hash matches. + if ( ! isset( $token_data['access_token_hash'] ) || + ! hash_equals( $token_data['access_token_hash'], $token_hash ) ) { + return new \WP_Error( + 'activitypub_invalid_token', + \__( 'Invalid access token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } - return new self( $user_id, $token_hash, $token_data ); - } - } + // Check expiration. + if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < time() ) { + return new \WP_Error( + 'activitypub_token_expired', + \__( 'Access token has expired.', 'activitypub' ), + array( 'status' => 401 ) + ); } - return new \WP_Error( - 'activitypub_invalid_token', - \__( 'Invalid access token.', 'activitypub' ), - array( 'status' => 401 ) - ); + // Update last used timestamp. + $token_data['last_used_at'] = time(); + \update_user_meta( (int) $user_id, $meta_key, $token_data ); + + return new self( (int) $user_id, $token_hash, $token_data ); } /** @@ -254,7 +272,9 @@ public static function revoke( $token ) { $users = self::get_tracked_users(); foreach ( $users as $user_id ) { - $all_meta = \get_user_meta( $user_id ); + $all_meta = \get_user_meta( $user_id ); + $remaining_count = 0; + $found_token = false; foreach ( $all_meta as $meta_key => $meta_values ) { if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { @@ -274,8 +294,18 @@ public static function revoke( $token ) { hash_equals( $token_data['refresh_token_hash'], $token_hash ) ) ) { \delete_user_meta( $user_id, $meta_key ); - return true; + $found_token = true; + } else { + ++$remaining_count; + } + } + + if ( $found_token ) { + // Untrack user if no remaining tokens. + if ( 0 === $remaining_count ) { + self::untrack_user( $user_id ); } + return true; } } diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index e81ab3eb2c..ed945b3329 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -122,6 +122,10 @@ public function get_items( $request ) { $user_id = $request->get_param( 'user_id' ); $user = Actors::get_by_id( $user_id ); + if ( \is_wp_error( $user ) ) { + return $user; + } + /** * Action triggered prior to the ActivityPub inbox being created and sent to the client. * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index d4d45883b8..cf560e7ff6 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -348,7 +348,12 @@ public function overload_total_items( $response, $request ) { public function create_item( $request ) { $user_id = $request->get_param( 'user_id' ); $user = Actors::get_by_id( $user_id ); - $data = $request->get_json_params(); + + if ( \is_wp_error( $user ) ) { + return $user; + } + + $data = $request->get_json_params(); if ( empty( $data ) ) { return new \WP_Error( @@ -383,7 +388,10 @@ public function create_item( $request ) { * * Handlers can process the activity and return: * - WP_Post: A WordPress post was created (scheduler adds to outbox) + * - int: An outbox post ID (activity already added to outbox) * - WP_Error: Stop processing and return error + * - false: Stop processing (activity not allowed) + * - array: Modified activity data (fallback to default handling) * - Other: No handler processed the activity (fallback to default) * * @param array $data The activity data. @@ -396,11 +404,23 @@ public function create_item( $request ) { return $result; } + // Handler returned false to signal "not allowed" or "stop processing". + if ( false === $result ) { + return new \WP_Error( + 'activitypub_activity_not_allowed', + \__( 'This activity type is not allowed.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + // If handler returned a WP_Post, the scheduler already added it to outbox. if ( $result instanceof \WP_Post ) { $object_id = \Activitypub\get_post_id( $result->ID ); $activity_type = \ucfirst( $data['type'] ?? 'Create' ); $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type ); + } elseif ( \is_int( $result ) && $result > 0 ) { + // Handler returned an outbox post ID directly. + $outbox_item = \get_post( $result ); } else { // Default handling for raw activities. $data = \is_array( $result ) ? $result : $data; From aa52626a058218feb2ae1ab5ed6bd17dd13858fb Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 8 Feb 2026 10:59:47 +0100 Subject: [PATCH 033/105] Remove deprecated is_c2s_enabled method Delete the deprecated Server::is_c2s_enabled() method and its docblock. The method always returned true (C2S is now always enabled), so this removes obsolete code and cleans up the class. --- includes/oauth/class-server.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 9d5819c060..b05864eace 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -235,17 +235,6 @@ public static function check_oauth_permission( $request, $scope = null ) { return true; } - /** - * Check if C2S (Client-to-Server) is enabled. - * - * @deprecated C2S is now always enabled. - * - * @return bool Always returns true. - */ - public static function is_c2s_enabled() { - return true; - } - /** * Run cleanup tasks for OAuth data. */ From bfdb8d57c01445f1dd261541a87839431f9c50a8 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 8 Feb 2026 11:06:28 +0100 Subject: [PATCH 034/105] Address third round of Copilot review feedback - Token create: Return validated scopes in response, not original input - Token refresh: Add O(1) lookup via refresh token index instead of O(n) scan - Token revoke: Use O(1) lookups and clean up refresh indices - Inbox controller: Add deprecated hook for backward compatibility - Proxy controller: Use read scope instead of write (it's a read operation) --- includes/oauth/class-token.php | 245 ++++++++++++------ .../rest/class-actors-inbox-controller.php | 8 + includes/rest/class-proxy-controller.php | 29 ++- 3 files changed, 200 insertions(+), 82 deletions(-) diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index dca44cbb79..7cfb56f6b6 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -21,6 +21,11 @@ class Token { */ const META_PREFIX = '_activitypub_oauth_token_'; + /** + * User meta key prefix for refresh token index (maps refresh hash to access hash). + */ + const REFRESH_INDEX_PREFIX = '_activitypub_oauth_refresh_'; + /** * Option key for tracking users with tokens (for cleanup). */ @@ -101,8 +106,9 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D ); // Store in user meta with access token hash as key. - $meta_key = self::META_PREFIX . self::hash_token( $access_token ); - $result = \update_user_meta( $user_id, $meta_key, $token_data ); + $access_hash = self::hash_token( $access_token ); + $meta_key = self::META_PREFIX . $access_hash; + $result = \update_user_meta( $user_id, $meta_key, $token_data ); if ( false === $result ) { return new \WP_Error( @@ -112,6 +118,10 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D ); } + // Store refresh token index for O(1) lookup during refresh. + $refresh_index_key = self::REFRESH_INDEX_PREFIX . self::hash_token( $refresh_token ); + \update_user_meta( $user_id, $refresh_index_key, $access_hash ); + // Track user for cleanup. self::track_user( $user_id ); @@ -124,7 +134,7 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D 'token_type' => 'Bearer', 'expires_in' => $expires, 'refresh_token' => $refresh_token, - 'scope' => Scope::to_string( $scopes ), + 'scope' => Scope::to_string( $token_data['scopes'] ), 'me' => $me, ); } @@ -201,64 +211,90 @@ public static function validate( $token ) { * @return array|\WP_Error New token data or error. */ public static function refresh( $refresh_token, $client_id ) { - $refresh_hash = self::hash_token( $refresh_token ); - $users = self::get_tracked_users(); + global $wpdb; - foreach ( $users as $user_id ) { - // Get all token meta for this user. - $all_meta = \get_user_meta( $user_id ); + $refresh_hash = self::hash_token( $refresh_token ); + $refresh_index_key = self::REFRESH_INDEX_PREFIX . $refresh_hash; - foreach ( $all_meta as $meta_key => $meta_values ) { - if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { - continue; - } + // Direct DB lookup by refresh token index - O(1) instead of O(n) users. + $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1", + $refresh_index_key + ) + ); - $token_data = maybe_unserialize( $meta_values[0] ); + if ( empty( $user_id ) ) { + return new \WP_Error( + 'activitypub_invalid_refresh_token', + \__( 'Invalid refresh token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } - if ( ! is_array( $token_data ) ) { - continue; - } + $user_id = (int) $user_id; - // Check if this is our refresh token. - if ( isset( $token_data['refresh_token_hash'] ) && - hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) { - - // Verify client ID matches. - if ( $token_data['client_id'] !== $client_id ) { - return new \WP_Error( - 'activitypub_client_mismatch', - \__( 'Client ID does not match.', 'activitypub' ), - array( 'status' => 400 ) - ); - } + // Get the access token hash from the index. + $access_hash = \get_user_meta( $user_id, $refresh_index_key, true ); + if ( empty( $access_hash ) ) { + return new \WP_Error( + 'activitypub_invalid_refresh_token', + \__( 'Invalid refresh token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } - // Check refresh token expiration. - if ( isset( $token_data['refresh_expires_at'] ) && - $token_data['refresh_expires_at'] < time() ) { - // Delete the expired token. - \delete_user_meta( $user_id, $meta_key ); - - return new \WP_Error( - 'activitypub_refresh_token_expired', - \__( 'Refresh token has expired.', 'activitypub' ), - array( 'status' => 401 ) - ); - } + // Get the full token data. + $meta_key = self::META_PREFIX . $access_hash; + $token_data = \get_user_meta( $user_id, $meta_key, true ); - // Delete the old token. - \delete_user_meta( $user_id, $meta_key ); + if ( empty( $token_data ) || ! is_array( $token_data ) ) { + return new \WP_Error( + 'activitypub_invalid_refresh_token', + \__( 'Invalid refresh token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } - // Create a new token. - return self::create( $user_id, $client_id, $token_data['scopes'] ); - } - } + // Verify refresh token hash matches. + if ( ! isset( $token_data['refresh_token_hash'] ) || + ! hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) { + return new \WP_Error( + 'activitypub_invalid_refresh_token', + \__( 'Invalid refresh token.', 'activitypub' ), + array( 'status' => 401 ) + ); } - return new \WP_Error( - 'activitypub_invalid_refresh_token', - \__( 'Invalid refresh token.', 'activitypub' ), - array( 'status' => 401 ) - ); + // Verify client ID matches. + if ( $token_data['client_id'] !== $client_id ) { + return new \WP_Error( + 'activitypub_client_mismatch', + \__( 'Client ID does not match.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Check refresh token expiration. + if ( isset( $token_data['refresh_expires_at'] ) && + $token_data['refresh_expires_at'] < time() ) { + // Delete the expired token and index. + \delete_user_meta( $user_id, $meta_key ); + \delete_user_meta( $user_id, $refresh_index_key ); + + return new \WP_Error( + 'activitypub_refresh_token_expired', + \__( 'Refresh token has expired.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Delete the old token and index. + \delete_user_meta( $user_id, $meta_key ); + \delete_user_meta( $user_id, $refresh_index_key ); + + // Create a new token. + return self::create( $user_id, $client_id, $token_data['scopes'] ); } /** @@ -268,51 +304,85 @@ public static function refresh( $refresh_token, $client_id ) { * @return bool True on success (always returns true per RFC 7009). */ public static function revoke( $token ) { + global $wpdb; + $token_hash = self::hash_token( $token ); - $users = self::get_tracked_users(); - foreach ( $users as $user_id ) { - $all_meta = \get_user_meta( $user_id ); - $remaining_count = 0; - $found_token = false; + // Try as access token first (O(1) lookup). + $access_meta_key = self::META_PREFIX . $token_hash; + $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1", + $access_meta_key + ) + ); - foreach ( $all_meta as $meta_key => $meta_values ) { - if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { - continue; - } + if ( $user_id ) { + $user_id = (int) $user_id; + $token_data = \get_user_meta( $user_id, $access_meta_key, true ); - $token_data = maybe_unserialize( $meta_values[0] ); + // Delete the token. + \delete_user_meta( $user_id, $access_meta_key ); - if ( ! is_array( $token_data ) ) { - continue; - } + // Also delete the refresh token index if it exists. + if ( is_array( $token_data ) && isset( $token_data['refresh_token_hash'] ) ) { + $refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash']; + \delete_user_meta( $user_id, $refresh_index_key ); + } - // Check both access and refresh token hashes. - if ( ( isset( $token_data['access_token_hash'] ) && - hash_equals( $token_data['access_token_hash'], $token_hash ) ) || - ( isset( $token_data['refresh_token_hash'] ) && - hash_equals( $token_data['refresh_token_hash'], $token_hash ) ) ) { + self::maybe_untrack_user( $user_id ); + return true; + } - \delete_user_meta( $user_id, $meta_key ); - $found_token = true; - } else { - ++$remaining_count; - } - } + // Try as refresh token (O(1) lookup via index). + $refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_hash; + $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1", + $refresh_index_key + ) + ); - if ( $found_token ) { - // Untrack user if no remaining tokens. - if ( 0 === $remaining_count ) { - self::untrack_user( $user_id ); - } - return true; + if ( $user_id ) { + $user_id = (int) $user_id; + $access_hash = \get_user_meta( $user_id, $refresh_index_key, true ); + + // Delete the token and index. + if ( $access_hash ) { + \delete_user_meta( $user_id, self::META_PREFIX . $access_hash ); } + \delete_user_meta( $user_id, $refresh_index_key ); + + self::maybe_untrack_user( $user_id ); + return true; } // Token doesn't exist or already revoked - that's fine per RFC 7009. return true; } + /** + * Untrack user if they have no remaining tokens. + * + * @param int $user_id The user ID. + */ + private static function maybe_untrack_user( $user_id ) { + global $wpdb; + + // Check if user has any remaining tokens. + $count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->usermeta WHERE user_id = %d AND meta_key LIKE %s", + $user_id, + $wpdb->esc_like( self::META_PREFIX ) . '%' + ) + ); + + if ( 0 === (int) $count ) { + self::untrack_user( $user_id ); + } + } + /** * Revoke all tokens for a user. * @@ -324,10 +394,15 @@ public static function revoke_all_for_user( $user_id ) { $count = 0; foreach ( $all_meta as $meta_key => $meta_values ) { + // Delete token entries. if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) { \delete_user_meta( $user_id, $meta_key ); ++$count; } + // Delete refresh token indices. + if ( 0 === strpos( $meta_key, self::REFRESH_INDEX_PREFIX ) ) { + \delete_user_meta( $user_id, $meta_key ); + } } // Remove user from tracking if no more tokens. @@ -366,6 +441,10 @@ public static function revoke_for_client( $client_id ) { // Check if this token belongs to the client. if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) { \delete_user_meta( $user_id, $meta_key ); + // Also delete refresh token index. + if ( isset( $token_data['refresh_token_hash'] ) ) { + \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] ); + } ++$count; } else { ++$user_tokens; @@ -576,6 +655,10 @@ public static function cleanup_expired() { if ( $access_expired && $refresh_expired ) { \delete_user_meta( $user_id, $meta_key ); + // Also delete refresh token index. + if ( isset( $token_data['refresh_token_hash'] ) ) { + \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] ); + } ++$count; } else { ++$user_tokens; diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index ed945b3329..542c91b4e4 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -199,6 +199,14 @@ public function get_items( $request ) { */ \do_action( 'activitypub_rest_inbox_post', $request ); + // Fire deprecated hook for backward compatibility. + \do_action_deprecated( + 'activitypub_inbox_post', + array( $request ), + '4.8.0', + 'activitypub_rest_inbox_post' + ); + $response = \rest_ensure_response( $response ); $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php index 12611a0f58..a870a452f0 100644 --- a/includes/rest/class-proxy-controller.php +++ b/includes/rest/class-proxy-controller.php @@ -12,6 +12,8 @@ use Activitypub\Collection\Remote_Actors; use Activitypub\Http; +use Activitypub\OAuth\Scope; +use Activitypub\OAuth\Server as OAuth_Server; use function Activitypub\is_actor; @@ -49,7 +51,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this, 'verify_authentication' ), + 'permission_callback' => array( $this, 'verify_read_permission' ), 'args' => array( 'id' => array( 'description' => 'The URI of the remote ActivityPub object to fetch.', @@ -85,6 +87,31 @@ public function validate_url( $url ) { return (bool) \wp_http_validate_url( $url ); } + /** + * Verify read permission for proxy endpoint. + * + * The proxy is a read operation (fetching remote objects) even though it uses POST. + * This ensures clients with only read scope can use the proxy. + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public function verify_read_permission( $request ) { + // Try OAuth with read scope. + $oauth_result = OAuth_Server::check_oauth_permission( $request, Scope::READ ); + if ( true === $oauth_result ) { + return true; + } + + // If OAuth was attempted but failed, don't fall back. + if ( \is_wp_error( $oauth_result ) && OAuth_Server::is_oauth_request() ) { + return $oauth_result; + } + + // Fall back to Application Passwords. + return $this->verify_application_password(); + } + /** * Fetch a remote ActivityPub object via the proxy. * From 8731b1df194b31d3d4a8fda1edc4afc9f237acd5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 8 Feb 2026 11:10:49 +0100 Subject: [PATCH 035/105] Change proxy endpoint to GET for proper read scope inference --- includes/rest/class-proxy-controller.php | 31 ++----------------- .../rest/class-test-proxy-controller.php | 16 +++++----- 2 files changed, 10 insertions(+), 37 deletions(-) diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php index a870a452f0..4d9b6c4193 100644 --- a/includes/rest/class-proxy-controller.php +++ b/includes/rest/class-proxy-controller.php @@ -12,8 +12,6 @@ use Activitypub\Collection\Remote_Actors; use Activitypub\Http; -use Activitypub\OAuth\Scope; -use Activitypub\OAuth\Server as OAuth_Server; use function Activitypub\is_actor; @@ -49,9 +47,9 @@ public function register_routes() { '/' . $this->rest_base, array( array( - 'methods' => \WP_REST_Server::CREATABLE, + 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this, 'verify_read_permission' ), + 'permission_callback' => array( $this, 'verify_authentication' ), 'args' => array( 'id' => array( 'description' => 'The URI of the remote ActivityPub object to fetch.', @@ -87,31 +85,6 @@ public function validate_url( $url ) { return (bool) \wp_http_validate_url( $url ); } - /** - * Verify read permission for proxy endpoint. - * - * The proxy is a read operation (fetching remote objects) even though it uses POST. - * This ensures clients with only read scope can use the proxy. - * - * @param \WP_REST_Request $request The request object. - * @return bool|\WP_Error True if authorized, WP_Error otherwise. - */ - public function verify_read_permission( $request ) { - // Try OAuth with read scope. - $oauth_result = OAuth_Server::check_oauth_permission( $request, Scope::READ ); - if ( true === $oauth_result ) { - return true; - } - - // If OAuth was attempted but failed, don't fall back. - if ( \is_wp_error( $oauth_result ) && OAuth_Server::is_oauth_request() ) { - return $oauth_result; - } - - // Fall back to Application Passwords. - return $this->verify_application_password(); - } - /** * Fetch a remote ActivityPub object via the proxy. * diff --git a/tests/phpunit/tests/rest/class-test-proxy-controller.php b/tests/phpunit/tests/rest/class-test-proxy-controller.php index b9bb251f03..3d9a70f9c2 100644 --- a/tests/phpunit/tests/rest/class-test-proxy-controller.php +++ b/tests/phpunit/tests/rest/class-test-proxy-controller.php @@ -80,8 +80,8 @@ public function test_http_url_rejected() { // Mock OAuth authentication. $this->mock_oauth_auth(); - $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); - $request->set_body_params( array( 'id' => 'http://example.com/users/test' ) ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_query_params( array( 'id' => 'http://example.com/users/test' ) ); $response = $this->server->dispatch( $request ); @@ -102,8 +102,8 @@ public function test_private_network_rejected() { // Mock OAuth authentication. $this->mock_oauth_auth(); - $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); - $request->set_body_params( array( 'id' => 'https://192.168.1.1/users/test' ) ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_query_params( array( 'id' => 'https://192.168.1.1/users/test' ) ); $response = $this->server->dispatch( $request ); @@ -119,8 +119,8 @@ public function test_private_network_rejected() { * @covers ::verify_authentication */ public function test_requires_authentication() { - $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); - $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_query_params( array( 'id' => 'https://example.com/users/test' ) ); $response = $this->server->dispatch( $request ); @@ -158,8 +158,8 @@ function () use ( $actor_data ) { } ); - $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); - $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_query_params( array( 'id' => 'https://example.com/users/test' ) ); $response = $this->server->dispatch( $request ); From 90971cd6ccdc377a4b9cf93a6a24a123c76f67ef Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sun, 8 Feb 2026 15:57:18 +0100 Subject: [PATCH 036/105] Use post permalink as object ID instead of generating /objects/uuid When a C2S client submits a Create activity with an object that has no ID, the server now creates a WordPress post and uses its permalink as the object ID. Previously, a non-dereferenceable /objects/{uuid} URL was generated. The ensure_object_id() method now only sets attributedTo and published fields, leaving object ID assignment to handlers that create WordPress content (like Create::outgoing for Note/Article types). --- includes/rest/class-outbox-controller.php | 17 +++---- .../rest/class-test-outbox-controller.php | 46 +++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index cf560e7ff6..2e5147d52f 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -554,29 +554,24 @@ private function determine_visibility( $activity ) { } /** - * Ensure the activity object has an ID. + * Ensure the activity object has required fields. * - * For C2S activities, clients may not provide object IDs. - * The server must generate them. + * For C2S activities, clients may not provide all required fields. + * The server should fill in attributedTo and published, but object IDs + * should only be set by handlers that create WordPress content. * * @param array $data The activity data. * @param \Activitypub\Model\User|null $user The authenticated user. - * @return array The activity data with object ID ensured. + * @return array The activity data with required fields ensured. */ private function ensure_object_id( $data, $user ) { - // Check if there's an embedded object that needs an ID. + // Check if there's an embedded object that needs fields. if ( ! isset( $data['object'] ) || ! is_array( $data['object'] ) ) { return $data; } $object = &$data['object']; - // Generate ID if missing. - if ( empty( $object['id'] ) ) { - $uuid = \wp_generate_uuid4(); - $object['id'] = get_rest_url_by_path( 'objects/' . $uuid ); - } - // Set attributedTo if missing. if ( empty( $object['attributedTo'] ) && $user ) { $object['attributedTo'] = $user->get_id(); diff --git a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php index 59c328c6dc..9387868e24 100644 --- a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php @@ -647,4 +647,50 @@ public function test_get_item() { public function test_get_item_schema() { // Controller does not implement get_item_schema(). } + + /** + * Test C2S POST creates Note with proper object ID. + * + * When a client submits a Create activity with an object that has no ID, + * the server should create a WordPress post and use its permalink as the + * object ID (not generate a random /objects/uuid URL). + * + * @covers ::create_item + */ + public function test_c2s_create_note_object_id() { + $user = \Activitypub\Collection\Actors::get_by_id( self::$user_id ); + + $data = array( + 'type' => 'Create', + 'actor' => $user->get_id(), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'Hello from C2S test!', + // No ID provided - server should set it to the post permalink. + ), + ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . self::$user_id . '/outbox' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( $data ) ); + + \wp_set_current_user( self::$user_id ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $response_data = $response->get_data(); + + // The object should have an ID that's a post permalink, not /objects/uuid. + $this->assertArrayHasKey( 'object', $response_data ); + $object = $response_data['object']; + + if ( is_array( $object ) ) { + $this->assertArrayHasKey( 'id', $object ); + $this->assertStringNotContainsString( '/objects/', $object['id'], 'Object ID should not be a /objects/uuid URL' ); + $this->assertStringContainsString( '?p=', $object['id'], 'Object ID should be a post permalink' ); + } + } } From d41a76740d3df2eda8cc0cb6a2dbb74d0aaf9bb3 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 9 Feb 2026 18:12:53 +0100 Subject: [PATCH 037/105] Fix review issues and add outgoing handler tests. Address PR review feedback: guard Update handler against recursion, use (int) cast instead of absint() for negative actor IDs, switch to wp_redirect() for external OAuth URIs, require PKCE for public clients, add auth to introspection endpoint, remove dead render_consent_page(), convert multiline // comments to block comments, and add 24 tests covering all outgoing handler methods. --- includes/handler/class-delete.php | 6 +- includes/handler/class-update.php | 29 +- includes/oauth/class-authorization-code.php | 9 + includes/oauth/class-client.php | 18 +- includes/oauth/class-server.php | 19 +- includes/rest/class-oauth-controller.php | 126 ++------- includes/rest/trait-verification.php | 10 +- includes/scheduler/class-post.php | 6 +- .../includes/handler/class-test-announce.php | 53 ++++ .../includes/handler/class-test-delete.php | 126 +++++++++ .../includes/handler/class-test-follow.php | 114 ++++++++ .../includes/handler/class-test-like.php | 53 ++++ .../includes/handler/class-test-undo.php | 105 +++++++ .../includes/handler/class-test-update.php | 267 ++++++++++++++++++ 14 files changed, 817 insertions(+), 124 deletions(-) diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 42e7c9e1ba..8073d7230c 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -373,8 +373,10 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { return; } - // Find the post by its ActivityPub ID. - // First try to find a local post by permalink (for C2S-created posts). + /* + * Find the post by its ActivityPub ID. + * First try to find a local post by permalink (for C2S-created posts). + */ $post_id = \url_to_postid( $object_id ); $post = $post_id ? \get_post( $post_id ) : null; diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index d80ff3c9ad..8d85ff13df 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -18,6 +18,16 @@ * Handle Update requests. */ class Update { + /** + * Whether the outgoing handler is currently running. + * + * Used to prevent infinite recursion when wp_update_post() re-triggers + * the post scheduler which would fire another outbox Update. + * + * @var bool + */ + private static $is_outgoing = false; + /** * Initialize the class, registering WordPress hooks. */ @@ -159,6 +169,14 @@ public static function update_actor( $activity, $user_ids ) { * @param int $outbox_id The outbox post ID. */ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { + /* + * Prevent infinite recursion: wp_update_post() below re-triggers + * wp_after_insert_post → Post::triage() → outbox → this handler. + */ + if ( self::$is_outgoing ) { + return; + } + $object = $data['object'] ?? array(); if ( ! \is_array( $object ) ) { @@ -178,8 +196,10 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { return; } - // Find the post by its ActivityPub ID. - // First try to find a local post by permalink (for C2S-created posts). + /* + * Find the post by its ActivityPub ID. + * First try to find a local post by permalink (for C2S-created posts). + */ $post_id = \url_to_postid( $object_id ); $post = $post_id ? \get_post( $post_id ) : null; @@ -214,9 +234,11 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { 'post_excerpt' => $summary, ); - $post_id = \wp_update_post( $post_data, true ); + self::$is_outgoing = true; + $post_id = \wp_update_post( $post_data, true ); if ( \is_wp_error( $post_id ) ) { + self::$is_outgoing = false; return; } @@ -229,6 +251,7 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { * @param int $outbox_id The outbox post ID. */ \do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id ); + self::$is_outgoing = false; } /** diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index a14345f1b3..7a2307b771 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -58,6 +58,15 @@ public static function create( ); } + // PKCE is required for public clients (RFC 7636). + if ( $client->is_public() && empty( $code_challenge ) ) { + return new \WP_Error( + 'activitypub_pkce_required', + \__( 'PKCE code_challenge is required for public clients.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + // Filter scopes to only allowed ones. $filtered_scopes = $client->filter_scopes( Scope::validate( $scopes ) ); diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index bda2237479..2f2b7af446 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -391,8 +391,10 @@ public function is_valid_redirect_uri( $redirect_uri ) { return true; } - // RFC 8252 Section 7.3: For loopback redirects, allow any port. - // Compare scheme, host, and path - ignore port for 127.0.0.1 and localhost. + /* + * RFC 8252 Section 7.3: For loopback redirects, allow any port. + * Compare scheme, host, and path - ignore port for 127.0.0.1 and localhost. + */ foreach ( $allowed_uris as $allowed_uri ) { if ( self::is_loopback_redirect_match( $allowed_uri, $redirect_uri ) ) { return true; @@ -402,8 +404,10 @@ public function is_valid_redirect_uri( $redirect_uri ) { return false; } - // For auto-discovered clients without redirect_uris, use same-origin policy. - // The redirect_uri must be on the same host as the client_id. + /* + * For auto-discovered clients without redirect_uris, use same-origin policy. + * The redirect_uri must be on the same host as the client_id. + */ $client_id = $this->get_client_id(); if ( filter_var( $client_id, FILTER_VALIDATE_URL ) ) { $client_host = \wp_parse_url( $client_id, PHP_URL_HOST ); @@ -589,8 +593,10 @@ private static function validate_uri_format( $uri ) { // Allow http for localhost development. if ( 'http' === $parsed['scheme'] ) { - // Include both bracketed and unbracketed IPv6 loopback since parse_url - // may return either format depending on PHP version. + /* + * Include both bracketed and unbracketed IPv6 loopback since parse_url + * may return either format depending on PHP version. + */ $localhost_hosts = array( 'localhost', '127.0.0.1', '[::1]', '::1' ); if ( ! in_array( $parsed['host'], $localhost_hosts, true ) ) { return false; diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index b05864eace..2a40deeb2d 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -46,8 +46,10 @@ public static function init() { * @return \WP_Error|null|bool Authentication result. */ public static function authenticate_oauth( $result ) { - // Reset OAuth state at the start of each authentication to prevent - // leaking state between multiple REST dispatches in the same process. + /* + * Reset OAuth state at the start of each authentication to prevent + * leaking state between multiple REST dispatches in the same process. + */ self::$current_token = null; // If another authentication method already succeeded, use that. @@ -318,7 +320,7 @@ public static function get_metadata() { 'response_modes_supported' => array( 'query' ), 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ), 'token_endpoint_auth_methods_supported' => array( 'none', 'client_secret_post' ), - 'introspection_endpoint_auth_methods_supported' => array( 'none' ), + 'introspection_endpoint_auth_methods_supported' => array( 'bearer' ), 'code_challenge_methods_supported' => array( 'S256', 'plain' ), 'service_documentation' => 'https://github.com/swicg/activitypub-api', ); @@ -435,7 +437,13 @@ private static function process_authorize_form() { ), $redirect_uri ); - \wp_safe_redirect( $error_url ); + + /* + * wp_safe_redirect() blocks external domains, but OAuth redirect_uris + * are always external. The URI is pre-validated against the registered + * client's redirect_uris by render_authorize_form(). + */ + \wp_redirect( $error_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } @@ -459,7 +467,8 @@ private static function process_authorize_form() { ), $redirect_uri ); - \wp_safe_redirect( $error_url ); + // See comment above regarding wp_redirect vs wp_safe_redirect. + \wp_redirect( $error_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index 6a22878f86..f3624d316d 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -122,7 +122,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'introspect' ), - 'permission_callback' => '__return_true', + 'permission_callback' => array( $this, 'introspect_permissions_check' ), 'args' => array( 'token' => array( 'description' => 'The token to introspect.', @@ -234,8 +234,10 @@ public function authorize( \WP_REST_Request $request ) { // Check for PKCE (recommended but optional for compatibility). $code_challenge = $request->get_param( 'code_challenge' ); - // Redirect to wp-login.php with action=activitypub_authorize. - // This uses WordPress's login_form_{action} hook for proper cookie auth. + /* + * Redirect to wp-login.php with action=activitypub_authorize. + * This uses WordPress's login_form_{action} hook for proper cookie auth. + */ $login_url = \wp_login_url(); $login_url = \add_query_arg( array( @@ -347,6 +349,25 @@ public function authorize_submit_permissions_check( \WP_REST_Request $request ) return true; } + /** + * Permission check for token introspection. + * + * Per RFC 7662, the introspection endpoint must be protected. + * + * @return bool|\WP_Error True if allowed, error otherwise. + */ + public function introspect_permissions_check() { + if ( \is_user_logged_in() ) { + return true; + } + + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'Authentication required.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + /** * Handle token request (POST /oauth/token). * @@ -676,103 +697,4 @@ private function redirect_with_error( $redirect_uri, $error, $description, $stat array( 'Location' => $redirect_url ) ); } - - /** - * Render the consent page HTML. - * - * @param Client $client The OAuth client. - * @param array $scopes Requested scopes. - * @param \WP_User $user The current user. - * @param array $params Request parameters. - * @param string $nonce Security nonce. - * @return string HTML content. - */ - private function render_consent_page( $client, $scopes, $user, $params, $nonce ) { - $action_url = \rest_url( $this->namespace . '/' . $this->rest_base . '/authorize' ); - $site_name = \get_bloginfo( 'name' ); - - ob_start(); - ?> - - > - - - - <?php esc_html_e( 'Authorize Application', 'activitypub' ); ?> - <?php echo esc_html( $site_name ); ?> - - - -
-

- -

- ' . esc_html( $client->get_name() ) . '' - ); - ?> -

- - - - -
-

-
    - -
  • - -
-
- - -
- $value ) : ?> - - - - - - -
- - -
-
-
- - - maybe_verify_owner( $request ); } - // If OAuth was attempted (Bearer token present), don't fall back to Application Passwords. - // This prevents scope bypass when OAuth auth succeeds but scope check fails. + /* + * If OAuth was attempted (Bearer token present), don't fall back to Application Passwords. + * This prevents scope bypass when OAuth auth succeeds but scope check fails. + */ if ( \is_wp_error( $oauth_result ) && OAuth_Server::is_oauth_request() ) { return $oauth_result; } @@ -167,12 +169,12 @@ public function verify_owner( $request ) { // Try OAuth token first. $token = OAuth_Server::get_current_token(); - if ( $token && $token->get_user_id() === \absint( $user_id ) ) { + if ( $token && $token->get_user_id() === (int) $user_id ) { return true; } // Fall back to WordPress authenticated user (Application Passwords). - if ( \is_user_logged_in() && \get_current_user_id() === \absint( $user_id ) ) { + if ( \is_user_logged_in() && \get_current_user_id() === (int) $user_id ) { return true; } diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index 236d5ea0a1..587e8a9128 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -98,8 +98,10 @@ public static function triage( $post_id, $post, $update, $post_before ) { return; } - // If the post was already federated and this is a Create, skip. - // The outbox controller already added it to the outbox. + /* + * If the post was already federated and this is a Create, skip. + * The outbox controller already added it to the outbox. + */ if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status && 'Create' === $type ) { return; } diff --git a/tests/phpunit/tests/includes/handler/class-test-announce.php b/tests/phpunit/tests/includes/handler/class-test-announce.php index f43d5ebcad..5821cdf5db 100644 --- a/tests/phpunit/tests/includes/handler/class-test-announce.php +++ b/tests/phpunit/tests/includes/handler/class-test-announce.php @@ -307,6 +307,59 @@ public function test_external_actor_announce_not_ignored() { \remove_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) ); } + /** + * Test outgoing Announce fires action hook. + * + * @covers ::outgoing + */ + public function test_outgoing_fires_action() { + $object_url = 'https://example.com/post/123'; + $fired = false; + + $callback = function ( $url ) use ( &$fired, $object_url ) { + if ( $url === $object_url ) { + $fired = true; + } + }; + \add_action( 'activitypub_outbox_announce_sent', $callback ); + + $data = array( + 'type' => 'Announce', + 'object' => $object_url, + ); + + Announce::outgoing( $data, $this->user_id, null, 0 ); + + $this->assertTrue( $fired, 'activitypub_outbox_announce_sent action should fire.' ); + + \remove_action( 'activitypub_outbox_announce_sent', $callback ); + } + + /** + * Test outgoing Announce returns early for empty object. + * + * @covers ::outgoing + */ + public function test_outgoing_returns_early_for_empty_object() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_announce_sent', $callback ); + + $data = array( + 'type' => 'Announce', + 'object' => '', + ); + + Announce::outgoing( $data, $this->user_id, null, 0 ); + + $this->assertFalse( $fired, 'Action should not fire for empty object.' ); + + \remove_action( 'activitypub_outbox_announce_sent', $callback ); + } + /** * Test that announces from same domain but different actor are not ignored. * diff --git a/tests/phpunit/tests/includes/handler/class-test-delete.php b/tests/phpunit/tests/includes/handler/class-test-delete.php index 10d70ed463..81ab189a43 100644 --- a/tests/phpunit/tests/includes/handler/class-test-delete.php +++ b/tests/phpunit/tests/includes/handler/class-test-delete.php @@ -10,6 +10,7 @@ use Activitypub\Activity\Activity; use Activitypub\Activity\Base_Object; use Activitypub\Handler\Delete; +use Activitypub\Scheduler\Post; use Activitypub\Tombstone; /** @@ -43,6 +44,9 @@ public static function set_up_before_class() { public function set_up() { parent::set_up(); + // Prevent wp_trash_post() from triggering the full outbox chain. + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + \add_filter( 'pre_get_remote_metadata_by_actor', array( self::class, 'get_remote_metadata_by_actor' ), 0, 2 ); } @@ -52,6 +56,8 @@ public function set_up() { public function tear_down() { \remove_filter( 'pre_get_remote_metadata_by_actor', array( self::class, 'get_remote_metadata_by_actor' ) ); + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + parent::tear_down(); } @@ -495,6 +501,126 @@ public function test_delete_object_with_tombstone_string_id() { \remove_filter( 'pre_http_request', $filter ); } + /** + * Test outgoing Delete trashes a local post. + * + * @covers ::outgoing + */ + public function test_outgoing_trashes_post() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + 'post_title' => 'To Be Deleted', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Delete', + 'object' => $permalink, + ); + + Delete::outgoing( $data, self::$user_id, null, 0 ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'trash', $post->post_status ); + } + + /** + * Test outgoing Delete fires action hook on success. + * + * @covers ::outgoing + */ + public function test_outgoing_fires_action() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_deleted_post', $callback ); + + $data = array( + 'type' => 'Delete', + 'object' => $permalink, + ); + + Delete::outgoing( $data, self::$user_id, null, 0 ); + + $this->assertTrue( $fired, 'activitypub_outbox_deleted_post action should fire.' ); + + \remove_action( 'activitypub_outbox_deleted_post', $callback ); + } + + /** + * Test outgoing Delete skips posts not owned by user. + * + * @covers ::outgoing + */ + public function test_outgoing_skips_unowned_post() { + $other_user = self::factory()->user->create(); + $post_id = self::factory()->post->create( + array( + 'post_author' => $other_user, + 'post_status' => 'publish', + 'post_title' => 'Not My Post', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Delete', + 'object' => $permalink, + ); + + Delete::outgoing( $data, self::$user_id, null, 0 ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'publish', $post->post_status ); + } + + /** + * Test outgoing Delete returns early for empty object. + * + * @covers ::outgoing + */ + public function test_outgoing_returns_early_for_empty_object() { + $data = array( + 'type' => 'Delete', + 'object' => '', + ); + + // Should not throw errors. + Delete::outgoing( $data, self::$user_id, null, 0 ); + $this->assertTrue( true ); + } + + /** + * Test outgoing Delete returns early for non-existent post. + * + * @covers ::outgoing + */ + public function test_outgoing_returns_early_for_nonexistent_post() { + $data = array( + 'type' => 'Delete', + 'object' => 'https://example.com/nonexistent/post', + ); + + // Should not throw errors. + Delete::outgoing( $data, self::$user_id, null, 0 ); + $this->assertTrue( true ); + } + /** * Get remote metadata by actor. * diff --git a/tests/phpunit/tests/includes/handler/class-test-follow.php b/tests/phpunit/tests/includes/handler/class-test-follow.php index aa5ca1139a..fc6815c8ca 100644 --- a/tests/phpunit/tests/includes/handler/class-test-follow.php +++ b/tests/phpunit/tests/includes/handler/class-test-follow.php @@ -353,6 +353,120 @@ public function test_queue_reject() { $this->assertEquals( $actor_url, $activity_json['object']['actor'] ); } + /** + * Test outgoing Follow adds pending follow metadata. + * + * @covers ::outgoing + */ + public function test_outgoing_adds_pending_follow() { + $actor_url = 'https://example.com/users/to-follow'; + + // Mock the HTTP request used by Remote_Actors::fetch_by_uri(). + $mock_callback = function ( $response, $url ) use ( $actor_url ) { + if ( $url === $actor_url ) { + return array( + 'id' => $actor_url, + 'type' => 'Person', + 'preferredUsername' => 'tofollow', + 'inbox' => $actor_url . '/inbox', + ); + } + return $response; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $mock_callback, 10, 2 ); + + $data = array( + 'type' => 'Follow', + 'object' => $actor_url, + ); + + Follow::outgoing( $data, self::$user_id, null, 0 ); + + // Verify pending follow was added. + $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url ); + $this->assertNotWPError( $remote_actor ); + + $pending = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); + $this->assertContains( (string) self::$user_id, $pending ); + + \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_callback ); + } + + /** + * Test outgoing Follow skips if already following. + * + * @covers ::outgoing + */ + public function test_outgoing_skips_if_already_following() { + $actor_url = 'https://example.com/users/already-following'; + + // Mock the HTTP request used by Remote_Actors::fetch_by_uri(). + $mock_callback = function ( $response, $url ) use ( $actor_url ) { + if ( $url === $actor_url ) { + return array( + 'id' => $actor_url, + 'type' => 'Person', + 'preferredUsername' => 'alreadyfollowing', + 'inbox' => $actor_url . '/inbox', + ); + } + return $response; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $mock_callback, 10, 2 ); + + $data = array( + 'type' => 'Follow', + 'object' => $actor_url, + ); + + // First follow should succeed. + Follow::outgoing( $data, self::$user_id, null, 0 ); + + $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url ); + $pending = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); + $count_after_first = count( $pending ); + + // Second follow should be skipped (already pending). + Follow::outgoing( $data, self::$user_id, null, 0 ); + + $pending_after = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); + $this->assertCount( $count_after_first, $pending_after, 'Should not add duplicate pending follow.' ); + + \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_callback ); + } + + /** + * Test outgoing Follow returns early for empty object. + * + * @covers ::outgoing + */ + public function test_outgoing_returns_early_for_empty_object() { + $data = array( + 'type' => 'Follow', + 'object' => '', + ); + + // Should not throw errors. + Follow::outgoing( $data, self::$user_id, null, 0 ); + $this->assertTrue( true ); + } + + /** + * Test outgoing Follow returns early for non-string object. + * + * @covers ::outgoing + */ + public function test_outgoing_returns_early_for_non_string_object() { + $data = array( + 'type' => 'Follow', + 'object' => array( 'id' => 'https://example.com/user' ), + ); + + // Should not throw errors. + Follow::outgoing( $data, self::$user_id, null, 0 ); + $this->assertTrue( true ); + } + /** * Test that deprecated hook still fires for backward compatibility. * diff --git a/tests/phpunit/tests/includes/handler/class-test-like.php b/tests/phpunit/tests/includes/handler/class-test-like.php index 595ddd575c..bf544429d4 100644 --- a/tests/phpunit/tests/includes/handler/class-test-like.php +++ b/tests/phpunit/tests/includes/handler/class-test-like.php @@ -301,6 +301,59 @@ public function test_handle_like_action_hook() { \remove_action( 'activitypub_handled_like', $handled_like_callback ); } + /** + * Test outgoing Like fires action hook. + * + * @covers ::outgoing + */ + public function test_outgoing_fires_action() { + $object_url = 'https://example.com/post/456'; + $fired = false; + + $callback = function ( $url ) use ( &$fired, $object_url ) { + if ( $url === $object_url ) { + $fired = true; + } + }; + \add_action( 'activitypub_outbox_like_sent', $callback ); + + $data = array( + 'type' => 'Like', + 'object' => $object_url, + ); + + Like::outgoing( $data, $this->user_id, null, 0 ); + + $this->assertTrue( $fired, 'activitypub_outbox_like_sent action should fire.' ); + + \remove_action( 'activitypub_outbox_like_sent', $callback ); + } + + /** + * Test outgoing Like returns early for empty object. + * + * @covers ::outgoing + */ + public function test_outgoing_returns_early_for_empty_object() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_like_sent', $callback ); + + $data = array( + 'type' => 'Like', + 'object' => '', + ); + + Like::outgoing( $data, $this->user_id, null, 0 ); + + $this->assertFalse( $fired, 'Action should not fire for empty object.' ); + + \remove_action( 'activitypub_outbox_like_sent', $callback ); + } + /** * Test outbox_activity method with Like activity. * diff --git a/tests/phpunit/tests/includes/handler/class-test-undo.php b/tests/phpunit/tests/includes/handler/class-test-undo.php index d8d8ad71c4..d8f6b3ddd4 100644 --- a/tests/phpunit/tests/includes/handler/class-test-undo.php +++ b/tests/phpunit/tests/includes/handler/class-test-undo.php @@ -328,6 +328,111 @@ public function test_handle_undo_action_hook() { \remove_filter( 'pre_get_remote_metadata_by_actor', $mock_actor_metadata ); } + /** + * Test outgoing Undo Follow removes following metadata. + * + * @covers ::outgoing + */ + public function test_outgoing_undo_follow() { + $actor_url = 'https://example.com/users/unfollow-target'; + + // Mock the HTTP request used by Remote_Actors::fetch_by_uri(). + $mock_callback = function ( $response, $url ) use ( $actor_url ) { + if ( $url === $actor_url ) { + return array( + 'id' => $actor_url, + 'type' => 'Person', + 'preferredUsername' => 'unfollowtarget', + 'inbox' => $actor_url . '/inbox', + ); + } + return $response; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $mock_callback, 10, 2 ); + + // First add the following metadata by triggering a Follow outgoing. + \Activitypub\Handler\Follow::outgoing( + array( + 'type' => 'Follow', + 'object' => $actor_url, + ), + self::$user_id, + null, + 0 + ); + + $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url ); + $this->assertNotWPError( $remote_actor ); + + // Verify pending metadata exists. + $pending = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); + $this->assertNotEmpty( $pending, 'Pending follow metadata should exist before undo.' ); + + // Now undo the follow. + $data = array( + 'type' => 'Undo', + 'object' => array( + 'type' => 'Follow', + 'object' => $actor_url, + ), + ); + + Undo::outgoing( $data, self::$user_id, null, 0 ); + + // Verify following/pending metadata was removed. + $pending_after = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); + $following_after = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::FOLLOWING_META_KEY, false ); + + $this->assertNotContains( (string) self::$user_id, $pending_after, 'Pending metadata should be removed.' ); + $this->assertNotContains( (string) self::$user_id, $following_after, 'Following metadata should be removed.' ); + + \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_callback ); + } + + /** + * Test outgoing Undo ignores non-Follow types. + * + * @covers ::outgoing + */ + public function test_outgoing_ignores_non_follow() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_undo_follow_sent', $callback ); + + $data = array( + 'type' => 'Undo', + 'object' => array( + 'type' => 'Like', + 'object' => 'https://example.com/post/123', + ), + ); + + Undo::outgoing( $data, self::$user_id, null, 0 ); + + $this->assertFalse( $fired, 'Action should not fire for non-Follow undo.' ); + + \remove_action( 'activitypub_outbox_undo_follow_sent', $callback ); + } + + /** + * Test outgoing Undo returns early for non-array object. + * + * @covers ::outgoing + */ + public function test_outgoing_returns_early_for_string_object() { + $data = array( + 'type' => 'Undo', + 'object' => 'https://example.com/activity/123', + ); + + // Should not throw errors. + Undo::outgoing( $data, self::$user_id, null, 0 ); + $this->assertTrue( true ); + } + /** * Test validate_object with various scenarios. * diff --git a/tests/phpunit/tests/includes/handler/class-test-update.php b/tests/phpunit/tests/includes/handler/class-test-update.php index 709b869f45..69f4b40ced 100644 --- a/tests/phpunit/tests/includes/handler/class-test-update.php +++ b/tests/phpunit/tests/includes/handler/class-test-update.php @@ -11,6 +11,7 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Remote_Actors; use Activitypub\Handler\Update; +use Activitypub\Scheduler\Post; /** * Update Handler Test Class. @@ -73,9 +74,21 @@ public function test_activitypub_inbox_create_fallback() { public function set_up() { parent::set_up(); + // Prevent wp_update_post() from triggering the full outbox chain. + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + $this->user_id = self::factory()->user->create(); } + /** + * Tear down the test. + */ + public function tear_down() { + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + + parent::tear_down(); + } + /** * Test updating an actor with various scenarios. * @@ -125,6 +138,260 @@ public function test_update_actor( $activity_data, $http_response, $expected_out \remove_filter( 'activitypub_pre_http_get_remote_object', $fake_request ); } + /** + * Test outgoing Update with a Note updates the post. + * + * @covers ::outgoing + */ + public function test_outgoing_updates_post() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_title' => 'Original Title', + 'post_content' => 'Original content', + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'Updated content', + 'name' => 'Updated Title', + 'summary' => 'Updated summary', + ), + ); + + Update::outgoing( $data, $this->user_id, null, 0 ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'Updated Title', $post->post_title ); + $this->assertEquals( 'Updated content', $post->post_content ); + $this->assertEquals( 'Updated summary', $post->post_excerpt ); + } + + /** + * Test outgoing Update generates title from content for Notes without name. + * + * @covers ::outgoing + */ + public function test_outgoing_generates_title_from_content() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'This is a short note without a title field.', + ), + ); + + Update::outgoing( $data, $this->user_id, null, 0 ); + + $post = \get_post( $post_id ); + $this->assertNotEmpty( $post->post_title ); + $this->assertStringContainsString( 'This is a short', $post->post_title ); + } + + /** + * Test outgoing Update ignores non-Note/Article types. + * + * @covers ::outgoing + */ + public function test_outgoing_ignores_unsupported_types() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_title' => 'Original', + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Event', + 'id' => $permalink, + 'content' => 'Should not update', + ), + ); + + Update::outgoing( $data, $this->user_id, null, 0 ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'Original', $post->post_title ); + } + + /** + * Test outgoing Update skips posts not owned by user. + * + * @covers ::outgoing + */ + public function test_outgoing_skips_unowned_post() { + $other_user = self::factory()->user->create(); + $post_id = self::factory()->post->create( + array( + 'post_author' => $other_user, + 'post_title' => 'Other User Post', + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'Should not update', + 'name' => 'Hijacked', + ), + ); + + Update::outgoing( $data, $this->user_id, null, 0 ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'Other User Post', $post->post_title ); + } + + /** + * Test outgoing Update returns early for non-array object. + * + * @covers ::outgoing + */ + public function test_outgoing_returns_early_for_string_object() { + $data = array( + 'type' => 'Update', + 'object' => 'https://example.com/note/1', + ); + + // Should not throw errors. + Update::outgoing( $data, $this->user_id, null, 0 ); + $this->assertTrue( true ); + } + + /** + * Test outgoing Update returns early for empty object ID. + * + * @covers ::outgoing + */ + public function test_outgoing_returns_early_for_empty_id() { + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'content' => 'No ID provided', + ), + ); + + // Should not throw errors. + Update::outgoing( $data, $this->user_id, null, 0 ); + $this->assertTrue( true ); + } + + /** + * Test outgoing Update fires action hook on success. + * + * @covers ::outgoing + */ + public function test_outgoing_fires_action() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_updated_post', $callback ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'Updated', + ), + ); + + Update::outgoing( $data, $this->user_id, null, 0 ); + + $this->assertTrue( $fired, 'activitypub_outbox_updated_post action should fire.' ); + + \remove_action( 'activitypub_outbox_updated_post', $callback ); + } + + /** + * Test outgoing Update recursion guard prevents infinite loop. + * + * @covers ::outgoing + */ + public function test_outgoing_recursion_guard() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_status' => 'publish', + 'post_title' => 'Original', + ) + ); + + $permalink = \get_permalink( $post_id ); + $call_count = 0; + + // Hook into the update action to count calls and re-trigger. + $callback = function () use ( &$call_count, $permalink ) { + ++$call_count; + + // Simulate what the scheduler would do: re-trigger the outgoing handler. + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'Re-triggered', + ), + ); + Update::outgoing( $data, 0, null, 0 ); + }; + \add_action( 'activitypub_outbox_updated_post', $callback ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'First update', + ), + ); + + Update::outgoing( $data, $this->user_id, null, 0 ); + + // Should only fire once due to recursion guard. + $this->assertEquals( 1, $call_count, 'Recursion guard should prevent re-entrant calls.' ); + + \remove_action( 'activitypub_outbox_updated_post', $callback ); + } + /** * Data provider for update_actor tests. * From 9597acbae936344140f5ca4ecda87a314098b898 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 15:16:37 +0100 Subject: [PATCH 038/105] Set post format to 'status' for Notes created via C2S. When a Note is submitted via C2S outbox, set the WordPress post format to 'status' so the transformer correctly maps it back to a Note type instead of Article. Props @steve-bate --- includes/handler/class-create.php | 12 ++- .../includes/handler/class-test-create.php | 4 +- .../rest/class-test-outbox-controller.php | 86 +++++++++++++++++++ 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index 8a4c628ee1..4925172910 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -185,9 +185,10 @@ private static function incoming_post( $activity, $user_ids ) { private static function outgoing_post( $activity, $user_id, $visibility ) { $object = $activity['object'] ?? array(); - $content = $object['content'] ?? ''; - $name = $object['name'] ?? ''; - $summary = $object['summary'] ?? ''; + $object_type = $object['type'] ?? ''; + $content = $object['content'] ?? ''; + $name = $object['name'] ?? ''; + $summary = $object['summary'] ?? ''; // Use name as title for Articles, or generate from content for Notes. $title = $name; @@ -218,6 +219,11 @@ private static function outgoing_post( $activity, $user_id, $visibility ) { return $post_id; } + // Set post format to 'status' for Notes so the transformer maps it back correctly. + if ( 'Note' === $object_type ) { + \set_post_format( $post_id, 'status' ); + } + $post = \get_post( $post_id ); /** diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index d6a1af7d4b..298b41d0ae 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -795,7 +795,7 @@ public function test_soft_delete_refederate_lifecycle() { * in `inReplyTo` to target the local test post. * * @dataProvider create_fixture_provider - * @covers ::handle_create + * @covers ::incoming * * @param string $path The path to the fixture JSON file. */ @@ -805,7 +805,7 @@ public function test_handle_create_from_fixture( $path ) { $activity = json_decode( $json, true ); - Create::handle_create( $activity, $this->user_id ); + Create::incoming( $activity, $this->user_id ); $comments = ( new \WP_Comment_Query( array( diff --git a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php index 9387868e24..e2de05fb32 100644 --- a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php @@ -693,4 +693,90 @@ public function test_c2s_create_note_object_id() { $this->assertStringContainsString( '?p=', $object['id'], 'Object ID should be a post permalink' ); } } + + /** + * Test C2S POST creates Note with 'status' post format. + * + * When a client submits a Note via C2S, the created WordPress post + * should have the 'status' post format so that the transformer maps + * it back to a Note type. + * + * @covers ::create_item + */ + public function test_c2s_create_note_sets_status_post_format() { + $user = \Activitypub\Collection\Actors::get_by_id( self::$user_id ); + + $data = array( + 'type' => 'Create', + 'actor' => $user->get_id(), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'A short status note via C2S.', + ), + ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . self::$user_id . '/outbox' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( $data ) ); + + \wp_set_current_user( self::$user_id ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $response_data = $response->get_data(); + $object = $response_data['object']; + + // Find the created post by its permalink. + if ( is_array( $object ) && ! empty( $object['id'] ) ) { + $post_id = \url_to_postid( $object['id'] ); + $this->assertGreaterThan( 0, $post_id, 'Should find a post from the object ID.' ); + $this->assertSame( 'status', \get_post_format( $post_id ), 'Note should have status post format.' ); + } + } + + /** + * Test C2S POST creates Article without post format. + * + * When a client submits an Article via C2S, the created WordPress post + * should not have a post format set (standard format). + * + * @covers ::create_item + */ + public function test_c2s_create_article_has_no_post_format() { + $user = \Activitypub\Collection\Actors::get_by_id( self::$user_id ); + + $data = array( + 'type' => 'Create', + 'actor' => $user->get_id(), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Article', + 'name' => 'My Article Title', + 'content' => '

This is a full article with a title.

', + ), + ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . self::$user_id . '/outbox' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( $data ) ); + + \wp_set_current_user( self::$user_id ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $response_data = $response->get_data(); + $object = $response_data['object']; + + // Find the created post by its permalink. + if ( is_array( $object ) && ! empty( $object['id'] ) ) { + $post_id = \url_to_postid( $object['id'] ); + $this->assertGreaterThan( 0, $post_id, 'Should find a post from the object ID.' ); + $this->assertFalse( \get_post_format( $post_id ), 'Article should have standard (no) post format.' ); + } + } } From 4c977e37aa0f725103d33b60c79569aea62bfc40 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 16:16:59 +0100 Subject: [PATCH 039/105] Add CORS headers to HEAD requests and centralize CORS handling. Move add_headers() to the send_headers hook so it fires for HEAD requests too (template_include does not fire for HEAD). Inline CORS headers in add_headers() and remove the separate add_cors_headers() method. Move WebFinger CORS from inline response header to the centralized rest_post_dispatch filter. Props @steve-bate --- includes/class-router.php | 26 +++++-------------- includes/oauth/class-server.php | 5 ++++ includes/rest/class-webfinger-controller.php | 3 +-- .../rest/class-test-webfinger-controller.php | 1 - 4 files changed, 12 insertions(+), 23 deletions(-) diff --git a/includes/class-router.php b/includes/class-router.php index 16fd886306..ee0327815c 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -20,6 +20,7 @@ class Router { public static function init() { \add_action( 'init', array( self::class, 'add_rewrite_rules' ), 11 ); + \add_action( 'send_headers', array( self::class, 'add_headers' ) ); \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 ); @@ -80,8 +81,6 @@ public static function render_activitypub_template( $template ) { return $template; } - self::add_headers(); - if ( ! is_activitypub_request() || ! should_negotiate_content() ) { if ( \get_query_var( 'p' ) && Outbox::POST_TYPE === \get_post_type( \get_query_var( 'p' ) ) ) { \set_query_var( 'is_404', true ); @@ -97,7 +96,7 @@ public static function render_activitypub_template( $template ) { if ( ! $activitypub_object ) { \status_header( 410 ); } - self::add_cors_headers(); + return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php'; } @@ -143,7 +142,6 @@ public static function render_activitypub_template( $template ) { \status_header( 200 ); } - self::add_cors_headers(); return $activitypub_template; } @@ -167,6 +165,10 @@ public static function add_headers() { // Send Vary header for Accept header. \header( 'Vary: Accept', false ); } + + \header( 'Access-Control-Allow-Origin: *' ); + \header( 'Access-Control-Allow-Methods: GET, OPTIONS' ); + \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' ); } add_action( @@ -177,22 +179,6 @@ static function () use ( $id ) { ); } - /** - * Add CORS headers for ActivityPub JSON responses. - * - * This enables C2S clients to fetch actor profiles and other - * ActivityPub objects directly from the browser. - */ - private static function add_cors_headers() { - if ( \headers_sent() ) { - return; - } - - \header( 'Access-Control-Allow-Origin: *' ); - \header( 'Access-Control-Allow-Methods: GET, OPTIONS' ); - \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' ); - } - /** * Remove trailing slash from ActivityPub @username requests. * diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 2a40deeb2d..28e877bf8e 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -297,6 +297,11 @@ private static function route_needs_cors( $route ) { return true; } + // WebFinger endpoint. + if ( $namespace . '/webfinger' === $route ) { + return true; + } + return false; } diff --git a/includes/rest/class-webfinger-controller.php b/includes/rest/class-webfinger-controller.php index ee75e7addb..356f087e67 100644 --- a/includes/rest/class-webfinger-controller.php +++ b/includes/rest/class-webfinger-controller.php @@ -85,8 +85,7 @@ public function get_item( $request ) { $response, $code, array( - 'Access-Control-Allow-Origin' => '*', - 'Content-Type' => 'application/jrd+json; charset=' . \get_option( 'blog_charset' ), + 'Content-Type' => 'application/jrd+json; charset=' . \get_option( 'blog_charset' ), ) ); } diff --git a/tests/phpunit/tests/includes/rest/class-test-webfinger-controller.php b/tests/phpunit/tests/includes/rest/class-test-webfinger-controller.php index e6025bed17..eff3a4c24b 100644 --- a/tests/phpunit/tests/includes/rest/class-test-webfinger-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-webfinger-controller.php @@ -94,7 +94,6 @@ public function test_get_item() { $this->assertEquals( 200, $response->get_status() ); $this->assertStringContainsString( 'application/jrd+json', $response->get_headers()['Content-Type'] ); - $this->assertEquals( '*', $response->get_headers()['Access-Control-Allow-Origin'] ); } /** From 82bd497641f76cf4a23cfeafa9d42ddb57666971 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 17:19:16 +0100 Subject: [PATCH 040/105] Make PKCE optional, add WRITE scope, show warning Relax strict PKCE enforcement for public clients to preserve compatibility with existing C2S clients (keep PKCE recommended per RFC 7636 but not required). Add WRITE to DEFAULT_SCOPES so write permissions are included by default. Show a non-blocking warning on the OAuth authorize page when the client does not provide a PKCE code_challenge to surface decreased security to the user. --- includes/oauth/class-authorization-code.php | 12 ++++-------- includes/oauth/class-scope.php | 1 + templates/oauth-authorize.php | 9 +++++++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index 7a2307b771..6dc79a8e30 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -58,14 +58,10 @@ public static function create( ); } - // PKCE is required for public clients (RFC 7636). - if ( $client->is_public() && empty( $code_challenge ) ) { - return new \WP_Error( - 'activitypub_pkce_required', - \__( 'PKCE code_challenge is required for public clients.', 'activitypub' ), - array( 'status' => 400 ) - ); - } + /* + * PKCE is recommended for public clients (RFC 7636) but not enforced + * to maintain compatibility with existing C2S clients. + */ // Filter scopes to only allowed ones. $filtered_scopes = $client->filter_scopes( Scope::validate( $scopes ) ); diff --git a/includes/oauth/class-scope.php b/includes/oauth/class-scope.php index b1c47084d1..d91757f1ca 100644 --- a/includes/oauth/class-scope.php +++ b/includes/oauth/class-scope.php @@ -71,6 +71,7 @@ class Scope { */ const DEFAULT_SCOPES = array( self::READ, + self::WRITE, ); /** diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php index b7fea45ed2..6dee2a8715 100644 --- a/templates/oauth-authorize.php +++ b/templates/oauth-authorize.php @@ -23,6 +23,15 @@ // Use WordPress login page header. $login_errors = new WP_Error(); + +if ( empty( $code_challenge ) ) { + $login_errors->add( + 'pkce_missing', + __( 'Warning: This client does not support PKCE. The connection may be less secure.', 'activitypub' ), + 'message' + ); +} + login_header( /* translators: %s: Client name */ sprintf( __( 'Authorize %s', 'activitypub' ), esc_html( $client_name ?: $client_id ) ), From 9153715f0d1b335638c3281f6e455facc2e895c1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 17:38:32 +0100 Subject: [PATCH 041/105] Add tests for Verification trait and Create handler outgoing methods Cover the security-critical Verification trait (verify_signature, verify_application_password, verify_authentication, verify_owner) and the new C2S outgoing/outgoing_post methods on the Create handler. --- .../includes/handler/class-test-create.php | 274 +++++++++++ .../rest/class-test-trait-verification.php | 453 ++++++++++++++++++ 2 files changed, 727 insertions(+) create mode 100644 tests/phpunit/tests/includes/rest/class-test-trait-verification.php diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index 298b41d0ae..2713318d4a 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -12,6 +12,7 @@ use Activitypub\Collection\Posts; use Activitypub\Handler\Create; use Activitypub\Post_Types; +use Activitypub\Scheduler\Post; use Activitypub\Tombstone; /** @@ -834,4 +835,277 @@ public function create_fixture_provider() { return $fixtures; } + + /** + * Test outgoing Note creates a post with status post format. + * + * @covers ::outgoing + */ + public function test_outgoing_note_creates_post_with_status_format() { + // Prevent wp_insert_post() from triggering the full outbox chain. + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => '

Hello from the Fediverse!

', + ), + ); + + $result = Create::outgoing( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertEquals( 'status', \get_post_format( $result->ID ) ); + $this->assertStringContainsString( 'Hello from the Fediverse!', $result->post_content ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing Article creates a post without post format. + * + * @covers ::outgoing + */ + public function test_outgoing_article_creates_post_without_format() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Article', + 'name' => 'My Article Title', + 'content' => '

Article body here.

', + ), + ); + + $result = Create::outgoing( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertFalse( \get_post_format( $result->ID ) ); + $this->assertEquals( 'My Article Title', $result->post_title ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing private visibility returns false. + * + * @covers ::outgoing + */ + public function test_outgoing_private_visibility_returns_false() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://example.com/users/recipient' ), // Private message. + 'object' => array( + 'type' => 'Note', + 'content' => 'Private note.', + 'to' => array( 'https://example.com/users/recipient' ), + ), + ); + + $result = Create::outgoing( $activity, 1, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); + + $this->assertFalse( $result ); + } + + /** + * Test outgoing non-Note/Article types return null. + * + * @covers ::outgoing + */ + public function test_outgoing_unsupported_type_returns_null() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Event', + 'content' => 'An event.', + ), + ); + + $result = Create::outgoing( $activity, 1 ); + + $this->assertNull( $result ); + } + + /** + * Test outgoing replies return null. + * + * @covers ::outgoing + */ + public function test_outgoing_reply_returns_null() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'A reply.', + 'inReplyTo' => 'https://example.com/note/123', + ), + ); + + $result = Create::outgoing( $activity, 1 ); + + $this->assertNull( $result ); + } + + /** + * Test outgoing invalid (non-array) object returns WP_Error. + * + * @covers ::outgoing + */ + public function test_outgoing_invalid_object_returns_error() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => 'https://example.com/note/1', + ); + + $result = Create::outgoing( $activity, 1 ); + + $this->assertWPError( $result ); + $this->assertEquals( 'invalid_object', $result->get_error_code() ); + } + + /** + * Test outgoing post sets content and title correctly. + * + * @covers ::outgoing + */ + public function test_outgoing_post_content_and_title() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Article', + 'name' => 'Specific Title', + 'content' => '

Specific content here.

', + 'summary' => 'A brief summary.', + ), + ); + + $result = Create::outgoing( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertEquals( 'Specific Title', $result->post_title ); + $this->assertEquals( '

Specific content here.

', $result->post_content ); + $this->assertEquals( 'A brief summary.', $result->post_excerpt ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing post auto-generates title from content when name is empty. + * + * @covers ::outgoing + */ + public function test_outgoing_post_generates_title_from_content() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => '

This is a short note without a title field.

', + ), + ); + + $result = Create::outgoing( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertNotEmpty( $result->post_title ); + $this->assertStringContainsString( 'This is a short', $result->post_title ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing post fires activitypub_outbox_created_post action. + * + * @covers ::outgoing + */ + public function test_outgoing_post_fires_action() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_created_post', $callback ); + + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'Testing action hook.', + ), + ); + + Create::outgoing( $activity, $user_id ); + + $this->assertTrue( $fired, 'activitypub_outbox_created_post action should fire.' ); + + \remove_action( 'activitypub_outbox_created_post', $callback ); + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing post sets user_id as post_author. + * + * @covers ::outgoing + */ + public function test_outgoing_post_sets_author() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'Author test.', + ), + ); + + $result = Create::outgoing( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertEquals( $user_id, (int) $result->post_author ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing quotes return null. + * + * @covers ::outgoing + */ + public function test_outgoing_quote_returns_null() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'A quote post.', + 'quoteUrl' => 'https://example.com/note/456', + ), + ); + + $result = Create::outgoing( $activity, 1 ); + + $this->assertNull( $result ); + } } diff --git a/tests/phpunit/tests/includes/rest/class-test-trait-verification.php b/tests/phpunit/tests/includes/rest/class-test-trait-verification.php new file mode 100644 index 0000000000..d6e769018f --- /dev/null +++ b/tests/phpunit/tests/includes/rest/class-test-trait-verification.php @@ -0,0 +1,453 @@ +instance = new class() { + use Verification; + }; + $this->user_id = self::factory()->user->create( + array( + 'role' => 'author', + ) + ); + } + + /** + * Tear down the test. + */ + public function tear_down() { + \wp_set_current_user( 0 ); + \remove_all_filters( 'activitypub_defer_signature_verification' ); + \remove_all_filters( 'activitypub_oauth_check_permission' ); + + // Reset OAuth token state. + $reflection = new \ReflectionClass( OAuth_Server::class ); + $property = $reflection->getProperty( 'current_token' ); + $property->setAccessible( true ); + $property->setValue( null, null ); + + parent::tear_down(); + } + + /** + * Test HEAD request always returns true. + * + * @covers ::verify_signature + */ + public function test_verify_signature_head_returns_true() { + $request = new \WP_REST_Request( 'HEAD', '/activitypub/1.0/users/1' ); + + $this->assertTrue( $this->instance->verify_signature( $request ) ); + } + + /** + * Test GET request without authorized fetch returns true. + * + * @covers ::verify_signature + */ + public function test_verify_signature_get_without_authorized_fetch() { + \delete_option( 'activitypub_authorized_fetch' ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/users/1' ); + + $this->assertTrue( $this->instance->verify_signature( $request ) ); + } + + /** + * Test GET request with authorized fetch enabled requires signature. + * + * @covers ::verify_signature + */ + public function test_verify_signature_get_with_authorized_fetch() { + \update_option( 'activitypub_authorized_fetch', '1' ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/users/1' ); + + // Without a valid signature, this should return WP_Error. + $result = $this->instance->verify_signature( $request ); + + $this->assertWPError( $result ); + $this->assertEquals( 'activitypub_signature_verification', $result->get_error_code() ); + $this->assertEquals( 401, $result->get_error_data()['status'] ); + + \delete_option( 'activitypub_authorized_fetch' ); + } + + /** + * Test POST request requires signature. + * + * @covers ::verify_signature + */ + public function test_verify_signature_post_requires_signature() { + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/1/inbox' ); + + $result = $this->instance->verify_signature( $request ); + + $this->assertWPError( $result ); + $this->assertEquals( 'activitypub_signature_verification', $result->get_error_code() ); + $this->assertEquals( 401, $result->get_error_data()['status'] ); + } + + /** + * Test defer filter bypasses signature verification. + * + * @covers ::verify_signature + */ + public function test_verify_signature_defer_filter() { + \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/1/inbox' ); + + $this->assertTrue( $this->instance->verify_signature( $request ) ); + } + + /** + * Test logged-in user returns true. + * + * @covers ::verify_application_password + */ + public function test_verify_application_password_logged_in() { + \wp_set_current_user( $this->user_id ); + + $this->assertTrue( $this->instance->verify_application_password() ); + } + + /** + * Test not logged in returns WP_Error with 401. + * + * @covers ::verify_application_password + */ + public function test_verify_application_password_not_logged_in() { + \wp_set_current_user( 0 ); + + $result = $this->instance->verify_application_password(); + + $this->assertWPError( $result ); + $this->assertEquals( 'activitypub_unauthorized', $result->get_error_code() ); + $this->assertEquals( 401, $result->get_error_data()['status'] ); + } + + /** + * Test GET request uses read scope. + * + * @covers ::verify_authentication + */ + public function test_verify_authentication_get_uses_read_scope() { + $captured_scope = null; + \add_filter( + 'activitypub_oauth_check_permission', + function ( $result, $request, $scope ) use ( &$captured_scope ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $captured_scope = $scope; + return true; + }, + 10, + 3 + ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/users/1/outbox' ); + + $this->instance->verify_authentication( $request ); + + $this->assertEquals( 'read', $captured_scope ); + } + + /** + * Test POST request uses write scope. + * + * @covers ::verify_authentication + */ + public function test_verify_authentication_post_uses_write_scope() { + $captured_scope = null; + \add_filter( + 'activitypub_oauth_check_permission', + function ( $result, $request, $scope ) use ( &$captured_scope ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $captured_scope = $scope; + return true; + }, + 10, + 3 + ); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/1/outbox' ); + + $this->instance->verify_authentication( $request ); + + $this->assertEquals( 'write', $captured_scope ); + } + + /** + * Test OAuth success proceeds to owner verification. + * + * @covers ::verify_authentication + */ + public function test_verify_authentication_oauth_success_without_user_id() { + \add_filter( + 'activitypub_oauth_check_permission', + function () { + return true; + } + ); + + // Request without user_id param skips owner verification. + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/outbox' ); + + $result = $this->instance->verify_authentication( $request ); + + $this->assertTrue( $result ); + } + + /** + * Test OAuth failure with Bearer token does not fall back to App Passwords. + * + * @covers ::verify_authentication + */ + public function test_verify_authentication_oauth_failure_no_fallback() { + // Simulate OAuth returning error (scope check fails). + $oauth_error = new \WP_Error( + 'activitypub_insufficient_scope', + 'Insufficient scope.', + array( 'status' => 403 ) + ); + \add_filter( + 'activitypub_oauth_check_permission', + function () use ( $oauth_error ) { + return $oauth_error; + } + ); + + // Log in a user (would pass Application Passwords if fallback occurred). + \wp_set_current_user( $this->user_id ); + + // Simulate an OAuth request by setting a current token via reflection. + $this->set_oauth_token( $this->create_mock_token( 0, false ) ); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/users/1/outbox' ); + + $result = $this->instance->verify_authentication( $request ); + + // Should return the OAuth error, NOT fall back to Application Passwords. + $this->assertWPError( $result ); + $this->assertEquals( 'activitypub_insufficient_scope', $result->get_error_code() ); + } + + /** + * Test no OAuth token falls back to Application Passwords. + * + * @covers ::verify_authentication + */ + public function test_verify_authentication_falls_back_to_app_passwords() { + // OAuth returns error but no token was present (not an OAuth request). + \add_filter( + 'activitypub_oauth_check_permission', + function () { + return new \WP_Error( 'activitypub_oauth_required', 'OAuth required.' ); + } + ); + + // User is logged in via Application Passwords. + \wp_set_current_user( $this->user_id ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/outbox' ); + + $result = $this->instance->verify_authentication( $request ); + + // Should fall back to Application Passwords and succeed. + $this->assertTrue( $result ); + } + + /** + * Test request without user_id param skips owner verification. + * + * @covers ::verify_authentication + */ + public function test_verify_authentication_skips_owner_without_user_id() { + \add_filter( + 'activitypub_oauth_check_permission', + function () { + return true; + } + ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/outbox' ); + // No user_id param set. + + $result = $this->instance->verify_authentication( $request ); + + $this->assertTrue( $result ); + } + + /** + * Test WordPress authenticated user matches user_id. + * + * @covers ::verify_owner + */ + public function test_verify_owner_wp_user_matches() { + \wp_set_current_user( $this->user_id ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/users/' . $this->user_id . '/outbox' ); + $request->set_param( 'user_id', $this->user_id ); + + $result = $this->instance->verify_owner( $request ); + + $this->assertTrue( $result ); + } + + /** + * Test mismatched user returns WP_Error with 403. + * + * @covers ::verify_owner + */ + public function test_verify_owner_mismatch() { + $other_user = self::factory()->user->create( + array( + 'role' => 'author', + ) + ); + + \wp_set_current_user( $other_user ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/users/' . $this->user_id . '/outbox' ); + $request->set_param( 'user_id', $this->user_id ); + + $result = $this->instance->verify_owner( $request ); + + $this->assertWPError( $result ); + $this->assertEquals( 'activitypub_forbidden', $result->get_error_code() ); + $this->assertEquals( 403, $result->get_error_data()['status'] ); + } + + /** + * Test invalid user_id returns WP_Error. + * + * @covers ::verify_owner + */ + public function test_verify_owner_invalid_user_id() { + \wp_set_current_user( $this->user_id ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/users/99999/outbox' ); + $request->set_param( 'user_id', 99999 ); + + $result = $this->instance->verify_owner( $request ); + + $this->assertWPError( $result ); + } + + /** + * Test OAuth token user matches user_id. + * + * @covers ::verify_owner + */ + public function test_verify_owner_oauth_token_matches() { + $this->set_oauth_token( $this->create_mock_token( $this->user_id, true ) ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/users/' . $this->user_id . '/outbox' ); + $request->set_param( 'user_id', $this->user_id ); + + $result = $this->instance->verify_owner( $request ); + + $this->assertTrue( $result ); + } + + /** + * Create a mock OAuth token object. + * + * @param int $user_id The user ID the token belongs to. + * @param bool $has_scope Whether the token has any scope. + * @return object Mock token with get_user_id() and has_scope() methods. + */ + private function create_mock_token( $user_id, $has_scope ) { + return new class( $user_id, $has_scope ) { + /** + * User ID. + * + * @var int + */ + private $user_id; + + /** + * Whether the token has scope. + * + * @var bool + */ + private $has_scope; + + /** + * Constructor. + * + * @param int $user_id User ID. + * @param bool $has_scope Has scope. + */ + public function __construct( $user_id, $has_scope ) { + $this->user_id = $user_id; + $this->has_scope = $has_scope; + } + + /** + * Get user ID. + * + * @return int + */ + public function get_user_id() { + return $this->user_id; + } + + /** + * Check scope. + * + * @param string $scope Scope to check. + * @return bool + */ + public function has_scope( $scope ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + return $this->has_scope; + } + }; + } + + /** + * Set the OAuth Server's current token via reflection. + * + * @param object|null $token The token to set. + */ + private function set_oauth_token( $token ) { + $reflection = new \ReflectionClass( OAuth_Server::class ); + $property = $reflection->getProperty( 'current_token' ); + $property->setAccessible( true ); + $property->setValue( null, $token ); + } +} From ad81a22b3db0382d38db9bc088558ffddcc0364b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 17:41:16 +0100 Subject: [PATCH 042/105] Fix OAuth security and performance issues from code review - Re-validate redirect_uri on POST in authorize flow to prevent open redirect via tampered form fields - Default auto-discovered clients to DEFAULT_SCOPES instead of ALL - Throttle token last_used_at writes to 5-minute intervals - Skip auth code cleanup SQL when external object cache is active --- includes/oauth/class-authorization-code.php | 9 +++++++++ includes/oauth/class-client.php | 2 +- includes/oauth/class-server.php | 18 ++++++++++++++++++ includes/oauth/class-token.php | 9 ++++++--- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index 6dc79a8e30..8f1a6a367e 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -246,6 +246,15 @@ public static function hash_code( $code ) { public static function cleanup() { global $wpdb; + /* + * When an external object cache is active, transients are stored in + * the cache backend (Redis, Memcached, etc.) and auto-expire there. + * The direct SQL below only targets the options table, so skip it. + */ + if ( \wp_using_ext_object_cache() ) { + return 0; + } + $timeout_prefix = '_transient_timeout_' . self::TRANSIENT_PREFIX; $now = time(); diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index 2f2b7af446..2f9da483aa 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -203,7 +203,7 @@ private static function discover_and_register( $client_id ) { '_activitypub_client_id' => $client_id, '_activitypub_client_secret_hash' => '', // Public client. '_activitypub_redirect_uris' => array_map( 'sanitize_url', $redirect_uris ), - '_activitypub_allowed_scopes' => Scope::ALL, + '_activitypub_allowed_scopes' => Scope::DEFAULT_SCOPES, '_activitypub_is_public' => true, '_activitypub_discovered' => true, '_activitypub_logo_uri' => ! empty( $metadata['logo_uri'] ) ? \sanitize_url( $metadata['logo_uri'] ) : '', diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 28e877bf8e..a26434190f 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -432,6 +432,24 @@ private static function process_authorize_form() { $approve = isset( $_POST['approve'] ); // phpcs:enable WordPress.Security.NonceVerification.Missing + // Re-validate client and redirect URI (form fields could be tampered with). + $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { + \wp_die( + \esc_html( $client->get_error_message() ), + \esc_html__( 'Authorization Error', 'activitypub' ), + array( 'response' => 404 ) + ); + } + + if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { + \wp_die( + \esc_html__( 'Invalid redirect URI for this client.', 'activitypub' ), + \esc_html__( 'Authorization Error', 'activitypub' ), + array( 'response' => 400 ) + ); + } + // User denied authorization. if ( ! $approve ) { $error_url = \add_query_arg( diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index 7cfb56f6b6..ed5612d603 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -196,9 +196,12 @@ public static function validate( $token ) { ); } - // Update last used timestamp. - $token_data['last_used_at'] = time(); - \update_user_meta( (int) $user_id, $meta_key, $token_data ); + // Throttle last_used_at writes to avoid a DB write on every request. + $last_used = $token_data['last_used_at'] ?? 0; + if ( empty( $last_used ) || ( time() - $last_used ) > 5 * MINUTE_IN_SECONDS ) { + $token_data['last_used_at'] = time(); + \update_user_meta( (int) $user_id, $meta_key, $token_data ); + } return new self( (int) $user_id, $token_hash, $token_data ); } From 3bcf515fbda0632fe265d8c672816dcbe65102d0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 17:43:23 +0100 Subject: [PATCH 043/105] Fix correctness issues from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use try/finally for Update handler recursion guard so the flag cannot get stuck on uncaught exceptions - Document blog actor ownership bypass as intentional in both Update and Delete handlers - Fix hardcoded deprecation version 4.8.0 → unreleased --- includes/handler/class-delete.php | 6 ++- includes/handler/class-update.php | 37 +++++++++++-------- .../rest/class-actors-inbox-controller.php | 2 +- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 8073d7230c..4474812552 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -389,7 +389,11 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { return; } - // Verify the user owns this post. + /* + * Verify the user owns this post. + * The blog actor ($user_id === 0) can delete any post since it + * represents the site itself. + */ if ( (int) $post->post_author !== $user_id && $user_id > 0 ) { return; } diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index 8d85ff13df..42491c3a21 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -212,7 +212,11 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { return; } - // Verify the user owns this post. + /* + * Verify the user owns this post. + * The blog actor ($user_id === 0) can update any post since it + * represents the site itself. + */ if ( (int) $post->post_author !== $user_id && $user_id > 0 ) { return; } @@ -235,23 +239,26 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { ); self::$is_outgoing = true; - $post_id = \wp_update_post( $post_data, true ); - if ( \is_wp_error( $post_id ) ) { + try { + $post_id = \wp_update_post( $post_data, true ); + + if ( \is_wp_error( $post_id ) ) { + return; + } + + /** + * Fires after a post has been updated from an outgoing Update activity. + * + * @param int $post_id The updated post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id ); + } finally { self::$is_outgoing = false; - return; } - - /** - * Fires after a post has been updated from an outgoing Update activity. - * - * @param int $post_id The updated post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id ); - self::$is_outgoing = false; } /** diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 542c91b4e4..751b78b7cf 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -203,7 +203,7 @@ public function get_items( $request ) { \do_action_deprecated( 'activitypub_inbox_post', array( $request ), - '4.8.0', + 'unreleased', 'activitypub_rest_inbox_post' ); From 0221bff8e12747cbad82cfb17dc1ae23dcfc8a9b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 17:45:39 +0100 Subject: [PATCH 044/105] Revert try/finally in Update handler recursion guard WordPress core does not throw exceptions, so the guard cannot get stuck in practice. Keep the simpler original pattern. --- includes/handler/class-update.php | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index 42491c3a21..f3d098294d 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -239,26 +239,23 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { ); self::$is_outgoing = true; + $post_id = \wp_update_post( $post_data, true ); - try { - $post_id = \wp_update_post( $post_data, true ); - - if ( \is_wp_error( $post_id ) ) { - return; - } - - /** - * Fires after a post has been updated from an outgoing Update activity. - * - * @param int $post_id The updated post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id ); - } finally { + if ( \is_wp_error( $post_id ) ) { self::$is_outgoing = false; + return; } + + /** + * Fires after a post has been updated from an outgoing Update activity. + * + * @param int $post_id The updated post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + * @param int $outbox_id The outbox post ID. + */ + \do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id ); + self::$is_outgoing = false; } /** From d3c87f031238dc9bdbabeefd75eafbdbbda9cb2e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 17:46:32 +0100 Subject: [PATCH 045/105] Revert deprecation version back to 4.8.0 The version was intentionally set, not a mistake. --- includes/rest/class-actors-inbox-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 751b78b7cf..542c91b4e4 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -203,7 +203,7 @@ public function get_items( $request ) { \do_action_deprecated( 'activitypub_inbox_post', array( $request ), - 'unreleased', + '4.8.0', 'activitypub_rest_inbox_post' ); From e66deb8c911002f454683e1a0a1cd9d2b863efbb Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 17:47:28 +0100 Subject: [PATCH 046/105] Change deprecation version to 'unreleased' for consistency --- includes/rest/class-actors-inbox-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 542c91b4e4..751b78b7cf 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -203,7 +203,7 @@ public function get_items( $request ) { \do_action_deprecated( 'activitypub_inbox_post', array( $request ), - '4.8.0', + 'unreleased', 'activitypub_rest_inbox_post' ); From 81a7b94aec2b581ca2749f4a6454b5af004e0da0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 18:12:36 +0100 Subject: [PATCH 047/105] Replace Update handler recursion guard with fire_after_hooks=false Use wp_update_post()'s $fire_after_hooks parameter instead of a manual $is_outgoing static flag to prevent infinite recursion when the outbox chain re-triggers via wp_after_insert_post. --- .claude/skills/code-style/SKILL.md | 9 +++++ includes/handler/class-update.php | 27 +++------------ .../includes/handler/class-test-update.php | 34 +++++++++---------- 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/.claude/skills/code-style/SKILL.md b/.claude/skills/code-style/SKILL.md index b7aeb63bc6..721eedfd4a 100644 --- a/.claude/skills/code-style/SKILL.md +++ b/.claude/skills/code-style/SKILL.md @@ -36,6 +36,15 @@ Always use `'activitypub'` for translations: ### WordPress Global Functions When in a namespace, always escape WordPress functions with backslash: `\get_option()`, `\add_action()`, etc. +### Comments +Use `/* */` for multi-line comments, not `//` on consecutive lines: +```php +/* + * This is the correct way + * to write a multi-line comment. + */ +``` + ## Comprehensive Standards See [PHP Coding Standards](../../../docs/php-coding-standards.md) for complete WordPress coding standards. diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index f3d098294d..60f988b4fe 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -18,16 +18,6 @@ * Handle Update requests. */ class Update { - /** - * Whether the outgoing handler is currently running. - * - * Used to prevent infinite recursion when wp_update_post() re-triggers - * the post scheduler which would fire another outbox Update. - * - * @var bool - */ - private static $is_outgoing = false; - /** * Initialize the class, registering WordPress hooks. */ @@ -169,14 +159,6 @@ public static function update_actor( $activity, $user_ids ) { * @param int $outbox_id The outbox post ID. */ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { - /* - * Prevent infinite recursion: wp_update_post() below re-triggers - * wp_after_insert_post → Post::triage() → outbox → this handler. - */ - if ( self::$is_outgoing ) { - return; - } - $object = $data['object'] ?? array(); if ( ! \is_array( $object ) ) { @@ -238,11 +220,13 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { 'post_excerpt' => $summary, ); - self::$is_outgoing = true; - $post_id = \wp_update_post( $post_data, true ); + /* + * Pass $fire_after_hooks = false to prevent wp_after_insert_post from + * re-triggering the outbox chain and causing infinite recursion. + */ + $post_id = \wp_update_post( $post_data, true, false ); if ( \is_wp_error( $post_id ) ) { - self::$is_outgoing = false; return; } @@ -255,7 +239,6 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) { * @param int $outbox_id The outbox post ID. */ \do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id ); - self::$is_outgoing = false; } /** diff --git a/tests/phpunit/tests/includes/handler/class-test-update.php b/tests/phpunit/tests/includes/handler/class-test-update.php index 69f4b40ced..9125a23a49 100644 --- a/tests/phpunit/tests/includes/handler/class-test-update.php +++ b/tests/phpunit/tests/includes/handler/class-test-update.php @@ -342,11 +342,17 @@ public function test_outgoing_fires_action() { } /** - * Test outgoing Update recursion guard prevents infinite loop. + * Test outgoing Update does not re-trigger Post::triage via wp_update_post. * * @covers ::outgoing */ public function test_outgoing_recursion_guard() { + /* + * Re-add the triage hook so we can verify it does NOT fire + * during the wp_update_post() call inside outgoing(). + */ + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + $post_id = self::factory()->post->create( array( 'post_author' => $this->user_id, @@ -358,22 +364,14 @@ public function test_outgoing_recursion_guard() { $permalink = \get_permalink( $post_id ); $call_count = 0; - // Hook into the update action to count calls and re-trigger. - $callback = function () use ( &$call_count, $permalink ) { + /* + * Count how many times triage is invoked. If the recursion guard + * works, triage should not fire during the outgoing update. + */ + $counter = function () use ( &$call_count ) { ++$call_count; - - // Simulate what the scheduler would do: re-trigger the outgoing handler. - $data = array( - 'type' => 'Update', - 'object' => array( - 'type' => 'Note', - 'id' => $permalink, - 'content' => 'Re-triggered', - ), - ); - Update::outgoing( $data, 0, null, 0 ); }; - \add_action( 'activitypub_outbox_updated_post', $callback ); + \add_action( 'wp_after_insert_post', $counter, 32 ); $data = array( 'type' => 'Update', @@ -386,10 +384,10 @@ public function test_outgoing_recursion_guard() { Update::outgoing( $data, $this->user_id, null, 0 ); - // Should only fire once due to recursion guard. - $this->assertEquals( 1, $call_count, 'Recursion guard should prevent re-entrant calls.' ); + $this->assertEquals( 0, $call_count, 'wp_after_insert_post should not fire during outgoing update.' ); - \remove_action( 'activitypub_outbox_updated_post', $callback ); + \remove_action( 'wp_after_insert_post', $counter, 32 ); + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); } /** From 6ef05e496d2bd6ee9e6219f07fc59094e2c78b1c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 18:27:14 +0100 Subject: [PATCH 048/105] Add E2E tests for CORS headers on REST API endpoints Verify that CORS headers are present on C2S endpoints (outbox, inbox, webfinger, OAuth token) and absent on S2S-only endpoints (actors, followers). --- .../includes/rest/oauth-controller.test.js | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/e2e/specs/includes/rest/oauth-controller.test.js diff --git a/tests/e2e/specs/includes/rest/oauth-controller.test.js b/tests/e2e/specs/includes/rest/oauth-controller.test.js new file mode 100644 index 0000000000..064251ab09 --- /dev/null +++ b/tests/e2e/specs/includes/rest/oauth-controller.test.js @@ -0,0 +1,62 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'OAuth Controller CORS Headers', () => { + const restBase = 'http://localhost:8889/index.php?rest_route='; + + test( 'should include CORS headers on outbox endpoint', async ( { request } ) => { + const response = await request.get( `${ restBase }/activitypub/1.0/actors/1/outbox` ); + + expect( response.status() ).toBe( 200 ); + expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); + expect( response.headers()[ 'access-control-allow-methods' ] ).toContain( 'GET' ); + expect( response.headers()[ 'access-control-allow-headers' ] ).toContain( 'Authorization' ); + } ); + + test( 'should include CORS headers on inbox endpoint', async ( { request } ) => { + const response = await request.get( `${ restBase }/activitypub/1.0/actors/1/inbox` ); + + expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); + } ); + + test( 'should include CORS headers on webfinger endpoint', async ( { request } ) => { + const resource = encodeURIComponent( 'http://localhost:8889/?author=1' ); + const response = await request.get( `${ restBase }/activitypub/1.0/webfinger&resource=${ resource }` ); + + expect( response.status() ).toBe( 200 ); + expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); + } ); + + test( 'should include CORS headers on OAuth token endpoint', async ( { request } ) => { + const response = await request.post( `${ restBase }/activitypub/1.0/oauth/token`, { + form: { + grant_type: 'authorization_code', + code: 'invalid', + client_id: 'invalid', + redirect_uri: 'http://localhost', + }, + } ); + + /* + * The request will fail (invalid code), but CORS headers + * should still be present on error responses. + */ + expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); + } ); + + test( 'should NOT include CORS headers on actors endpoint', async ( { request } ) => { + const response = await request.get( `${ restBase }/activitypub/1.0/actors/1` ); + + expect( response.status() ).toBe( 200 ); + expect( response.headers()[ 'access-control-allow-origin' ] ).toBeUndefined(); + } ); + + test( 'should NOT include CORS headers on followers endpoint', async ( { request } ) => { + const response = await request.get( `${ restBase }/activitypub/1.0/actors/1/followers` ); + + expect( response.status() ).toBe( 200 ); + expect( response.headers()[ 'access-control-allow-origin' ] ).toBeUndefined(); + } ); +} ); From 7ac65cc40fac5a99fb502572422112827e7ec9a2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 20:43:24 +0100 Subject: [PATCH 049/105] Extract outbox handlers into Handler\Outbox namespace Move outgoing (C2S) logic from root handler classes into separate Handler\Outbox\* classes, keeping inbox handlers with their original method names (handle_create, handle_update, etc.). Register outbox handlers centrally in class-handler.php via register_outbox_handlers(). --- includes/class-handler.php | 58 +-- includes/collection/class-interactions.php | 124 +++--- includes/handler/class-announce.php | 64 +-- includes/handler/class-create.php | 152 +------ includes/handler/class-delete.php | 98 +---- includes/handler/class-follow.php | 84 +--- includes/handler/class-like.php | 63 +-- includes/handler/class-undo.php | 90 +--- includes/handler/class-update.php | 147 +------ includes/handler/outbox/class-announce.php | 47 ++ includes/handler/outbox/class-create.php | 158 +++++++ includes/handler/outbox/class-delete.php | 84 ++++ includes/handler/outbox/class-follow.php | 68 +++ includes/handler/outbox/class-like.php | 47 ++ includes/handler/outbox/class-undo.php | 73 ++++ includes/handler/outbox/class-update.php | 132 ++++++ includes/rest/class-outbox-controller.php | 7 +- .../tests/includes/class-test-migration.php | 11 + .../includes/handler/class-test-announce.php | 29 +- .../includes/handler/class-test-create.php | 330 ++------------ .../includes/handler/class-test-delete.php | 21 +- .../includes/handler/class-test-follow.php | 37 +- .../includes/handler/class-test-like.php | 27 +- .../includes/handler/class-test-undo.php | 38 +- .../includes/handler/class-test-update.php | 252 ----------- .../handler/outbox/class-test-create.php | 402 ++++++++++++++++++ .../handler/outbox/class-test-update.php | 250 +++++++++++ .../rest/class-test-outbox-controller.php | 10 + 28 files changed, 1535 insertions(+), 1368 deletions(-) create mode 100644 includes/handler/outbox/class-announce.php create mode 100644 includes/handler/outbox/class-create.php create mode 100644 includes/handler/outbox/class-delete.php create mode 100644 includes/handler/outbox/class-follow.php create mode 100644 includes/handler/outbox/class-like.php create mode 100644 includes/handler/outbox/class-undo.php create mode 100644 includes/handler/outbox/class-update.php create mode 100644 tests/phpunit/tests/includes/handler/outbox/class-test-create.php create mode 100644 tests/phpunit/tests/includes/handler/outbox/class-test-update.php diff --git a/includes/class-handler.php b/includes/class-handler.php index e5edb51960..c70127881f 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -7,19 +7,6 @@ namespace Activitypub; -use Activitypub\Handler\Accept; -use Activitypub\Handler\Announce; -use Activitypub\Handler\Collection_Sync; -use Activitypub\Handler\Create; -use Activitypub\Handler\Delete; -use Activitypub\Handler\Follow; -use Activitypub\Handler\Like; -use Activitypub\Handler\Move; -use Activitypub\Handler\Quote_Request; -use Activitypub\Handler\Reject; -use Activitypub\Handler\Undo; -use Activitypub\Handler\Update; - /** * Handler class. */ @@ -29,24 +16,25 @@ class Handler { */ public static function init() { self::register_handlers(); + self::register_outbox_handlers(); } /** * Register handlers. */ public static function register_handlers() { - Accept::init(); - Announce::init(); - Collection_Sync::init(); - Create::init(); - Delete::init(); - Follow::init(); - Like::init(); - Move::init(); - Quote_Request::init(); - Reject::init(); - Undo::init(); - Update::init(); + Handler\Accept::init(); + Handler\Announce::init(); + Handler\Collection_Sync::init(); + Handler\Create::init(); + Handler\Delete::init(); + Handler\Follow::init(); + Handler\Like::init(); + Handler\Move::init(); + Handler\Quote_Request::init(); + Handler\Reject::init(); + Handler\Undo::init(); + Handler\Update::init(); /** * Register additional handlers. @@ -55,4 +43,24 @@ public static function register_handlers() { */ do_action( 'activitypub_register_handlers' ); } + + /** + * Register outbox handlers. + */ + public static function register_outbox_handlers() { + Handler\Outbox\Announce::init(); + Handler\Outbox\Create::init(); + Handler\Outbox\Delete::init(); + Handler\Outbox\Follow::init(); + Handler\Outbox\Like::init(); + Handler\Outbox\Undo::init(); + Handler\Outbox\Update::init(); + + /** + * Register additional outbox handlers. + * + * @since 1.3.0 + */ + do_action( 'activitypub_register_outbox_handlers' ); + } } diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 9756149afe..43686164e2 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -29,12 +29,16 @@ class Interactions { /** * Add a comment to a post. * - * @param array $activity The activity-object. + * When $user_id is provided, comment author data is built from the + * local WordPress user instead of fetching remote actor metadata. + * + * @param array $activity The activity-object. + * @param int|null $user_id Optional. Local user ID for outbox replies. * * @return int|false|\WP_Error The comment ID or false or WP_Error on failure. */ - public static function add_comment( $activity ) { - $comment_data = self::activity_to_comment( $activity ); + public static function add_comment( $activity, $user_id = null ) { + $comment_data = self::activity_to_comment( $activity, $user_id ); if ( ! $comment_data ) { return false; @@ -331,78 +335,102 @@ public static function allowed_comment_html( $allowed_tags, $context = '' ) { } /** - * Convert an Activity to a WP_Comment + * Convert an Activity to a WP_Comment. * - * @param array $activity The Activity array. + * When $user_id is provided, comment author data is built from the + * local WordPress user instead of fetching remote actor metadata. + * + * @param array $activity The Activity array. + * @param int|null $user_id Optional. Local user ID for outbox comments. * * @return array|false The comment data or false on failure. */ - public static function activity_to_comment( $activity ) { + public static function activity_to_comment( $activity, $user_id = null ) { $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; - } + if ( $user_id ) { + // Outbox: resolve author from the local WordPress user. + $user = \get_userdata( $user_id ); - // Check Actor-Name. - $comment_author = null; - if ( ! empty( $actor['name'] ) ) { - $comment_author = $actor['name']; - } elseif ( ! empty( $actor['preferredUsername'] ) ) { - $comment_author = $actor['preferredUsername']; - } + if ( ! $user ) { + return false; + } - if ( empty( $comment_author ) && \get_option( 'require_name_email' ) ) { - return false; - } + $comment_author = $user->display_name; + $comment_author_url = $user->user_url; + $comment_author_email = $user->user_email; + $comment_content = \wp_kses_post( $activity['object']['content'] ?? '' ); + } else { + // S2S: resolve author from remote actor metadata. + $actor = object_to_uri( $activity['actor'] ?? null ); + $actor = get_remote_metadata_by_actor( $actor ); + + if ( ! $actor || is_wp_error( $actor ) ) { + return false; + } - $url = object_to_uri( $actor['url'] ?? $actor['id'] ); + $comment_author = null; + if ( ! empty( $actor['name'] ) ) { + $comment_author = $actor['name']; + } elseif ( ! empty( $actor['preferredUsername'] ) ) { + $comment_author = $actor['preferredUsername']; + } - if ( isset( $activity['object']['content'] ) ) { - // Wrap emoji in content with blocks for runtime replacement. - // Note: Remote images in comments are stripped for security (only emoji allowed). - $content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] ); - $comment_content = \addslashes( $content ); - } + if ( empty( $comment_author ) && \get_option( 'require_name_email' ) ) { + return false; + } - $webfinger = Webfinger::uri_to_acct( $url ); - if ( is_wp_error( $webfinger ) ) { - $webfinger = ''; - } else { - $webfinger = str_replace( 'acct:', '', $webfinger ); + $comment_author = $comment_author ?? __( 'Anonymous', 'activitypub' ); + $comment_author_url = \esc_url_raw( object_to_uri( $actor['url'] ?? $actor['id'] ) ); + + $webfinger = Webfinger::uri_to_acct( $comment_author_url ); + if ( is_wp_error( $webfinger ) ) { + $comment_author_email = ''; + } else { + $comment_author_email = str_replace( 'acct:', '', $webfinger ); + } + + if ( isset( $activity['object']['content'] ) ) { + // Wrap emoji in content with blocks for runtime replacement. + // Note: Remote images in comments are stripped for security (only emoji allowed). + $content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] ); + $comment_content = \addslashes( $content ); + } } $published = $activity['object']['published'] ?? $activity['published'] ?? 'now'; $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $published ) ); $comment_data = array( - 'comment_author' => $comment_author ?? __( 'Anonymous', 'activitypub' ), - 'comment_author_url' => \esc_url_raw( $url ), + 'comment_author' => $comment_author, + 'comment_author_url' => $comment_author_url, 'comment_content' => $comment_content, 'comment_type' => 'comment', - 'comment_author_email' => $webfinger, + 'comment_author_email' => $comment_author_email, 'comment_date' => \get_date_from_gmt( $gm_date ), 'comment_date_gmt' => $gm_date, 'comment_meta' => array( - 'source_id' => \esc_url_raw( object_to_uri( $activity['object'] ) ), - 'protocol' => 'activitypub', + 'protocol' => 'activitypub', ), ); - // Store reference to remote actor post. - $actor_uri = object_to_uri( $activity['actor'] ?? null ); - if ( $actor_uri ) { - $remote_actor = Remote_Actors::get_by_uri( $actor_uri ); - if ( ! \is_wp_error( $remote_actor ) ) { - $comment_data['comment_meta']['_activitypub_remote_actor_id'] = $remote_actor->ID; + if ( $user_id ) { + $comment_data['user_id'] = $user_id; + } else { + $comment_data['comment_meta']['source_id'] = \esc_url_raw( object_to_uri( $activity['object'] ) ); + + // Store reference to remote actor post. + $actor_uri = object_to_uri( $activity['actor'] ?? null ); + if ( $actor_uri ) { + $remote_actor = Remote_Actors::get_by_uri( $actor_uri ); + if ( ! \is_wp_error( $remote_actor ) ) { + $comment_data['comment_meta']['_activitypub_remote_actor_id'] = $remote_actor->ID; + } } - } - if ( isset( $activity['object']['url'] ) ) { - $comment_data['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) ); + if ( isset( $activity['object']['url'] ) ) { + $comment_data['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) ); + } } return $comment_data; diff --git a/includes/handler/class-announce.php b/includes/handler/class-announce.php index bcfdda39d0..a9e528f03a 100644 --- a/includes/handler/class-announce.php +++ b/includes/handler/class-announce.php @@ -24,8 +24,7 @@ class Announce { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_announce', array( self::class, 'incoming' ), 10, 3 ); - \add_action( 'activitypub_handled_outbox_announce', array( self::class, 'outgoing' ), 10, 4 ); + \add_action( 'activitypub_inbox_announce', array( self::class, 'handle_announce' ), 10, 3 ); } /** @@ -35,7 +34,7 @@ public static function init() { * @param int|int[] $user_ids The id(s) of the local blog-user(s). * @param \Activitypub\Activity\Activity $activity The activity object. */ - public static function incoming( $announcement, $user_ids, $activity = null ) { + public static function handle_announce( $announcement, $user_ids, $activity = null ) { // Check if Activity is public or not. if ( ! is_activity_public( $announcement ) ) { // @todo maybe send email @@ -131,63 +130,4 @@ public static function maybe_save_announce( $activity, $user_ids ) { */ \do_action( 'activitypub_handled_announce', $activity, (array) $user_ids, $success, $result ); } - - /** - * Handle outgoing "Announce" activities from local actors. - * - * Records an announce/boost from the local user on remote content. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function outgoing( $data, $user_id, $activity, $outbox_id ) { - $object_url = object_to_uri( $data['object'] ?? '' ); - - if ( empty( $object_url ) ) { - return; - } - - /** - * Fires after an outgoing Announce activity has been processed. - * - * @param string $object_url The URL of the announced object. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_outbox_announce_sent', $object_url, $data, $user_id, $outbox_id ); - } - - /** - * Handle "Announce" requests. - * - * @deprecated unreleased Use Announce::incoming() instead. - * - * @param array $announcement The activity-object. - * @param int|int[] $user_ids The id(s) of the local blog-user(s). - * @param \Activitypub\Activity\Activity $activity The activity object. - */ - public static function handle_announce( $announcement, $user_ids, $activity = null ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Announce::incoming()' ); - - return self::incoming( $announcement, $user_ids, $activity ); - } - - /** - * Handle outbox "Announce" activities. - * - * @deprecated unreleased Use Announce::outgoing() instead. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function handle_outbox_announce( $data, $user_id, $activity, $outbox_id ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Announce::outgoing()' ); - - return self::outgoing( $data, $user_id, $activity, $outbox_id ); - } } diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index 4925172910..8ecb87cff2 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -12,7 +12,6 @@ use Activitypub\Tombstone; use function Activitypub\get_activity_visibility; -use function Activitypub\get_content_visibility; use function Activitypub\is_activity_reply; use function Activitypub\is_quote_activity; use function Activitypub\is_self_ping; @@ -26,11 +25,7 @@ class Create { * Initialize the class, registering WordPress hooks. */ public static function init() { - // Incoming activities (from remote actors via inbox). - \add_action( 'activitypub_handled_inbox_create', array( self::class, 'incoming' ), 10, 3 ); - - // Outgoing activities (from local actors via outbox). - \add_filter( 'activitypub_outbox_create', array( self::class, 'outgoing' ), 10, 3 ); + \add_action( 'activitypub_handled_inbox_create', array( self::class, 'handle_create' ), 10, 2 ); \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'maybe_unbury' ), 10, 2 ); @@ -39,13 +34,12 @@ public static function init() { /** * Handle incoming "Create" activities from remote actors. * - * @param array $activity The activity data. - * @param int[] $user_ids The local user IDs targeted. - * @param mixed $activity_object The activity object (unused, required by hook signature). + * @param array $activity The activity data. + * @param int[] $user_ids The local user IDs targeted. * * @return \WP_Post|\WP_Comment|\WP_Error|false The created content or error. */ - public static function incoming( $activity, $user_ids = null, $activity_object = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + public static function handle_create( $activity, $user_ids = null ) { // Check for private and/or direct messages. if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) { return false; @@ -53,9 +47,9 @@ public static function incoming( $activity, $user_ids = null, $activity_object = // Route to appropriate handler based on content type. if ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { - $result = self::incoming_interaction( $activity, $user_ids ); + $result = self::create_interaction( $activity, $user_ids ); } else { - $result = self::incoming_post( $activity, $user_ids ); + $result = self::create_post( $activity, $user_ids ); } if ( false === $result ) { @@ -77,44 +71,6 @@ public static function incoming( $activity, $user_ids = null, $activity_object = return $result; } - /** - * Handle outgoing "Create" activities from local actors. - * - * Creates WordPress content and adds to outbox for federation. - * - * @param array $activity The activity data. - * @param int $user_id The local user ID. - * @param string|null $visibility Content visibility. - * - * @return int|\WP_Error|null The outbox ID on success, WP_Error on failure, null if not handled. - */ - public static function outgoing( $activity, $user_id = null, $visibility = null ) { - // Check for private and/or direct messages. - if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) { - return false; - } - - $object = $activity['object'] ?? array(); - - if ( ! \is_array( $object ) ) { - return new \WP_Error( 'invalid_object', 'Invalid object in activity.' ); - } - - $object_type = $object['type'] ?? ''; - - // Only handle Note and Article types for now. - if ( ! \in_array( $object_type, array( 'Note', 'Article' ), true ) ) { - return null; - } - - // TODO: Handle replies/interactions differently. - if ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { - return null; - } - - return self::outgoing_post( $activity, $user_id, $visibility ); - } - /** * Handle incoming interaction (reply/quote) from remote actor. * @@ -123,12 +79,12 @@ public static function outgoing( $activity, $user_id = null, $visibility = null * * @return \WP_Comment|\WP_Error|false Comment, WP_Error, or false. */ - private static function incoming_interaction( $activity, $user_ids ) { + public static function create_interaction( $activity, $user_ids ) { $existing_comment = object_id_to_comment( $activity['object']['id'] ); // If comment exists, call update action. if ( $existing_comment ) { - Update::incoming( $activity, (array) $user_ids, null ); + Update::handle_update( $activity, (array) $user_ids, null ); return false; } @@ -154,7 +110,7 @@ private static function incoming_interaction( $activity, $user_ids ) { * * @return \WP_Post|\WP_Error|false Post, WP_Error, or false. */ - private static function incoming_post( $activity, $user_ids ) { + public static function create_post( $activity, $user_ids ) { if ( ! \get_option( 'activitypub_create_posts', false ) ) { return false; } @@ -163,7 +119,7 @@ private static function incoming_post( $activity, $user_ids ) { // If post exists, call update action. if ( $existing_post instanceof \WP_Post ) { - Update::incoming( $activity, (array) $user_ids, null ); + Update::handle_update( $activity, (array) $user_ids, null ); return false; } @@ -171,74 +127,6 @@ private static function incoming_post( $activity, $user_ids ) { return Posts::add( $activity, $user_ids ); } - /** - * Handle outgoing post from local actor. - * - * Creates a WordPress post. The scheduler will add it to the outbox. - * - * @param array $activity The activity data. - * @param int $user_id The local user ID. - * @param string|null $visibility Content visibility. - * - * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure. - */ - private static function outgoing_post( $activity, $user_id, $visibility ) { - $object = $activity['object'] ?? array(); - - $object_type = $object['type'] ?? ''; - $content = $object['content'] ?? ''; - $name = $object['name'] ?? ''; - $summary = $object['summary'] ?? ''; - - // Use name as title for Articles, or generate from content for Notes. - $title = $name; - if ( empty( $title ) && ! empty( $content ) ) { - $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); - } - - // Determine visibility if not provided. - if ( null === $visibility ) { - $visibility = get_content_visibility( $activity ); - } - - $post_data = array( - 'post_author' => $user_id > 0 ? $user_id : 0, - 'post_title' => $title, - 'post_content' => $content, - 'post_excerpt' => $summary, - 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish', - 'post_type' => 'post', - 'meta_input' => array( - 'activitypub_content_visibility' => $visibility, - ), - ); - - $post_id = \wp_insert_post( $post_data, true ); - - if ( \is_wp_error( $post_id ) ) { - return $post_id; - } - - // Set post format to 'status' for Notes so the transformer maps it back correctly. - if ( 'Note' === $object_type ) { - \set_post_format( $post_id, 'status' ); - } - - $post = \get_post( $post_id ); - - /** - * Fires after a post has been created from an outgoing Create activity. - * - * @param int $post_id The created post ID. - * @param array $activity The activity data. - * @param int $user_id The user ID. - * @param string $visibility The content visibility. - */ - \do_action( 'activitypub_outbox_created_post', $post_id, $activity, $user_id, $visibility ); - - return $post; - } - /** * Validate the object. * @@ -263,7 +151,8 @@ public static function validate_object( $valid, $param, $request ) { return false; } - if ( ! isset( $activity['object']['id'], $activity['object']['content'] ) ) { + // Only content is required; ID is optional for outbox activities (assigned by the server). + if ( ! isset( $activity['object']['content'] ) ) { return false; } @@ -291,21 +180,4 @@ public static function maybe_unbury( $outbox_id, $activity ) { Tombstone::remove( $object->get_id(), $object->get_url() ); } } - - /** - * Handle "Create" requests. - * - * @deprecated unreleased Use Create::incoming() instead. - * - * @param array $activity The activity data. - * @param int[] $user_ids The local user IDs targeted. - * @param mixed $activity_object The activity object. - * - * @return \WP_Post|\WP_Comment|\WP_Error|false The created content or error. - */ - public static function handle_create( $activity, $user_ids = null, $activity_object = null ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Create::incoming()' ); - - return self::incoming( $activity, $user_ids, $activity_object ); - } } diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 4474812552..6c42ec1fd5 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -22,8 +22,7 @@ class Delete { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_delete', array( self::class, 'incoming' ), 10, 2 ); - \add_action( 'activitypub_handled_outbox_delete', array( self::class, 'outgoing' ), 10, 4 ); + \add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ), 10, 2 ); \add_filter( 'activitypub_skip_inbox_storage', array( self::class, 'skip_inbox_storage' ), 10, 2 ); \add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 ); \add_action( 'activitypub_delete_remote_actor_interactions', array( self::class, 'delete_interactions' ) ); @@ -39,7 +38,7 @@ public static function init() { * @param array $activity The delete activity. * @param int|int[] $user_ids The local user ID(s). */ - public static function incoming( $activity, $user_ids ) { + public static function handle_delete( $activity, $user_ids ) { $object_type = $activity['object']['type'] ?? ''; switch ( $object_type ) { @@ -352,97 +351,4 @@ public static function maybe_bury( $outbox_id, $activity ) { Tombstone::bury( $object->get_id(), $object->get_url() ); } } - - /** - * Handle outgoing "Delete" activities from local actors. - * - * Deletes a WordPress post. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function outgoing( $data, $user_id, $activity, $outbox_id ) { - $object = $data['object'] ?? ''; - - // Get the object ID (can be a string URL or an object with an id). - $object_id = object_to_uri( $object ); - - if ( empty( $object_id ) ) { - return; - } - - /* - * Find the post by its ActivityPub ID. - * First try to find a local post by permalink (for C2S-created posts). - */ - $post_id = \url_to_postid( $object_id ); - $post = $post_id ? \get_post( $post_id ) : null; - - // Fall back to Posts collection for remote posts (ap_post type). - if ( ! $post instanceof \WP_Post ) { - $post = Posts::get_by_guid( $object_id ); - } - - if ( ! $post instanceof \WP_Post ) { - return; - } - - /* - * Verify the user owns this post. - * The blog actor ($user_id === 0) can delete any post since it - * represents the site itself. - */ - if ( (int) $post->post_author !== $user_id && $user_id > 0 ) { - return; - } - - // Trash the post (use wp_delete_post with false to move to trash). - $result = \wp_trash_post( $post->ID ); - - if ( ! $result ) { - return; - } - - /** - * Fires after a post has been deleted from an outgoing Delete activity. - * - * @param int $post_id The deleted post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_outbox_deleted_post', $post->ID, $data, $user_id, $outbox_id ); - } - - /** - * Handle "Delete" requests. - * - * @deprecated unreleased Use Delete::incoming() instead. - * - * @param array $activity The delete activity. - * @param int|int[] $user_ids The local user ID(s). - */ - public static function handle_delete( $activity, $user_ids ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Delete::incoming()' ); - - return self::incoming( $activity, $user_ids ); - } - - /** - * Handle outbox "Delete" activities. - * - * @deprecated unreleased Use Delete::outgoing() instead. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function handle_outbox_delete( $data, $user_id, $activity, $outbox_id ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Delete::outgoing()' ); - - return self::outgoing( $data, $user_id, $activity, $outbox_id ); - } } diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php index 47faac2e4e..39628d43c5 100644 --- a/includes/handler/class-follow.php +++ b/includes/handler/class-follow.php @@ -10,7 +10,6 @@ use Activitypub\Activity\Activity; use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; -use Activitypub\Collection\Following; use Activitypub\Collection\Remote_Actors; use function Activitypub\add_to_outbox; @@ -23,9 +22,8 @@ class Follow { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_follow', array( self::class, 'incoming' ), 10, 2 ); + \add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 ); \add_action( 'activitypub_handled_follow', array( self::class, 'queue_accept' ), 10, 4 ); - \add_action( 'activitypub_handled_outbox_follow', array( self::class, 'outgoing' ), 10, 4 ); } /** @@ -34,7 +32,7 @@ public static function init() { * @param array $activity The activity object. * @param int|int[] $user_ids The user ID(s). */ - public static function incoming( $activity, $user_ids ) { + public static function handle_follow( $activity, $user_ids ) { // Extract the user ID (follow requests are always for a single user). $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids; @@ -150,82 +148,4 @@ public static function queue_reject( $activity, $user_id ) { add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); } - - /** - * Handle outgoing "Follow" activities from local actors. - * - * Adds the target actor to the user's following list (pending until accepted). - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function outgoing( $data, $user_id, $activity, $outbox_id ) { - $object = $data['object'] ?? ''; - - // The object should be the actor URL to follow. - if ( empty( $object ) || ! \is_string( $object ) ) { - return; - } - - // Fetch or create the remote actor. - $remote_actor = Remote_Actors::fetch_by_uri( $object ); - - if ( \is_wp_error( $remote_actor ) ) { - return; - } - - // Check if already following. - $all_meta = \get_post_meta( $remote_actor->ID ); - $following = $all_meta[ Following::FOLLOWING_META_KEY ] ?? array(); - $pending = $all_meta[ Following::PENDING_META_KEY ] ?? array(); - - if ( \in_array( (string) $user_id, $following, true ) || \in_array( (string) $user_id, $pending, true ) ) { - return; - } - - // Add to pending following. - \add_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, (string) $user_id ); - - /** - * Fires after an outgoing Follow activity has been processed. - * - * @param int $remote_actor_id The remote actor post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_outbox_follow_sent', $remote_actor->ID, $data, $user_id, $outbox_id ); - } - - /** - * Handle "Follow" requests. - * - * @deprecated unreleased Use Follow::incoming() instead. - * - * @param array $activity The activity object. - * @param int|int[] $user_ids The user ID(s). - */ - public static function handle_follow( $activity, $user_ids ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Follow::incoming()' ); - - return self::incoming( $activity, $user_ids ); - } - - /** - * Handle outbox "Follow" activities. - * - * @deprecated unreleased Use Follow::outgoing() instead. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function handle_outbox_follow( $data, $user_id, $activity, $outbox_id ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Follow::outgoing()' ); - - return self::outgoing( $data, $user_id, $activity, $outbox_id ); - } } diff --git a/includes/handler/class-like.php b/includes/handler/class-like.php index 837ff5de57..bb6e2d8d0b 100644 --- a/includes/handler/class-like.php +++ b/includes/handler/class-like.php @@ -20,8 +20,7 @@ class Like { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_like', array( self::class, 'incoming' ), 10, 2 ); - \add_action( 'activitypub_handled_outbox_like', array( self::class, 'outgoing' ), 10, 4 ); + \add_action( 'activitypub_inbox_like', array( self::class, 'handle_like' ), 10, 2 ); \add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) ); } @@ -31,7 +30,7 @@ public static function init() { * @param array $like The Activity array. * @param int|int[] $user_ids The user ID(s). */ - public static function incoming( $like, $user_ids ) { + public static function handle_like( $like, $user_ids ) { if ( ! Comment::is_comment_type_enabled( 'like' ) ) { return; } @@ -66,34 +65,6 @@ public static function incoming( $like, $user_ids ) { \do_action( 'activitypub_handled_like', $like, (array) $user_ids, $success, $result ); } - /** - * Handle outgoing "Like" activities from local actors. - * - * Records a like from the local user on remote content. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function outgoing( $data, $user_id, $activity, $outbox_id ) { - $object_url = object_to_uri( $data['object'] ?? '' ); - - if ( empty( $object_url ) ) { - return; - } - - /** - * Fires after an outgoing Like activity has been processed. - * - * @param string $object_url The URL of the liked object. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_outbox_like_sent', $object_url, $data, $user_id, $outbox_id ); - } - /** * Set the object to the object ID. * @@ -107,34 +78,4 @@ public static function outbox_activity( $activity ) { return $activity; } - - /** - * Handle "Like" requests. - * - * @deprecated unreleased Use Like::incoming() instead. - * - * @param array $like The Activity array. - * @param int|int[] $user_ids The user ID(s). - */ - public static function handle_like( $like, $user_ids ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Like::incoming()' ); - - return self::incoming( $like, $user_ids ); - } - - /** - * Handle outbox "Like" activities. - * - * @deprecated unreleased Use Like::outgoing() instead. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function handle_outbox_like( $data, $user_id, $activity, $outbox_id ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Like::outgoing()' ); - - return self::outgoing( $data, $user_id, $activity, $outbox_id ); - } } diff --git a/includes/handler/class-undo.php b/includes/handler/class-undo.php index a920a98850..d71361bb0f 100644 --- a/includes/handler/class-undo.php +++ b/includes/handler/class-undo.php @@ -7,9 +7,7 @@ namespace Activitypub\Handler; -use Activitypub\Collection\Following; use Activitypub\Collection\Inbox as Inbox_Collection; -use Activitypub\Collection\Remote_Actors; use function Activitypub\object_to_uri; @@ -21,8 +19,7 @@ class Undo { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_undo', array( self::class, 'incoming' ), 10, 2 ); - \add_action( 'activitypub_handled_outbox_undo', array( self::class, 'outgoing' ), 10, 4 ); + \add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo' ), 10, 2 ); \add_action( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); } @@ -32,7 +29,7 @@ public static function init() { * @param array $activity The JSON "Undo" Activity. * @param int|int[]|null $user_ids The user ID(s). */ - public static function incoming( $activity, $user_ids ) { + public static function handle_undo( $activity, $user_ids ) { $success = false; $result = Inbox_Collection::undo( object_to_uri( $activity['object'] ) ); @@ -85,87 +82,4 @@ public static function validate_object( $valid, $param, $request ) { return $valid; } - - /** - * Handle outgoing "Undo" activities from local actors. - * - * Handles Undo Follow (unfollow) activities. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function outgoing( $data, $user_id, $activity, $outbox_id ) { - $object = $data['object'] ?? array(); - - if ( ! \is_array( $object ) ) { - return; - } - - $type = $object['type'] ?? ''; - - // Only handle Undo Follow for now. - if ( 'Follow' !== $type ) { - return; - } - - // Get the target actor from the original Follow activity. - $target = $object['object'] ?? ''; - - if ( empty( $target ) || ! \is_string( $target ) ) { - return; - } - - // Get the remote actor. - $remote_actor = Remote_Actors::get_by_uri( $target ); - - if ( \is_wp_error( $remote_actor ) ) { - return; - } - - // Remove following relationship. - \delete_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, $user_id ); - \delete_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, $user_id ); - - /** - * Fires after an outgoing Undo Follow activity has been processed. - * - * @param int $remote_actor_id The remote actor post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_outbox_undo_follow_sent', $remote_actor->ID, $data, $user_id, $outbox_id ); - } - - /** - * Handle "Undo" requests. - * - * @deprecated unreleased Use Undo::incoming() instead. - * - * @param array $activity The JSON "Undo" Activity. - * @param int|int[]|null $user_ids The user ID(s). - */ - public static function handle_undo( $activity, $user_ids ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Undo::incoming()' ); - - return self::incoming( $activity, $user_ids ); - } - - /** - * Handle outbox "Undo" activities. - * - * @deprecated unreleased Use Undo::outgoing() instead. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_id ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Undo::outgoing()' ); - - return self::outgoing( $data, $user_id, $activity, $outbox_id ); - } } diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index 60f988b4fe..b8c3d2cd85 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -22,18 +22,16 @@ class Update { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_handled_inbox_update', array( self::class, 'incoming' ), 10, 3 ); - \add_action( 'activitypub_handled_outbox_update', array( self::class, 'outgoing' ), 10, 4 ); + \add_action( 'activitypub_handled_inbox_update', array( self::class, 'handle_update' ), 10, 2 ); } /** - * Handle incoming "Update" requests from remote actors. + * Handle "Update" requests. * - * @param array $activity The Activity object. - * @param int[] $user_ids The user IDs. Always null for Update activities. - * @param \Activitypub\Activity\Activity $activity_object The activity object. Default null. + * @param array $activity The Activity object. + * @param int[]|null $user_ids The user IDs. Always null for Update activities. */ - public static function incoming( $activity, $user_ids, $activity_object ) { + public static function handle_update( $activity, $user_ids = null ) { $object_type = $activity['object']['type'] ?? ''; switch ( $object_type ) { @@ -62,7 +60,7 @@ public static function incoming( $activity, $user_ids, $activity_object ) { case 'Video': case 'Event': case 'Document': - self::update_object( $activity, $user_ids, $activity_object ); + self::update_object( $activity, $user_ids ); break; /* @@ -78,11 +76,10 @@ public static function incoming( $activity, $user_ids, $activity_object ) { /** * Update an Object. * - * @param array $activity The Activity object. - * @param int[]|null $user_ids The user IDs. Always null for Update activities. - * @param \Activitypub\Activity\Activity $activity_object The activity object. Default null. + * @param array $activity The Activity object. + * @param int[]|null $user_ids The user IDs. Always null for Update activities. */ - public static function update_object( $activity, $user_ids, $activity_object ) { + public static function update_object( $activity, $user_ids ) { $result = new \WP_Error( 'activitypub_update_failed', 'Update failed' ); $updated = true; @@ -105,7 +102,7 @@ public static function update_object( $activity, $user_ids, $activity_object ) { // There is no object to update, try to trigger create instead. if ( ! $updated ) { - return Create::incoming( $activity, $user_ids, $activity_object ); + return Create::handle_create( $activity, $user_ids ); } $success = ( $result && ! \is_wp_error( $result ) ); @@ -147,128 +144,4 @@ public static function update_actor( $activity, $user_ids ) { */ \do_action( 'activitypub_handled_update', $activity, (array) $user_ids, $state, $actor ); } - - /** - * Handle outgoing "Update" activities from local actors. - * - * Updates a WordPress post from the ActivityPub object. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function outgoing( $data, $user_id, $activity, $outbox_id ) { - $object = $data['object'] ?? array(); - - if ( ! \is_array( $object ) ) { - return; - } - - $type = $object['type'] ?? ''; - - // Only handle Note and Article types. - if ( ! \in_array( $type, array( 'Note', 'Article' ), true ) ) { - return; - } - - $object_id = $object['id'] ?? ''; - - if ( empty( $object_id ) ) { - return; - } - - /* - * Find the post by its ActivityPub ID. - * First try to find a local post by permalink (for C2S-created posts). - */ - $post_id = \url_to_postid( $object_id ); - $post = $post_id ? \get_post( $post_id ) : null; - - // Fall back to Posts collection for remote posts (ap_post type). - if ( ! $post instanceof \WP_Post ) { - $post = Posts::get_by_guid( $object_id ); - } - - if ( ! $post instanceof \WP_Post ) { - return; - } - - /* - * Verify the user owns this post. - * The blog actor ($user_id === 0) can update any post since it - * represents the site itself. - */ - if ( (int) $post->post_author !== $user_id && $user_id > 0 ) { - return; - } - - $content = $object['content'] ?? ''; - $name = $object['name'] ?? ''; - $summary = $object['summary'] ?? ''; - - // Use name as title for Articles, or generate from content for Notes. - $title = $name; - if ( empty( $title ) && ! empty( $content ) ) { - $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); - } - - $post_data = array( - 'ID' => $post->ID, - 'post_title' => $title, - 'post_content' => $content, - 'post_excerpt' => $summary, - ); - - /* - * Pass $fire_after_hooks = false to prevent wp_after_insert_post from - * re-triggering the outbox chain and causing infinite recursion. - */ - $post_id = \wp_update_post( $post_data, true, false ); - - if ( \is_wp_error( $post_id ) ) { - return; - } - - /** - * Fires after a post has been updated from an outgoing Update activity. - * - * @param int $post_id The updated post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - * @param int $outbox_id The outbox post ID. - */ - \do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id ); - } - - /** - * Handle "Update" requests. - * - * @deprecated unreleased Use Update::incoming() instead. - * - * @param array $activity The Activity object. - * @param int[] $user_ids The user IDs. - * @param \Activitypub\Activity\Activity $activity_object The activity object. - */ - public static function handle_update( $activity, $user_ids, $activity_object ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Update::incoming()' ); - - return self::incoming( $activity, $user_ids, $activity_object ); - } - - /** - * Handle outbox "Update" activities. - * - * @deprecated unreleased Use Update::outgoing() instead. - * - * @param array $data The activity data array. - * @param int $user_id The user ID. - * @param \Activitypub\Activity\Activity $activity The Activity object. - * @param int $outbox_id The outbox post ID. - */ - public static function handle_outbox_update( $data, $user_id, $activity, $outbox_id ) { - \_deprecated_function( __METHOD__, 'unreleased', 'Update::outgoing()' ); - - return self::outgoing( $data, $user_id, $activity, $outbox_id ); - } } diff --git a/includes/handler/outbox/class-announce.php b/includes/handler/outbox/class-announce.php new file mode 100644 index 0000000000..9cd31b0932 --- /dev/null +++ b/includes/handler/outbox/class-announce.php @@ -0,0 +1,47 @@ + $user_id > 0 ? $user_id : 0, + 'post_title' => $title, + 'post_content' => $content, + 'post_excerpt' => $summary, + 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish', + 'post_type' => 'post', + 'meta_input' => array( + 'activitypub_content_visibility' => $visibility, + ), + ); + + $post_id = \wp_insert_post( $post_data, true ); + + if ( \is_wp_error( $post_id ) ) { + return $post_id; + } + + // Set post format to 'status' for Notes so the transformer maps it back correctly. + if ( 'Note' === $object_type ) { + \set_post_format( $post_id, 'status' ); + } + + $post = \get_post( $post_id ); + + /** + * Fires after a post has been created from an outgoing Create activity. + * + * @param int $post_id The created post ID. + * @param array $activity The activity data. + * @param int $user_id The user ID. + * @param string $visibility The content visibility. + */ + \do_action( 'activitypub_outbox_created_post', $post_id, $activity, $user_id, $visibility ); + + return $post; + } + + /** + * Handle outgoing reply from local actor. + * + * Creates a WordPress comment on the local post. The comment scheduler + * will add it to the outbox and federate it. + * + * @param array $activity The activity data. + * @param int $user_id The local user ID. + * + * @return \WP_Comment|false Comment on success, false if not a local reply. + */ + private static function create_comment( $activity, $user_id ) { + $result = Interactions::add_comment( $activity, $user_id ); + + if ( ! $result ) { + return false; + } + + return \get_comment( $result ); + } +} diff --git a/includes/handler/outbox/class-delete.php b/includes/handler/outbox/class-delete.php new file mode 100644 index 0000000000..ad6b688cb3 --- /dev/null +++ b/includes/handler/outbox/class-delete.php @@ -0,0 +1,84 @@ +post_author !== $user_id && $user_id > 0 ) { + return; + } + + // Trash the post (use wp_delete_post with false to move to trash). + $result = \wp_trash_post( $post->ID ); + + if ( ! $result ) { + return; + } + + /** + * Fires after a post has been deleted from an outgoing Delete activity. + * + * @param int $post_id The deleted post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + */ + \do_action( 'activitypub_outbox_deleted_post', $post->ID, $data, $user_id ); + } +} diff --git a/includes/handler/outbox/class-follow.php b/includes/handler/outbox/class-follow.php new file mode 100644 index 0000000000..1cdf8f5ad9 --- /dev/null +++ b/includes/handler/outbox/class-follow.php @@ -0,0 +1,68 @@ +ID ); + $following = $all_meta[ Following::FOLLOWING_META_KEY ] ?? array(); + $pending = $all_meta[ Following::PENDING_META_KEY ] ?? array(); + + if ( \in_array( (string) $user_id, $following, true ) || \in_array( (string) $user_id, $pending, true ) ) { + return; + } + + // Add to pending following. + \add_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, (string) $user_id ); + + /** + * Fires after an outgoing Follow activity has been processed. + * + * @param int $remote_actor_id The remote actor post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + */ + \do_action( 'activitypub_outbox_follow_sent', $remote_actor->ID, $data, $user_id ); + } +} diff --git a/includes/handler/outbox/class-like.php b/includes/handler/outbox/class-like.php new file mode 100644 index 0000000000..166a872c96 --- /dev/null +++ b/includes/handler/outbox/class-like.php @@ -0,0 +1,47 @@ +ID, Following::FOLLOWING_META_KEY, $user_id ); + \delete_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, $user_id ); + + /** + * Fires after an outgoing Undo Follow activity has been processed. + * + * @param int $remote_actor_id The remote actor post ID. + * @param array $data The activity data. + * @param int $user_id The user ID. + */ + \do_action( 'activitypub_outbox_undo_follow_sent', $remote_actor->ID, $data, $user_id ); + } +} diff --git a/includes/handler/outbox/class-update.php b/includes/handler/outbox/class-update.php new file mode 100644 index 0000000000..4866af841d --- /dev/null +++ b/includes/handler/outbox/class-update.php @@ -0,0 +1,132 @@ +post_author !== $user_id && $user_id > 0 ) { + return null; + } + + $content = $object['content'] ?? ''; + $name = $object['name'] ?? ''; + $summary = $object['summary'] ?? ''; + + // Use name as title for Articles, or generate from content for Notes. + $title = $name; + if ( empty( $title ) && ! empty( $content ) ) { + $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); + } + + // Determine visibility if not provided. + if ( null === $visibility ) { + $visibility = get_content_visibility( $activity ); + } + + $post_data = array( + 'ID' => $post->ID, + 'post_title' => $title, + 'post_content' => $content, + 'post_excerpt' => $summary, + 'meta_input' => array( + 'activitypub_content_visibility' => $visibility, + ), + ); + + $post_id = \wp_update_post( $post_data, true ); + + if ( \is_wp_error( $post_id ) ) { + return null; + } + + $post = \get_post( $post_id ); + + /** + * Fires after a post has been updated from an outgoing Update activity. + * + * @param int $post_id The updated post ID. + * @param array $activity The activity data. + * @param int $user_id The user ID. + */ + \do_action( 'activitypub_outbox_updated_post', $post_id, $activity, $user_id ); + + return $post; + } +} diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 2e5147d52f..f0eb80b44c 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -11,6 +11,7 @@ use Activitypub\Activity\Base_Object; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; +use Activitypub\Comment; use function Activitypub\add_to_outbox; use function Activitypub\get_masked_wp_version; @@ -413,11 +414,15 @@ public function create_item( $request ) { ); } - // If handler returned a WP_Post, the scheduler already added it to outbox. + // If handler returned a WP_Post or WP_Comment, the scheduler already added it to outbox. if ( $result instanceof \WP_Post ) { $object_id = \Activitypub\get_post_id( $result->ID ); $activity_type = \ucfirst( $data['type'] ?? 'Create' ); $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type ); + } elseif ( $result instanceof \WP_Comment ) { + $object_id = Comment::generate_id( $result ); + $activity_type = \ucfirst( $data['type'] ?? 'Create' ); + $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type ); } elseif ( \is_int( $result ) && $result > 0 ) { // Handler returned an outbox post ID directly. $outbox_item = \get_post( $result ); diff --git a/tests/phpunit/tests/includes/class-test-migration.php b/tests/phpunit/tests/includes/class-test-migration.php index 4b4438910e..4203219059 100644 --- a/tests/phpunit/tests/includes/class-test-migration.php +++ b/tests/phpunit/tests/includes/class-test-migration.php @@ -99,6 +99,17 @@ public static function set_up_before_class() { \add_comment_meta( self::$fixtures['comment'], 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); } + /** + * Restore hooks removed in set_up_before_class. + */ + public static function tear_down_after_class() { + \add_action( 'wp_after_insert_post', array( \Activitypub\Scheduler\Post::class, 'triage' ), 33, 4 ); + \add_action( 'transition_comment_status', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity' ), 20, 3 ); + \add_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ), 10, 2 ); + + parent::tear_down_after_class(); + } + /** * Tear down the test. */ diff --git a/tests/phpunit/tests/includes/handler/class-test-announce.php b/tests/phpunit/tests/includes/handler/class-test-announce.php index 5821cdf5db..a709ea5915 100644 --- a/tests/phpunit/tests/includes/handler/class-test-announce.php +++ b/tests/phpunit/tests/includes/handler/class-test-announce.php @@ -9,6 +9,7 @@ use Activitypub\Activity\Activity; use Activitypub\Handler\Announce; +use Activitypub\Handler\Outbox\Announce as Outbox_Announce; use Activitypub\Model\Blog; /** @@ -102,7 +103,7 @@ public static function create_test_object() { /** * Test handle announce. * - * @covers ::incoming + * @covers ::handle_announce */ public function test_handle_announce() { $external_actor = 'https://example.com/users/testuser'; @@ -116,7 +117,7 @@ public function test_handle_announce() { 'object' => $this->post_permalink, ); - Announce::incoming( $object, $this->user_id ); + Announce::handle_announce( $object, $this->user_id ); $args = array( 'type' => 'repost', @@ -132,7 +133,7 @@ public function test_handle_announce() { /** * Test handle announces. * - * @covers ::incoming + * @covers ::handle_announce * * @dataProvider data_handle_announces * @@ -146,7 +147,7 @@ public function test_handle_announces( $announce, $recursion, $message ) { \add_action( 'activitypub_inbox', array( $inbox_action, 'action' ) ); $activity = Activity::init_from_array( $announce ); - Announce::incoming( $announce, $this->user_id, $activity ); + Announce::handle_announce( $announce, $this->user_id, $activity ); $this->assertEquals( $recursion, $inbox_action->get_call_count(), $message ); } @@ -226,7 +227,7 @@ public static function data_handle_announces() { /** * Test that announces from the blog actor are ignored. * - * @covers ::incoming + * @covers ::handle_announce */ public function test_ignore_blog_actor_announce() { $blog = new Blog(); @@ -246,7 +247,7 @@ public function test_ignore_blog_actor_announce() { \add_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) ); // Call with blog actor as sender - should be ignored. - Announce::incoming( $object, $this->user_id ); + Announce::handle_announce( $object, $this->user_id ); // Verify the announce was NOT handled. $this->assertEquals( 0, $handled_action->get_call_count() ); @@ -268,7 +269,7 @@ public function test_ignore_blog_actor_announce() { /** * Test that announces from external actors are not ignored. * - * @covers ::incoming + * @covers ::handle_announce */ public function test_external_actor_announce_not_ignored() { $external_actor = 'https://external.example.com/users/someone'; @@ -287,7 +288,7 @@ public function test_external_actor_announce_not_ignored() { \add_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) ); // Call with external actor - should be processed. - Announce::incoming( $object, $this->user_id ); + Announce::handle_announce( $object, $this->user_id ); // Verify the announce WAS handled. $this->assertEquals( 1, $handled_action->get_call_count() ); @@ -310,7 +311,7 @@ public function test_external_actor_announce_not_ignored() { /** * Test outgoing Announce fires action hook. * - * @covers ::outgoing + * @covers ::handle_announce */ public function test_outgoing_fires_action() { $object_url = 'https://example.com/post/123'; @@ -328,7 +329,7 @@ public function test_outgoing_fires_action() { 'object' => $object_url, ); - Announce::outgoing( $data, $this->user_id, null, 0 ); + Outbox_Announce::handle_announce( $data, $this->user_id ); $this->assertTrue( $fired, 'activitypub_outbox_announce_sent action should fire.' ); @@ -338,7 +339,7 @@ public function test_outgoing_fires_action() { /** * Test outgoing Announce returns early for empty object. * - * @covers ::outgoing + * @covers ::handle_announce */ public function test_outgoing_returns_early_for_empty_object() { $fired = false; @@ -353,7 +354,7 @@ public function test_outgoing_returns_early_for_empty_object() { 'object' => '', ); - Announce::outgoing( $data, $this->user_id, null, 0 ); + Outbox_Announce::handle_announce( $data, $this->user_id ); $this->assertFalse( $fired, 'Action should not fire for empty object.' ); @@ -363,7 +364,7 @@ public function test_outgoing_returns_early_for_empty_object() { /** * Test that announces from same domain but different actor are not ignored. * - * @covers ::incoming + * @covers ::handle_announce */ public function test_same_domain_different_actor_not_ignored() { // Get a regular user actor URL (not the blog actor). @@ -383,7 +384,7 @@ public function test_same_domain_different_actor_not_ignored() { \add_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) ); // Call with same domain but user actor - should be processed. - Announce::incoming( $object, $this->user_id ); + Announce::handle_announce( $object, $this->user_id ); // Verify the announce WAS handled. $this->assertEquals( 1, $handled_action->get_call_count() ); diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index 2713318d4a..f60cdcfa4b 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -12,7 +12,6 @@ use Activitypub\Collection\Posts; use Activitypub\Handler\Create; use Activitypub\Post_Types; -use Activitypub\Scheduler\Post; use Activitypub\Tombstone; /** @@ -127,23 +126,23 @@ public function create_test_object( $id = 'https://example.com/123' ) { /** * Test handle create. * - * @covers ::incoming + * @covers ::handle_create */ public function test_handle_create_non_public_rejected() { $object = $this->create_test_object(); $object['cc'] = array(); - $converted = Create::incoming( $object, $this->user_id ); + $converted = Create::handle_create( $object, $this->user_id ); $this->assertFalse( $converted ); } /** * Test handle create. * - * @covers ::incoming + * @covers ::handle_create */ public function test_handle_create_public_accepted() { $object = $this->create_test_object(); - Create::incoming( $object, $this->user_id ); + Create::handle_create( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -161,13 +160,13 @@ public function test_handle_create_public_accepted() { /** * Test handle create. * - * @covers ::incoming + * @covers ::handle_create */ public function test_handle_create_public_accepted_without_type() { $object = $this->create_test_object( 'https://example.com/123456' ); unset( $object['type'] ); - Create::incoming( $object, $this->user_id ); + Create::handle_create( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -184,12 +183,12 @@ public function test_handle_create_public_accepted_without_type() { /** * Test handle create check duplicate ID. * - * @covers ::incoming + * @covers ::handle_create */ public function test_handle_create_check_duplicate_id() { $id = 'https://example.com/id/' . microtime( true ); $object = $this->create_test_object( $id ); - Create::incoming( $object, $this->user_id ); + Create::handle_create( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -204,7 +203,7 @@ public function test_handle_create_check_duplicate_id() { $this->assertCount( 1, $result ); $object['object']['content'] = 'example2'; - Create::incoming( $object, $this->user_id ); + Create::handle_create( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -220,12 +219,12 @@ public function test_handle_create_check_duplicate_id() { /** * Test handle create check duplicate content. * - * @covers ::incoming + * @covers ::handle_create */ public function test_handle_create_check_duplicate_content() { $id = 'https://example.com/id/' . microtime( true ); $object = $this->create_test_object( $id ); - Create::incoming( $object, $this->user_id ); + Create::handle_create( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -241,7 +240,7 @@ public function test_handle_create_check_duplicate_content() { $id = 'https://example.com/id/' . microtime( true ); $object = $this->create_test_object( $id ); - Create::incoming( $object, $this->user_id ); + Create::handle_create( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -257,12 +256,12 @@ public function test_handle_create_check_duplicate_content() { /** * Test handle create multiple comments. * - * @covers ::incoming + * @covers ::handle_create */ public function test_handle_create_check_multiple_comments() { $id = 'https://example.com/id/4711'; $object = $this->create_test_object( $id ); - Create::incoming( $object, $this->user_id ); + Create::handle_create( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -279,7 +278,7 @@ public function test_handle_create_check_multiple_comments() { $id = 'https://example.com/id/23'; $object = $this->create_test_object( $id ); $object['object']['content'] = 'example2'; - Create::incoming( $object, $this->user_id ); + Create::handle_create( $object, $this->user_id ); $args = array( 'type' => 'comment', @@ -299,7 +298,7 @@ public function test_handle_create_check_multiple_comments() { /** * Test handling create activity for objects with content sanitization. * - * @covers ::incoming + * @covers ::handle_create */ public function test_handle_create_object_with_sanitization() { // Mock HTTP request for Remote_Actors::fetch_by_uri. @@ -340,7 +339,7 @@ public function test_handle_create_object_with_sanitization() { \update_option( 'activitypub_create_posts', true ); - Create::incoming( $activity, $this->user_id ); + Create::handle_create( $activity, $this->user_id ); // Verify the object was created with sanitized content. $created_object = Posts::get_by_guid( 'https://example.com/objects/note_sanitize' ); @@ -360,7 +359,7 @@ public function test_handle_create_object_with_sanitization() { /** * Test handling private create activity. * - * @covers ::incoming + * @covers ::handle_create */ public function test_handle_create_private_activity() { $private_activity = array( @@ -384,7 +383,7 @@ public function test_handle_create_private_activity() { ) ); - Create::incoming( $private_activity, $this->user_id ); + Create::handle_create( $private_activity, $this->user_id ); // Count objects after. $objects_after = get_posts( @@ -402,7 +401,7 @@ public function test_handle_create_private_activity() { /** * Test create activity with malformed object data. * - * @covers ::incoming + * @covers ::handle_create */ public function test_handle_create_malformed_object() { $malformed_activity = array( @@ -425,7 +424,7 @@ public function test_handle_create_malformed_object() { ) ); - Create::incoming( $malformed_activity, $this->user_id ); + Create::handle_create( $malformed_activity, $this->user_id ); // Count objects after. $objects_after = get_posts( @@ -443,7 +442,7 @@ public function test_handle_create_malformed_object() { /** * Test incoming returns false when activitypub_create_posts option is disabled. * - * @covers ::incoming + * @covers ::handle_create */ public function test_incoming_post_disabled_by_option() { // Ensure option is not set. @@ -481,7 +480,7 @@ public function test_incoming_post_disabled_by_option() { ), ); - $result = Create::incoming( $activity, array( $this->user_id ) ); + $result = Create::handle_create( $activity, array( $this->user_id ) ); $this->assertFalse( $result ); @@ -495,7 +494,7 @@ public function test_incoming_post_disabled_by_option() { /** * Test incoming works when activitypub_create_posts option is enabled. * - * @covers ::incoming + * @covers ::handle_create */ public function test_incoming_post_enabled_by_option() { // Enable the option. @@ -533,7 +532,7 @@ public function test_incoming_post_enabled_by_option() { ), ); - $result = Create::incoming( $activity, array( $this->user_id ) ); + $result = Create::handle_create( $activity, array( $this->user_id ) ); $this->assertInstanceOf( 'WP_Post', $result ); @@ -566,7 +565,7 @@ public function test_reply_to_non_existent_post_returns_false() { ), ); - $result = Create::incoming( $object, $this->user_id ); + $result = Create::handle_create( $object, $this->user_id ); $this->assertFalse( $result ); @@ -796,7 +795,7 @@ public function test_soft_delete_refederate_lifecycle() { * in `inReplyTo` to target the local test post. * * @dataProvider create_fixture_provider - * @covers ::incoming + * @covers ::handle_create * * @param string $path The path to the fixture JSON file. */ @@ -806,7 +805,7 @@ public function test_handle_create_from_fixture( $path ) { $activity = json_decode( $json, true ); - Create::incoming( $activity, $this->user_id ); + Create::handle_create( $activity, $this->user_id ); $comments = ( new \WP_Comment_Query( array( @@ -835,277 +834,4 @@ public function create_fixture_provider() { return $fixtures; } - - /** - * Test outgoing Note creates a post with status post format. - * - * @covers ::outgoing - */ - public function test_outgoing_note_creates_post_with_status_format() { - // Prevent wp_insert_post() from triggering the full outbox chain. - \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - - $user_id = self::factory()->user->create(); - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => array( - 'type' => 'Note', - 'content' => '

Hello from the Fediverse!

', - ), - ); - - $result = Create::outgoing( $activity, $user_id ); - - $this->assertInstanceOf( 'WP_Post', $result ); - $this->assertEquals( 'status', \get_post_format( $result->ID ) ); - $this->assertStringContainsString( 'Hello from the Fediverse!', $result->post_content ); - - \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); - } - - /** - * Test outgoing Article creates a post without post format. - * - * @covers ::outgoing - */ - public function test_outgoing_article_creates_post_without_format() { - \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - - $user_id = self::factory()->user->create(); - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => array( - 'type' => 'Article', - 'name' => 'My Article Title', - 'content' => '

Article body here.

', - ), - ); - - $result = Create::outgoing( $activity, $user_id ); - - $this->assertInstanceOf( 'WP_Post', $result ); - $this->assertFalse( \get_post_format( $result->ID ) ); - $this->assertEquals( 'My Article Title', $result->post_title ); - - \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); - } - - /** - * Test outgoing private visibility returns false. - * - * @covers ::outgoing - */ - public function test_outgoing_private_visibility_returns_false() { - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://example.com/users/recipient' ), // Private message. - 'object' => array( - 'type' => 'Note', - 'content' => 'Private note.', - 'to' => array( 'https://example.com/users/recipient' ), - ), - ); - - $result = Create::outgoing( $activity, 1, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); - - $this->assertFalse( $result ); - } - - /** - * Test outgoing non-Note/Article types return null. - * - * @covers ::outgoing - */ - public function test_outgoing_unsupported_type_returns_null() { - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => array( - 'type' => 'Event', - 'content' => 'An event.', - ), - ); - - $result = Create::outgoing( $activity, 1 ); - - $this->assertNull( $result ); - } - - /** - * Test outgoing replies return null. - * - * @covers ::outgoing - */ - public function test_outgoing_reply_returns_null() { - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => array( - 'type' => 'Note', - 'content' => 'A reply.', - 'inReplyTo' => 'https://example.com/note/123', - ), - ); - - $result = Create::outgoing( $activity, 1 ); - - $this->assertNull( $result ); - } - - /** - * Test outgoing invalid (non-array) object returns WP_Error. - * - * @covers ::outgoing - */ - public function test_outgoing_invalid_object_returns_error() { - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => 'https://example.com/note/1', - ); - - $result = Create::outgoing( $activity, 1 ); - - $this->assertWPError( $result ); - $this->assertEquals( 'invalid_object', $result->get_error_code() ); - } - - /** - * Test outgoing post sets content and title correctly. - * - * @covers ::outgoing - */ - public function test_outgoing_post_content_and_title() { - \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - - $user_id = self::factory()->user->create(); - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => array( - 'type' => 'Article', - 'name' => 'Specific Title', - 'content' => '

Specific content here.

', - 'summary' => 'A brief summary.', - ), - ); - - $result = Create::outgoing( $activity, $user_id ); - - $this->assertInstanceOf( 'WP_Post', $result ); - $this->assertEquals( 'Specific Title', $result->post_title ); - $this->assertEquals( '

Specific content here.

', $result->post_content ); - $this->assertEquals( 'A brief summary.', $result->post_excerpt ); - - \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); - } - - /** - * Test outgoing post auto-generates title from content when name is empty. - * - * @covers ::outgoing - */ - public function test_outgoing_post_generates_title_from_content() { - \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - - $user_id = self::factory()->user->create(); - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => array( - 'type' => 'Note', - 'content' => '

This is a short note without a title field.

', - ), - ); - - $result = Create::outgoing( $activity, $user_id ); - - $this->assertInstanceOf( 'WP_Post', $result ); - $this->assertNotEmpty( $result->post_title ); - $this->assertStringContainsString( 'This is a short', $result->post_title ); - - \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); - } - - /** - * Test outgoing post fires activitypub_outbox_created_post action. - * - * @covers ::outgoing - */ - public function test_outgoing_post_fires_action() { - \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - - $user_id = self::factory()->user->create(); - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_created_post', $callback ); - - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => array( - 'type' => 'Note', - 'content' => 'Testing action hook.', - ), - ); - - Create::outgoing( $activity, $user_id ); - - $this->assertTrue( $fired, 'activitypub_outbox_created_post action should fire.' ); - - \remove_action( 'activitypub_outbox_created_post', $callback ); - \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); - } - - /** - * Test outgoing post sets user_id as post_author. - * - * @covers ::outgoing - */ - public function test_outgoing_post_sets_author() { - \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - - $user_id = self::factory()->user->create(); - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => array( - 'type' => 'Note', - 'content' => 'Author test.', - ), - ); - - $result = Create::outgoing( $activity, $user_id ); - - $this->assertInstanceOf( 'WP_Post', $result ); - $this->assertEquals( $user_id, (int) $result->post_author ); - - \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); - } - - /** - * Test outgoing quotes return null. - * - * @covers ::outgoing - */ - public function test_outgoing_quote_returns_null() { - $activity = array( - 'type' => 'Create', - 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), - 'object' => array( - 'type' => 'Note', - 'content' => 'A quote post.', - 'quoteUrl' => 'https://example.com/note/456', - ), - ); - - $result = Create::outgoing( $activity, 1 ); - - $this->assertNull( $result ); - } } diff --git a/tests/phpunit/tests/includes/handler/class-test-delete.php b/tests/phpunit/tests/includes/handler/class-test-delete.php index 81ab189a43..957d269777 100644 --- a/tests/phpunit/tests/includes/handler/class-test-delete.php +++ b/tests/phpunit/tests/includes/handler/class-test-delete.php @@ -10,6 +10,7 @@ use Activitypub\Activity\Activity; use Activitypub\Activity\Base_Object; use Activitypub\Handler\Delete; +use Activitypub\Handler\Outbox\Delete as Outbox_Delete; use Activitypub\Scheduler\Post; use Activitypub\Tombstone; @@ -504,7 +505,7 @@ public function test_delete_object_with_tombstone_string_id() { /** * Test outgoing Delete trashes a local post. * - * @covers ::outgoing + * @covers ::handle_delete */ public function test_outgoing_trashes_post() { $post_id = self::factory()->post->create( @@ -522,7 +523,7 @@ public function test_outgoing_trashes_post() { 'object' => $permalink, ); - Delete::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Delete::handle_delete( $data, self::$user_id ); $post = \get_post( $post_id ); $this->assertEquals( 'trash', $post->post_status ); @@ -531,7 +532,7 @@ public function test_outgoing_trashes_post() { /** * Test outgoing Delete fires action hook on success. * - * @covers ::outgoing + * @covers ::handle_delete */ public function test_outgoing_fires_action() { $post_id = self::factory()->post->create( @@ -554,7 +555,7 @@ public function test_outgoing_fires_action() { 'object' => $permalink, ); - Delete::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Delete::handle_delete( $data, self::$user_id ); $this->assertTrue( $fired, 'activitypub_outbox_deleted_post action should fire.' ); @@ -564,7 +565,7 @@ public function test_outgoing_fires_action() { /** * Test outgoing Delete skips posts not owned by user. * - * @covers ::outgoing + * @covers ::handle_delete */ public function test_outgoing_skips_unowned_post() { $other_user = self::factory()->user->create(); @@ -583,7 +584,7 @@ public function test_outgoing_skips_unowned_post() { 'object' => $permalink, ); - Delete::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Delete::handle_delete( $data, self::$user_id ); $post = \get_post( $post_id ); $this->assertEquals( 'publish', $post->post_status ); @@ -592,7 +593,7 @@ public function test_outgoing_skips_unowned_post() { /** * Test outgoing Delete returns early for empty object. * - * @covers ::outgoing + * @covers ::handle_delete */ public function test_outgoing_returns_early_for_empty_object() { $data = array( @@ -601,14 +602,14 @@ public function test_outgoing_returns_early_for_empty_object() { ); // Should not throw errors. - Delete::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Delete::handle_delete( $data, self::$user_id ); $this->assertTrue( true ); } /** * Test outgoing Delete returns early for non-existent post. * - * @covers ::outgoing + * @covers ::handle_delete */ public function test_outgoing_returns_early_for_nonexistent_post() { $data = array( @@ -617,7 +618,7 @@ public function test_outgoing_returns_early_for_nonexistent_post() { ); // Should not throw errors. - Delete::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Delete::handle_delete( $data, self::$user_id ); $this->assertTrue( true ); } diff --git a/tests/phpunit/tests/includes/handler/class-test-follow.php b/tests/phpunit/tests/includes/handler/class-test-follow.php index fc6815c8ca..b013a8a9ef 100644 --- a/tests/phpunit/tests/includes/handler/class-test-follow.php +++ b/tests/phpunit/tests/includes/handler/class-test-follow.php @@ -11,6 +11,7 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Outbox; use Activitypub\Handler\Follow; +use Activitypub\Handler\Outbox\Follow as Outbox_Follow; /** * Test class for Follow handler. @@ -42,7 +43,7 @@ public static function wpSetUpBeforeClass( $factory ) { * Test handle_follow method with different scenarios. * * @dataProvider handle_follow_provider - * @covers ::incoming + * @covers ::handle_follow * * @param mixed $target_user_id The user ID being followed (int or 'test_user'). * @param string $actor_url The actor URL following. @@ -81,7 +82,7 @@ public function test_handle_follow( $target_user_id, $actor_url, $expected_respo $followers_before = Followers::get_many( $target_user_id ); $followers_count_before = count( $followers_before ); - Follow::incoming( $activity_object, $target_user_id ); + Follow::handle_follow( $activity_object, $target_user_id ); // Check if follower was added. $followers_after = Followers::get_many( $target_user_id ); @@ -240,7 +241,7 @@ public function test_queue_accept() { /** * Test that duplicate follow requests don't trigger notifications. * - * @covers ::incoming + * @covers ::handle_follow */ public function test_duplicate_follow_no_notification() { $actor_url = 'https://example.com/duplicate-actor'; @@ -278,7 +279,7 @@ public function test_duplicate_follow_no_notification() { \add_action( 'activitypub_handled_follow', $test_callback, 10, 4 ); // First follow request - should succeed. - Follow::incoming( $activity_object, self::$user_id ); + Follow::handle_follow( $activity_object, self::$user_id ); // Verify first follow was successful. $this->assertCount( 1, $handled_follow_calls, 'First follow should trigger the action' ); @@ -291,7 +292,7 @@ public function test_duplicate_follow_no_notification() { // Second follow request with a different activity ID (simulating a retry). $activity_object['id'] = $actor_url . '/activity/follow-2'; - Follow::incoming( $activity_object, self::$user_id ); + Follow::handle_follow( $activity_object, self::$user_id ); // Verify second follow was not successful (to prevent duplicate notification). $this->assertCount( 2, $handled_follow_calls, 'Second follow should also trigger the action' ); @@ -356,7 +357,7 @@ public function test_queue_reject() { /** * Test outgoing Follow adds pending follow metadata. * - * @covers ::outgoing + * @covers ::handle_follow */ public function test_outgoing_adds_pending_follow() { $actor_url = 'https://example.com/users/to-follow'; @@ -380,7 +381,7 @@ public function test_outgoing_adds_pending_follow() { 'object' => $actor_url, ); - Follow::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Follow::handle_follow( $data, self::$user_id ); // Verify pending follow was added. $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url ); @@ -395,7 +396,7 @@ public function test_outgoing_adds_pending_follow() { /** * Test outgoing Follow skips if already following. * - * @covers ::outgoing + * @covers ::handle_follow */ public function test_outgoing_skips_if_already_following() { $actor_url = 'https://example.com/users/already-following'; @@ -420,14 +421,14 @@ public function test_outgoing_skips_if_already_following() { ); // First follow should succeed. - Follow::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Follow::handle_follow( $data, self::$user_id ); $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url ); $pending = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); $count_after_first = count( $pending ); // Second follow should be skipped (already pending). - Follow::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Follow::handle_follow( $data, self::$user_id ); $pending_after = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); $this->assertCount( $count_after_first, $pending_after, 'Should not add duplicate pending follow.' ); @@ -438,7 +439,7 @@ public function test_outgoing_skips_if_already_following() { /** * Test outgoing Follow returns early for empty object. * - * @covers ::outgoing + * @covers ::handle_follow */ public function test_outgoing_returns_early_for_empty_object() { $data = array( @@ -447,14 +448,14 @@ public function test_outgoing_returns_early_for_empty_object() { ); // Should not throw errors. - Follow::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Follow::handle_follow( $data, self::$user_id ); $this->assertTrue( true ); } /** * Test outgoing Follow returns early for non-string object. * - * @covers ::outgoing + * @covers ::handle_follow */ public function test_outgoing_returns_early_for_non_string_object() { $data = array( @@ -463,14 +464,14 @@ public function test_outgoing_returns_early_for_non_string_object() { ); // Should not throw errors. - Follow::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Follow::handle_follow( $data, self::$user_id ); $this->assertTrue( true ); } /** * Test that deprecated hook still fires for backward compatibility. * - * @covers ::incoming + * @covers ::handle_follow */ public function test_deprecated_hook_fires() { // Expect the deprecation notice. @@ -512,7 +513,7 @@ public function test_deprecated_hook_fires() { 'object' => Actors::get_by_id( self::$user_id )->get_id(), ); - Follow::incoming( $activity_object, self::$user_id ); + Follow::handle_follow( $activity_object, self::$user_id ); // Verify deprecated hook fired. $this->assertTrue( $hook_fired, 'Deprecated hook should fire' ); @@ -529,7 +530,7 @@ public function test_deprecated_hook_fires() { /** * Test new hook fires correctly. * - * @covers ::incoming + * @covers ::handle_follow */ public function test_new_hook_fires() { $hook_fired = false; @@ -569,7 +570,7 @@ public function test_new_hook_fires() { 'object' => Actors::get_by_id( self::$user_id )->get_id(), ); - Follow::incoming( $activity_object, self::$user_id ); + Follow::handle_follow( $activity_object, self::$user_id ); // Verify new hook fired. $this->assertTrue( $hook_fired, 'New hook should fire' ); diff --git a/tests/phpunit/tests/includes/handler/class-test-like.php b/tests/phpunit/tests/includes/handler/class-test-like.php index bf544429d4..eb1e44ef2e 100644 --- a/tests/phpunit/tests/includes/handler/class-test-like.php +++ b/tests/phpunit/tests/includes/handler/class-test-like.php @@ -8,6 +8,7 @@ namespace Activitypub\Tests\Handler; use Activitypub\Handler\Like; +use Activitypub\Handler\Outbox\Like as Outbox_Like; /** * Test class for Activitypub Like Handler. @@ -110,7 +111,7 @@ public function create_test_object() { * Test handle_like with different scenarios. * * @dataProvider handle_like_provider - * @covers ::incoming + * @covers ::handle_like * * @param array $activity_data The like activity data. * @param bool $should_create_comment Whether a comment should be created. @@ -130,7 +131,7 @@ public function test_handle_like( $activity_data, $should_create_comment, $descr $count_before = count( $comments_before ); // Process the like. - Like::incoming( $activity, $this->user_id ); + Like::handle_like( $activity, $this->user_id ); // Check comment count after. $comments_after = \get_comments( @@ -191,7 +192,7 @@ public function handle_like_provider() { * This test verifies that Like activities from Pixelfed and other platforms * that include trailing slashes in object URLs are processed correctly. * - * @covers ::incoming + * @covers ::handle_like * @covers \Activitypub\Collection\Interactions::add_reaction */ public function test_handle_like_with_trailing_slash() { @@ -214,7 +215,7 @@ public function test_handle_like_with_trailing_slash() { $count_before = count( $comments_before ); // Process the like. - Like::incoming( $activity, $this->user_id ); + Like::handle_like( $activity, $this->user_id ); // Check that comment was created despite trailing slash. $comments_after = \get_comments( @@ -232,7 +233,7 @@ public function test_handle_like_with_trailing_slash() { /** * Test duplicate like handling. * - * @covers ::incoming + * @covers ::handle_like */ public function test_handle_like_duplicate() { $activity = array_merge( @@ -241,7 +242,7 @@ public function test_handle_like_duplicate() { ); // Process the like first time. - Like::incoming( $activity, $this->user_id ); + Like::handle_like( $activity, $this->user_id ); $comments_after_first = \get_comments( array( @@ -252,7 +253,7 @@ public function test_handle_like_duplicate() { $count_after_first = count( $comments_after_first ); // Process the same like again. - Like::incoming( $activity, $this->user_id ); + Like::handle_like( $activity, $this->user_id ); $comments_after_second = \get_comments( array( @@ -268,7 +269,7 @@ public function test_handle_like_duplicate() { /** * Test handle_like action hook fires. * - * @covers ::incoming + * @covers ::handle_like */ public function test_handle_like_action_hook() { $hook_fired = false; @@ -287,7 +288,7 @@ public function test_handle_like_action_hook() { \add_action( 'activitypub_handled_like', $handled_like_callback, 10, 4 ); $activity = $this->create_test_object(); - Like::incoming( $activity, $this->user_id ); + Like::handle_like( $activity, $this->user_id ); // Verify hook was fired. $this->assertTrue( $hook_fired, 'Action hook should be fired' ); @@ -304,7 +305,7 @@ public function test_handle_like_action_hook() { /** * Test outgoing Like fires action hook. * - * @covers ::outgoing + * @covers ::handle_like */ public function test_outgoing_fires_action() { $object_url = 'https://example.com/post/456'; @@ -322,7 +323,7 @@ public function test_outgoing_fires_action() { 'object' => $object_url, ); - Like::outgoing( $data, $this->user_id, null, 0 ); + Outbox_Like::handle_like( $data, $this->user_id ); $this->assertTrue( $fired, 'activitypub_outbox_like_sent action should fire.' ); @@ -332,7 +333,7 @@ public function test_outgoing_fires_action() { /** * Test outgoing Like returns early for empty object. * - * @covers ::outgoing + * @covers ::handle_like */ public function test_outgoing_returns_early_for_empty_object() { $fired = false; @@ -347,7 +348,7 @@ public function test_outgoing_returns_early_for_empty_object() { 'object' => '', ); - Like::outgoing( $data, $this->user_id, null, 0 ); + Outbox_Like::handle_like( $data, $this->user_id ); $this->assertFalse( $fired, 'Action should not fire for empty object.' ); diff --git a/tests/phpunit/tests/includes/handler/class-test-undo.php b/tests/phpunit/tests/includes/handler/class-test-undo.php index d8f6b3ddd4..39d46503b2 100644 --- a/tests/phpunit/tests/includes/handler/class-test-undo.php +++ b/tests/phpunit/tests/includes/handler/class-test-undo.php @@ -12,6 +12,7 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Inbox as Inbox_Collection; use Activitypub\Comment; +use Activitypub\Handler\Outbox\Undo as Outbox_Undo; use Activitypub\Handler\Undo; /** @@ -55,7 +56,7 @@ public function set_up() { * Test handle_undo with follow activities. * * @dataProvider follow_undo_provider - * @covers ::incoming + * @covers ::handle_undo * * @param string $actor_url The actor URL to test with. * @param string $description Description of the test case. @@ -96,7 +97,7 @@ public function test_handle_undo_follow( $actor_url, $description ) { Inbox_Collection::add( $activity_object, self::$user_id ); // Call the Follow handler directly to add the follower. - \Activitypub\Handler\Follow::incoming( $follow_activity, self::$user_id ); + \Activitypub\Handler\Follow::handle_follow( $follow_activity, self::$user_id ); // Verify follower was added. $followers = Followers::get_many( self::$user_id ); @@ -117,7 +118,7 @@ public function test_handle_undo_follow( $actor_url, $description ) { ); // Call the Undo handler directly. - Undo::incoming( $undo_activity, self::$user_id ); + Undo::handle_undo( $undo_activity, self::$user_id ); // Verify follower was removed. $followers_after = Followers::get_many( self::$user_id ); @@ -153,7 +154,7 @@ public function follow_undo_provider() { * Test handle_undo with comment-related activities (Like, Create, Announce). * * @dataProvider comment_activities_undo_provider - * @covers ::incoming + * @covers ::handle_undo * * @param string $actor_url The actor URL to test with. * @param string $activity_type The type of activity being undone. @@ -200,7 +201,8 @@ public function test_handle_undo_comment_activities( $actor_url, $activity_type, // Call the appropriate handler directly to create the comment. $handler_class = '\\Activitypub\\Handler\\' . $activity_type; - $handler_class::incoming( $create_activity, self::$user_id ); + $method = 'handle_' . strtolower( $activity_type ); + $handler_class::$method( $create_activity, self::$user_id ); // Find the comment that was created. $found_comment = Comment::object_id_to_comment( $activity_id ); @@ -220,7 +222,7 @@ public function test_handle_undo_comment_activities( $actor_url, $activity_type, ); // Call the Undo handler directly. - Undo::incoming( $undo_activity, self::$user_id ); + Undo::handle_undo( $undo_activity, self::$user_id ); // Verify comment was deleted. $comment_after = \get_comment( $comment_id ); @@ -248,7 +250,7 @@ public function comment_activities_undo_provider() { /** * Test handle_undo action hook is fired. * - * @covers ::incoming + * @covers ::handle_undo */ public function test_handle_undo_action_hook() { $action_fired = false; @@ -297,7 +299,7 @@ public function test_handle_undo_action_hook() { $activity_object = Activity::init_from_array( $follow_activity ); Inbox_Collection::add( $activity_object, self::$user_id ); - \Activitypub\Handler\Follow::incoming( $follow_activity, self::$user_id ); + \Activitypub\Handler\Follow::handle_follow( $follow_activity, self::$user_id ); // Create Undo activity. $activity = array( @@ -314,7 +316,7 @@ public function test_handle_undo_action_hook() { ); // Call the Undo handler directly. - Undo::incoming( $activity, self::$user_id ); + Undo::handle_undo( $activity, self::$user_id ); $this->assertTrue( $action_fired ); $this->assertEquals( $activity, $activity_data ); @@ -331,7 +333,7 @@ public function test_handle_undo_action_hook() { /** * Test outgoing Undo Follow removes following metadata. * - * @covers ::outgoing + * @covers ::handle_undo */ public function test_outgoing_undo_follow() { $actor_url = 'https://example.com/users/unfollow-target'; @@ -351,14 +353,12 @@ public function test_outgoing_undo_follow() { \add_filter( 'activitypub_pre_http_get_remote_object', $mock_callback, 10, 2 ); // First add the following metadata by triggering a Follow outgoing. - \Activitypub\Handler\Follow::outgoing( + \Activitypub\Handler\Outbox\Follow::handle_follow( array( 'type' => 'Follow', 'object' => $actor_url, ), - self::$user_id, - null, - 0 + self::$user_id ); $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url ); @@ -377,7 +377,7 @@ public function test_outgoing_undo_follow() { ), ); - Undo::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Undo::handle_undo( $data, self::$user_id ); // Verify following/pending metadata was removed. $pending_after = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); @@ -392,7 +392,7 @@ public function test_outgoing_undo_follow() { /** * Test outgoing Undo ignores non-Follow types. * - * @covers ::outgoing + * @covers ::handle_undo */ public function test_outgoing_ignores_non_follow() { $fired = false; @@ -410,7 +410,7 @@ public function test_outgoing_ignores_non_follow() { ), ); - Undo::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Undo::handle_undo( $data, self::$user_id ); $this->assertFalse( $fired, 'Action should not fire for non-Follow undo.' ); @@ -420,7 +420,7 @@ public function test_outgoing_ignores_non_follow() { /** * Test outgoing Undo returns early for non-array object. * - * @covers ::outgoing + * @covers ::handle_undo */ public function test_outgoing_returns_early_for_string_object() { $data = array( @@ -429,7 +429,7 @@ public function test_outgoing_returns_early_for_string_object() { ); // Should not throw errors. - Undo::outgoing( $data, self::$user_id, null, 0 ); + Outbox_Undo::handle_undo( $data, self::$user_id ); $this->assertTrue( true ); } diff --git a/tests/phpunit/tests/includes/handler/class-test-update.php b/tests/phpunit/tests/includes/handler/class-test-update.php index 9125a23a49..9979f30118 100644 --- a/tests/phpunit/tests/includes/handler/class-test-update.php +++ b/tests/phpunit/tests/includes/handler/class-test-update.php @@ -138,258 +138,6 @@ public function test_update_actor( $activity_data, $http_response, $expected_out \remove_filter( 'activitypub_pre_http_get_remote_object', $fake_request ); } - /** - * Test outgoing Update with a Note updates the post. - * - * @covers ::outgoing - */ - public function test_outgoing_updates_post() { - $post_id = self::factory()->post->create( - array( - 'post_author' => $this->user_id, - 'post_title' => 'Original Title', - 'post_content' => 'Original content', - 'post_status' => 'publish', - ) - ); - - $permalink = \get_permalink( $post_id ); - - $data = array( - 'type' => 'Update', - 'object' => array( - 'type' => 'Note', - 'id' => $permalink, - 'content' => 'Updated content', - 'name' => 'Updated Title', - 'summary' => 'Updated summary', - ), - ); - - Update::outgoing( $data, $this->user_id, null, 0 ); - - $post = \get_post( $post_id ); - $this->assertEquals( 'Updated Title', $post->post_title ); - $this->assertEquals( 'Updated content', $post->post_content ); - $this->assertEquals( 'Updated summary', $post->post_excerpt ); - } - - /** - * Test outgoing Update generates title from content for Notes without name. - * - * @covers ::outgoing - */ - public function test_outgoing_generates_title_from_content() { - $post_id = self::factory()->post->create( - array( - 'post_author' => $this->user_id, - 'post_status' => 'publish', - ) - ); - - $permalink = \get_permalink( $post_id ); - - $data = array( - 'type' => 'Update', - 'object' => array( - 'type' => 'Note', - 'id' => $permalink, - 'content' => 'This is a short note without a title field.', - ), - ); - - Update::outgoing( $data, $this->user_id, null, 0 ); - - $post = \get_post( $post_id ); - $this->assertNotEmpty( $post->post_title ); - $this->assertStringContainsString( 'This is a short', $post->post_title ); - } - - /** - * Test outgoing Update ignores non-Note/Article types. - * - * @covers ::outgoing - */ - public function test_outgoing_ignores_unsupported_types() { - $post_id = self::factory()->post->create( - array( - 'post_author' => $this->user_id, - 'post_title' => 'Original', - 'post_status' => 'publish', - ) - ); - - $permalink = \get_permalink( $post_id ); - - $data = array( - 'type' => 'Update', - 'object' => array( - 'type' => 'Event', - 'id' => $permalink, - 'content' => 'Should not update', - ), - ); - - Update::outgoing( $data, $this->user_id, null, 0 ); - - $post = \get_post( $post_id ); - $this->assertEquals( 'Original', $post->post_title ); - } - - /** - * Test outgoing Update skips posts not owned by user. - * - * @covers ::outgoing - */ - public function test_outgoing_skips_unowned_post() { - $other_user = self::factory()->user->create(); - $post_id = self::factory()->post->create( - array( - 'post_author' => $other_user, - 'post_title' => 'Other User Post', - 'post_status' => 'publish', - ) - ); - - $permalink = \get_permalink( $post_id ); - - $data = array( - 'type' => 'Update', - 'object' => array( - 'type' => 'Note', - 'id' => $permalink, - 'content' => 'Should not update', - 'name' => 'Hijacked', - ), - ); - - Update::outgoing( $data, $this->user_id, null, 0 ); - - $post = \get_post( $post_id ); - $this->assertEquals( 'Other User Post', $post->post_title ); - } - - /** - * Test outgoing Update returns early for non-array object. - * - * @covers ::outgoing - */ - public function test_outgoing_returns_early_for_string_object() { - $data = array( - 'type' => 'Update', - 'object' => 'https://example.com/note/1', - ); - - // Should not throw errors. - Update::outgoing( $data, $this->user_id, null, 0 ); - $this->assertTrue( true ); - } - - /** - * Test outgoing Update returns early for empty object ID. - * - * @covers ::outgoing - */ - public function test_outgoing_returns_early_for_empty_id() { - $data = array( - 'type' => 'Update', - 'object' => array( - 'type' => 'Note', - 'content' => 'No ID provided', - ), - ); - - // Should not throw errors. - Update::outgoing( $data, $this->user_id, null, 0 ); - $this->assertTrue( true ); - } - - /** - * Test outgoing Update fires action hook on success. - * - * @covers ::outgoing - */ - public function test_outgoing_fires_action() { - $post_id = self::factory()->post->create( - array( - 'post_author' => $this->user_id, - 'post_status' => 'publish', - ) - ); - - $permalink = \get_permalink( $post_id ); - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_updated_post', $callback ); - - $data = array( - 'type' => 'Update', - 'object' => array( - 'type' => 'Note', - 'id' => $permalink, - 'content' => 'Updated', - ), - ); - - Update::outgoing( $data, $this->user_id, null, 0 ); - - $this->assertTrue( $fired, 'activitypub_outbox_updated_post action should fire.' ); - - \remove_action( 'activitypub_outbox_updated_post', $callback ); - } - - /** - * Test outgoing Update does not re-trigger Post::triage via wp_update_post. - * - * @covers ::outgoing - */ - public function test_outgoing_recursion_guard() { - /* - * Re-add the triage hook so we can verify it does NOT fire - * during the wp_update_post() call inside outgoing(). - */ - \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); - - $post_id = self::factory()->post->create( - array( - 'post_author' => $this->user_id, - 'post_status' => 'publish', - 'post_title' => 'Original', - ) - ); - - $permalink = \get_permalink( $post_id ); - $call_count = 0; - - /* - * Count how many times triage is invoked. If the recursion guard - * works, triage should not fire during the outgoing update. - */ - $counter = function () use ( &$call_count ) { - ++$call_count; - }; - \add_action( 'wp_after_insert_post', $counter, 32 ); - - $data = array( - 'type' => 'Update', - 'object' => array( - 'type' => 'Note', - 'id' => $permalink, - 'content' => 'First update', - ), - ); - - Update::outgoing( $data, $this->user_id, null, 0 ); - - $this->assertEquals( 0, $call_count, 'wp_after_insert_post should not fire during outgoing update.' ); - - \remove_action( 'wp_after_insert_post', $counter, 32 ); - \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - } - /** * Data provider for update_actor tests. * diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-create.php b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php new file mode 100644 index 0000000000..ee430fbb63 --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php @@ -0,0 +1,402 @@ +user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => '

Hello from the Fediverse!

', + ), + ); + + $result = Create::handle_create( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertEquals( 'status', \get_post_format( $result->ID ) ); + $this->assertStringContainsString( 'Hello from the Fediverse!', $result->post_content ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing Article creates a post without post format. + * + * @covers ::handle_create + */ + public function test_outgoing_article_creates_post_without_format() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Article', + 'name' => 'My Article Title', + 'content' => '

Article body here.

', + ), + ); + + $result = Create::handle_create( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertFalse( \get_post_format( $result->ID ) ); + $this->assertEquals( 'My Article Title', $result->post_title ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing private visibility returns false. + * + * @covers ::handle_create + */ + public function test_outgoing_private_visibility_returns_false() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://example.com/users/recipient' ), // Private message. + 'object' => array( + 'type' => 'Note', + 'content' => 'Private note.', + 'to' => array( 'https://example.com/users/recipient' ), + ), + ); + + $result = Create::handle_create( $activity, 1, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); + + $this->assertFalse( $result ); + } + + /** + * Test outgoing non-Note/Article types return null. + * + * @covers ::handle_create + */ + public function test_outgoing_unsupported_type_returns_null() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Event', + 'content' => 'An event.', + ), + ); + + $result = Create::handle_create( $activity, 1 ); + + $this->assertNull( $result ); + } + + /** + * Test outgoing reply to non-local URL is not handled. + * + * @covers ::handle_create + */ + public function test_outgoing_reply_to_remote_url() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'A reply.', + 'inReplyTo' => 'https://example.com/note/123', + ), + ); + + $result = Create::handle_create( $activity, 1 ); + + // Reply to non-local URL: no local post found, returns false. + $this->assertFalse( $result ); + } + + /** + * Test outgoing invalid (non-array) object returns WP_Error. + * + * @covers ::handle_create + */ + public function test_outgoing_invalid_object_returns_error() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => 'https://example.com/note/1', + ); + + $result = Create::handle_create( $activity, 1 ); + + $this->assertWPError( $result ); + $this->assertEquals( 'invalid_object', $result->get_error_code() ); + } + + /** + * Test outgoing post sets content and title correctly. + * + * @covers ::handle_create + */ + public function test_outgoing_post_content_and_title() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Article', + 'name' => 'Specific Title', + 'content' => '

Specific content here.

', + 'summary' => 'A brief summary.', + ), + ); + + $result = Create::handle_create( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertEquals( 'Specific Title', $result->post_title ); + $this->assertEquals( '

Specific content here.

', $result->post_content ); + $this->assertEquals( 'A brief summary.', $result->post_excerpt ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing post auto-generates title from content when name is empty. + * + * @covers ::handle_create + */ + public function test_outgoing_post_generates_title_from_content() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => '

This is a short note without a title field.

', + ), + ); + + $result = Create::handle_create( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertNotEmpty( $result->post_title ); + $this->assertStringContainsString( 'This is a short', $result->post_title ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing post fires activitypub_outbox_created_post action. + * + * @covers ::handle_create + */ + public function test_outgoing_post_fires_action() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_created_post', $callback ); + + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'Testing action hook.', + ), + ); + + Create::handle_create( $activity, $user_id ); + + $this->assertTrue( $fired, 'activitypub_outbox_created_post action should fire.' ); + + \remove_action( 'activitypub_outbox_created_post', $callback ); + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing post sets user_id as post_author. + * + * @covers ::handle_create + */ + public function test_outgoing_post_sets_author() { + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + + $user_id = self::factory()->user->create(); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'Author test.', + ), + ); + + $result = Create::handle_create( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Post', $result ); + $this->assertEquals( $user_id, (int) $result->post_author ); + + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + } + + /** + * Test outgoing reply to local post creates a comment. + * + * @covers ::handle_create + */ + public function test_outgoing_reply_to_local_post() { + \remove_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ) ); + + $user_id = self::factory()->user->create(); + $post_id = self::factory()->post->create( array( 'post_author' => $user_id ) ); + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => '

This is a reply.

', + 'inReplyTo' => \get_permalink( $post_id ), + ), + ); + + $result = Create::handle_create( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Comment', $result ); + $this->assertEquals( $post_id, (int) $result->comment_post_ID ); + $this->assertEquals( 0, (int) $result->comment_parent ); + $this->assertEquals( $user_id, (int) $result->user_id ); + $this->assertStringContainsString( 'This is a reply.', $result->comment_content ); + $this->assertEquals( 'activitypub', \get_comment_meta( $result->comment_ID, 'protocol', true ) ); + + \add_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ), 10, 2 ); + } + + /** + * Test outgoing reply to local comment creates a nested comment. + * + * @covers ::handle_create + */ + public function test_outgoing_reply_to_local_comment() { + \remove_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ) ); + + $user_id = self::factory()->user->create(); + $post_id = self::factory()->post->create( array( 'post_author' => $user_id ) ); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'user_id' => $user_id, + ) + ); + + // Build the comment URL using the ?c= format that url_to_commentid() resolves. + $comment_url = \home_url( '?c=' . $comment_id ); + + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => '

Nested reply.

', + 'inReplyTo' => $comment_url, + ), + ); + + $result = Create::handle_create( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Comment', $result ); + $this->assertEquals( $post_id, (int) $result->comment_post_ID ); + $this->assertEquals( $comment_id, (int) $result->comment_parent ); + $this->assertEquals( $user_id, (int) $result->user_id ); + + \add_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ), 10, 2 ); + } + + /** + * Test outgoing reply uses local user data for comment author. + * + * @covers ::handle_create + */ + public function test_outgoing_reply_uses_local_user_data() { + \remove_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ) ); + + $user_id = self::factory()->user->create( + array( + 'display_name' => 'Test Author', + 'user_email' => 'test@example.org', + 'user_url' => 'https://example.org', + ) + ); + $post_id = self::factory()->post->create( array( 'post_author' => $user_id ) ); + + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'Author test reply.', + 'inReplyTo' => \get_permalink( $post_id ), + ), + ); + + $result = Create::handle_create( $activity, $user_id ); + + $this->assertInstanceOf( 'WP_Comment', $result ); + $this->assertEquals( 'Test Author', $result->comment_author ); + $this->assertEquals( 'test@example.org', $result->comment_author_email ); + $this->assertEquals( 'https://example.org', $result->comment_author_url ); + + \add_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ), 10, 2 ); + } + + /** + * Test outgoing quotes return null. + * + * @covers ::handle_create + */ + public function test_outgoing_quote_returns_null() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'object' => array( + 'type' => 'Note', + 'content' => 'A quote post.', + 'quoteUrl' => 'https://example.com/note/456', + ), + ); + + $result = Create::handle_create( $activity, 1 ); + + $this->assertNull( $result ); + } +} diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-update.php b/tests/phpunit/tests/includes/handler/outbox/class-test-update.php new file mode 100644 index 0000000000..62154d540f --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-update.php @@ -0,0 +1,250 @@ +user_id = self::factory()->user->create(); + } + + /** + * Tear down the test. + */ + public function tear_down() { + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + + parent::tear_down(); + } + + /** + * Test outgoing Update with a Note updates the post. + * + * @covers ::handle_update + */ + public function test_outgoing_updates_post() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_title' => 'Original Title', + 'post_content' => 'Original content', + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'Updated content', + 'name' => 'Updated Title', + 'summary' => 'Updated summary', + ), + ); + + Update::handle_update( $data, $this->user_id ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'Updated Title', $post->post_title ); + $this->assertEquals( 'Updated content', $post->post_content ); + $this->assertEquals( 'Updated summary', $post->post_excerpt ); + } + + /** + * Test outgoing Update generates title from content for Notes without name. + * + * @covers ::handle_update + */ + public function test_outgoing_generates_title_from_content() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'This is a short note without a title field.', + ), + ); + + Update::handle_update( $data, $this->user_id ); + + $post = \get_post( $post_id ); + $this->assertNotEmpty( $post->post_title ); + $this->assertStringContainsString( 'This is a short', $post->post_title ); + } + + /** + * Test outgoing Update ignores non-Note/Article types. + * + * @covers ::handle_update + */ + public function test_outgoing_ignores_unsupported_types() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_title' => 'Original', + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Event', + 'id' => $permalink, + 'content' => 'Should not update', + ), + ); + + Update::handle_update( $data, $this->user_id ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'Original', $post->post_title ); + } + + /** + * Test outgoing Update skips posts not owned by user. + * + * @covers ::handle_update + */ + public function test_outgoing_skips_unowned_post() { + $other_user = self::factory()->user->create(); + $post_id = self::factory()->post->create( + array( + 'post_author' => $other_user, + 'post_title' => 'Other User Post', + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'Should not update', + 'name' => 'Hijacked', + ), + ); + + Update::handle_update( $data, $this->user_id ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'Other User Post', $post->post_title ); + } + + /** + * Test outgoing Update returns early for non-array object. + * + * @covers ::handle_update + */ + public function test_outgoing_returns_early_for_string_object() { + $data = array( + 'type' => 'Update', + 'object' => 'https://example.com/note/1', + ); + + // Should not throw errors. + Update::handle_update( $data, $this->user_id ); + $this->assertTrue( true ); + } + + /** + * Test outgoing Update returns early for empty object ID. + * + * @covers ::handle_update + */ + public function test_outgoing_returns_early_for_empty_id() { + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'content' => 'No ID provided', + ), + ); + + // Should not throw errors. + Update::handle_update( $data, $this->user_id ); + $this->assertTrue( true ); + } + + /** + * Test outgoing Update fires action hook on success. + * + * @covers ::handle_update + */ + public function test_outgoing_fires_action() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_status' => 'publish', + ) + ); + + $permalink = \get_permalink( $post_id ); + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_updated_post', $callback ); + + $data = array( + 'type' => 'Update', + 'object' => array( + 'type' => 'Note', + 'id' => $permalink, + 'content' => 'Updated', + ), + ); + + Update::handle_update( $data, $this->user_id ); + + $this->assertTrue( $fired, 'activitypub_outbox_updated_post action should fire.' ); + + \remove_action( 'activitypub_outbox_updated_post', $callback ); + } +} diff --git a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php index e2de05fb32..47efca65d4 100644 --- a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php @@ -37,6 +37,11 @@ class Test_Outbox_Controller extends Test_REST_Controller_Testcase { * Set up class test fixtures. */ public static function set_up_before_class() { + // Ensure the post scheduler hook is present (may be removed by other test classes). + if ( ! \has_action( 'wp_after_insert_post', array( \Activitypub\Scheduler\Post::class, 'triage' ) ) ) { + \add_action( 'wp_after_insert_post', array( \Activitypub\Scheduler\Post::class, 'triage' ), 33, 4 ); + } + self::$user_id = self::factory()->user->create( array( 'role' => 'author' ) ); \get_user_by( 'ID', self::$user_id )->add_cap( 'activitypub' ); \wp_set_current_user( self::$user_id ); @@ -59,6 +64,11 @@ public static function tear_down_after_class() { public function set_up() { parent::set_up(); \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + + // Ensure the post scheduler hook is present (may be removed by other test classes). + if ( ! \has_action( 'wp_after_insert_post', array( \Activitypub\Scheduler\Post::class, 'triage' ) ) ) { + \add_action( 'wp_after_insert_post', array( \Activitypub\Scheduler\Post::class, 'triage' ), 33, 4 ); + } } /** From 38221bc0501bb5764d68f2db403bd72d622774e2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 20:46:56 +0100 Subject: [PATCH 050/105] Revert inbox handlers and tests to trunk state Restore all handler files and their tests to match trunk exactly. The outbox (C2S) logic lives solely in the Handler\Outbox\* classes. --- includes/handler/class-announce.php | 2 +- includes/handler/class-create.php | 64 ++++----- includes/handler/class-delete.php | 2 +- includes/handler/class-follow.php | 2 +- includes/handler/class-like.php | 2 +- includes/handler/class-undo.php | 2 +- includes/handler/class-update.php | 20 +-- .../includes/handler/class-test-announce.php | 54 -------- .../includes/handler/class-test-create.php | 21 +-- .../includes/handler/class-test-delete.php | 127 ------------------ .../includes/handler/class-test-follow.php | 115 ---------------- .../includes/handler/class-test-like.php | 54 -------- .../includes/handler/class-test-undo.php | 110 +-------------- .../includes/handler/class-test-update.php | 13 -- 14 files changed, 59 insertions(+), 529 deletions(-) diff --git a/includes/handler/class-announce.php b/includes/handler/class-announce.php index a9e528f03a..9f58ba7fc3 100644 --- a/includes/handler/class-announce.php +++ b/includes/handler/class-announce.php @@ -28,7 +28,7 @@ public static function init() { } /** - * Handle incoming "Announce" requests from remote actors. + * Handles "Announce" requests. * * @param array $announcement The activity-object. * @param int|int[] $user_ids The id(s) of the local blog-user(s). diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index 8ecb87cff2..a7ebc94a28 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -25,66 +25,60 @@ class Create { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_handled_inbox_create', array( self::class, 'handle_create' ), 10, 2 ); - + \add_action( 'activitypub_handled_inbox_create', array( self::class, 'handle_create' ), 10, 3 ); \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'maybe_unbury' ), 10, 2 ); } /** - * Handle incoming "Create" activities from remote actors. - * - * @param array $activity The activity data. - * @param int[] $user_ids The local user IDs targeted. + * Handles "Create" requests. * - * @return \WP_Post|\WP_Comment|\WP_Error|false The created content or error. + * @param array $activity The activity-object. + * @param int|int[] $user_ids The id(s) of the local blog-user(s). + * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null. */ - public static function handle_create( $activity, $user_ids = null ) { + public static function handle_create( $activity, $user_ids, $activity_object = null ) { // Check for private and/or direct messages. if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) { - return false; - } - - // Route to appropriate handler based on content type. - if ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { - $result = self::create_interaction( $activity, $user_ids ); - } else { - $result = self::create_post( $activity, $user_ids ); + $result = false; + } elseif ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { // Check for replies and quotes. + $result = self::create_interaction( $activity, $user_ids, $activity_object ); + } else { // Handle non-interaction objects. + $result = self::create_post( $activity, $user_ids, $activity_object ); } if ( false === $result ) { - return $result; + return; } $success = ! \is_wp_error( $result ); /** - * Fires after an incoming ActivityPub Create activity has been handled. + * Fires after an ActivityPub Create activity has been handled. * * @param array $activity The ActivityPub activity data. * @param int[] $user_ids The local user IDs. * @param bool $success True on success, false otherwise. - * @param \WP_Comment|\WP_Post|\WP_Error $result The created content or error. + * @param \WP_Comment|\WP_Post|\WP_Error $result The WP_Comment object of the created comment, or null if creation failed. */ \do_action( 'activitypub_handled_create', $activity, (array) $user_ids, $success, $result ); - - return $result; } /** - * Handle incoming interaction (reply/quote) from remote actor. + * Handle interactions like replies. * - * @param array $activity The activity data. - * @param int[] $user_ids The local user IDs targeted. + * @param array $activity The activity-object. + * @param int[] $user_ids The ids of the local blog-users. + * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null. * - * @return \WP_Comment|\WP_Error|false Comment, WP_Error, or false. + * @return \WP_Comment|\WP_Error|false The created comment, WP_Error on failure, false if already exists or not processed. */ - public static function create_interaction( $activity, $user_ids ) { + public static function create_interaction( $activity, $user_ids, $activity_object = null ) { $existing_comment = object_id_to_comment( $activity['object']['id'] ); // If comment exists, call update action. if ( $existing_comment ) { - Update::handle_update( $activity, (array) $user_ids, null ); + Update::handle_update( $activity, (array) $user_ids, $activity_object ); return false; } @@ -103,14 +97,15 @@ public static function create_interaction( $activity, $user_ids ) { } /** - * Handle incoming post from remote actor. + * Handle non-interaction posts like posts. * - * @param array $activity The activity data. - * @param int[] $user_ids The local user IDs targeted. + * @param array $activity The activity-object. + * @param int[] $user_ids The ids of the local blog-users. + * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null. * - * @return \WP_Post|\WP_Error|false Post, WP_Error, or false. + * @return \WP_Post|\WP_Error|false The post on success, WP_Error on failure, false if already exists. */ - public static function create_post( $activity, $user_ids ) { + public static function create_post( $activity, $user_ids, $activity_object = null ) { if ( ! \get_option( 'activitypub_create_posts', false ) ) { return false; } @@ -119,7 +114,7 @@ public static function create_post( $activity, $user_ids ) { // If post exists, call update action. if ( $existing_post instanceof \WP_Post ) { - Update::handle_update( $activity, (array) $user_ids, null ); + Update::handle_update( $activity, (array) $user_ids, $activity_object ); return false; } @@ -151,8 +146,7 @@ public static function validate_object( $valid, $param, $request ) { return false; } - // Only content is required; ID is optional for outbox activities (assigned by the server). - if ( ! isset( $activity['object']['content'] ) ) { + if ( ! isset( $activity['object']['id'], $activity['object']['content'] ) ) { return false; } diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 6c42ec1fd5..ccc0f788aa 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -33,7 +33,7 @@ public static function init() { } /** - * Handle incoming "Delete" requests from remote actors. + * Handles "Delete" requests. * * @param array $activity The delete activity. * @param int|int[] $user_ids The local user ID(s). diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php index 39628d43c5..3095411875 100644 --- a/includes/handler/class-follow.php +++ b/includes/handler/class-follow.php @@ -27,7 +27,7 @@ public static function init() { } /** - * Handle incoming "Follow" requests from remote actors. + * Handle "Follow" requests. * * @param array $activity The activity object. * @param int|int[] $user_ids The user ID(s). diff --git a/includes/handler/class-like.php b/includes/handler/class-like.php index bb6e2d8d0b..1d8912a7d8 100644 --- a/includes/handler/class-like.php +++ b/includes/handler/class-like.php @@ -25,7 +25,7 @@ public static function init() { } /** - * Handle incoming "Like" requests from remote actors. + * Handles "Like" requests. * * @param array $like The Activity array. * @param int|int[] $user_ids The user ID(s). diff --git a/includes/handler/class-undo.php b/includes/handler/class-undo.php index d71361bb0f..b2eda6c006 100644 --- a/includes/handler/class-undo.php +++ b/includes/handler/class-undo.php @@ -24,7 +24,7 @@ public static function init() { } /** - * Handle incoming "Undo" requests from remote actors. + * Handle "Unfollow" requests. * * @param array $activity The JSON "Undo" Activity. * @param int|int[]|null $user_ids The user ID(s). diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index b8c3d2cd85..0e5a6cf6dd 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -22,16 +22,17 @@ class Update { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_handled_inbox_update', array( self::class, 'handle_update' ), 10, 2 ); + \add_action( 'activitypub_handled_inbox_update', array( self::class, 'handle_update' ), 10, 3 ); } /** * Handle "Update" requests. * - * @param array $activity The Activity object. - * @param int[]|null $user_ids The user IDs. Always null for Update activities. + * @param array $activity The Activity object. + * @param int[] $user_ids The user IDs. Always null for Update activities. + * @param \Activitypub\Activity\Activity $activity_object The activity object. Default null. */ - public static function handle_update( $activity, $user_ids = null ) { + public static function handle_update( $activity, $user_ids, $activity_object ) { $object_type = $activity['object']['type'] ?? ''; switch ( $object_type ) { @@ -60,7 +61,7 @@ public static function handle_update( $activity, $user_ids = null ) { case 'Video': case 'Event': case 'Document': - self::update_object( $activity, $user_ids ); + self::update_object( $activity, $user_ids, $activity_object ); break; /* @@ -76,10 +77,11 @@ public static function handle_update( $activity, $user_ids = null ) { /** * Update an Object. * - * @param array $activity The Activity object. - * @param int[]|null $user_ids The user IDs. Always null for Update activities. + * @param array $activity The Activity object. + * @param int[]|null $user_ids The user IDs. Always null for Update activities. + * @param \Activitypub\Activity\Activity $activity_object The activity object. Default null. */ - public static function update_object( $activity, $user_ids ) { + public static function update_object( $activity, $user_ids, $activity_object ) { $result = new \WP_Error( 'activitypub_update_failed', 'Update failed' ); $updated = true; @@ -102,7 +104,7 @@ public static function update_object( $activity, $user_ids ) { // There is no object to update, try to trigger create instead. if ( ! $updated ) { - return Create::handle_create( $activity, $user_ids ); + return Create::handle_create( $activity, $user_ids, $activity_object ); } $success = ( $result && ! \is_wp_error( $result ) ); diff --git a/tests/phpunit/tests/includes/handler/class-test-announce.php b/tests/phpunit/tests/includes/handler/class-test-announce.php index a709ea5915..1c6ec81bf9 100644 --- a/tests/phpunit/tests/includes/handler/class-test-announce.php +++ b/tests/phpunit/tests/includes/handler/class-test-announce.php @@ -9,7 +9,6 @@ use Activitypub\Activity\Activity; use Activitypub\Handler\Announce; -use Activitypub\Handler\Outbox\Announce as Outbox_Announce; use Activitypub\Model\Blog; /** @@ -308,59 +307,6 @@ public function test_external_actor_announce_not_ignored() { \remove_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) ); } - /** - * Test outgoing Announce fires action hook. - * - * @covers ::handle_announce - */ - public function test_outgoing_fires_action() { - $object_url = 'https://example.com/post/123'; - $fired = false; - - $callback = function ( $url ) use ( &$fired, $object_url ) { - if ( $url === $object_url ) { - $fired = true; - } - }; - \add_action( 'activitypub_outbox_announce_sent', $callback ); - - $data = array( - 'type' => 'Announce', - 'object' => $object_url, - ); - - Outbox_Announce::handle_announce( $data, $this->user_id ); - - $this->assertTrue( $fired, 'activitypub_outbox_announce_sent action should fire.' ); - - \remove_action( 'activitypub_outbox_announce_sent', $callback ); - } - - /** - * Test outgoing Announce returns early for empty object. - * - * @covers ::handle_announce - */ - public function test_outgoing_returns_early_for_empty_object() { - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_announce_sent', $callback ); - - $data = array( - 'type' => 'Announce', - 'object' => '', - ); - - Outbox_Announce::handle_announce( $data, $this->user_id ); - - $this->assertFalse( $fired, 'Action should not fire for empty object.' ); - - \remove_action( 'activitypub_outbox_announce_sent', $callback ); - } - /** * Test that announces from same domain but different actor are not ignored. * diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index f60cdcfa4b..d6c94b5369 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -132,7 +132,7 @@ public function test_handle_create_non_public_rejected() { $object = $this->create_test_object(); $object['cc'] = array(); $converted = Create::handle_create( $object, $this->user_id ); - $this->assertFalse( $converted ); + $this->assertNull( $converted ); } /** @@ -299,6 +299,7 @@ public function test_handle_create_check_multiple_comments() { * Test handling create activity for objects with content sanitization. * * @covers ::handle_create + * @covers ::create_post */ public function test_handle_create_object_with_sanitization() { // Mock HTTP request for Remote_Actors::fetch_by_uri. @@ -440,11 +441,11 @@ public function test_handle_create_malformed_object() { } /** - * Test incoming returns false when activitypub_create_posts option is disabled. + * Test create_post returns false when activitypub_create_posts option is disabled. * - * @covers ::handle_create + * @covers ::create_post */ - public function test_incoming_post_disabled_by_option() { + public function test_create_post_disabled_by_option() { // Ensure option is not set. \delete_option( 'activitypub_create_posts' ); @@ -480,7 +481,7 @@ public function test_incoming_post_disabled_by_option() { ), ); - $result = Create::handle_create( $activity, array( $this->user_id ) ); + $result = Create::create_post( $activity, array( $this->user_id ) ); $this->assertFalse( $result ); @@ -492,11 +493,11 @@ public function test_incoming_post_disabled_by_option() { } /** - * Test incoming works when activitypub_create_posts option is enabled. + * Test create_post works when activitypub_create_posts option is enabled. * - * @covers ::handle_create + * @covers ::create_post */ - public function test_incoming_post_enabled_by_option() { + public function test_create_post_enabled_by_option() { // Enable the option. \update_option( 'activitypub_create_posts', '1' ); @@ -532,7 +533,7 @@ public function test_incoming_post_enabled_by_option() { ), ); - $result = Create::handle_create( $activity, array( $this->user_id ) ); + $result = Create::create_post( $activity, array( $this->user_id ) ); $this->assertInstanceOf( 'WP_Post', $result ); @@ -567,7 +568,7 @@ public function test_reply_to_non_existent_post_returns_false() { $result = Create::handle_create( $object, $this->user_id ); - $this->assertFalse( $result ); + $this->assertNull( $result ); // Verify no comment was created. $args = array( diff --git a/tests/phpunit/tests/includes/handler/class-test-delete.php b/tests/phpunit/tests/includes/handler/class-test-delete.php index 957d269777..10d70ed463 100644 --- a/tests/phpunit/tests/includes/handler/class-test-delete.php +++ b/tests/phpunit/tests/includes/handler/class-test-delete.php @@ -10,8 +10,6 @@ use Activitypub\Activity\Activity; use Activitypub\Activity\Base_Object; use Activitypub\Handler\Delete; -use Activitypub\Handler\Outbox\Delete as Outbox_Delete; -use Activitypub\Scheduler\Post; use Activitypub\Tombstone; /** @@ -45,9 +43,6 @@ public static function set_up_before_class() { public function set_up() { parent::set_up(); - // Prevent wp_trash_post() from triggering the full outbox chain. - \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - \add_filter( 'pre_get_remote_metadata_by_actor', array( self::class, 'get_remote_metadata_by_actor' ), 0, 2 ); } @@ -57,8 +52,6 @@ public function set_up() { public function tear_down() { \remove_filter( 'pre_get_remote_metadata_by_actor', array( self::class, 'get_remote_metadata_by_actor' ) ); - \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); - parent::tear_down(); } @@ -502,126 +495,6 @@ public function test_delete_object_with_tombstone_string_id() { \remove_filter( 'pre_http_request', $filter ); } - /** - * Test outgoing Delete trashes a local post. - * - * @covers ::handle_delete - */ - public function test_outgoing_trashes_post() { - $post_id = self::factory()->post->create( - array( - 'post_author' => self::$user_id, - 'post_status' => 'publish', - 'post_title' => 'To Be Deleted', - ) - ); - - $permalink = \get_permalink( $post_id ); - - $data = array( - 'type' => 'Delete', - 'object' => $permalink, - ); - - Outbox_Delete::handle_delete( $data, self::$user_id ); - - $post = \get_post( $post_id ); - $this->assertEquals( 'trash', $post->post_status ); - } - - /** - * Test outgoing Delete fires action hook on success. - * - * @covers ::handle_delete - */ - public function test_outgoing_fires_action() { - $post_id = self::factory()->post->create( - array( - 'post_author' => self::$user_id, - 'post_status' => 'publish', - ) - ); - - $permalink = \get_permalink( $post_id ); - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_deleted_post', $callback ); - - $data = array( - 'type' => 'Delete', - 'object' => $permalink, - ); - - Outbox_Delete::handle_delete( $data, self::$user_id ); - - $this->assertTrue( $fired, 'activitypub_outbox_deleted_post action should fire.' ); - - \remove_action( 'activitypub_outbox_deleted_post', $callback ); - } - - /** - * Test outgoing Delete skips posts not owned by user. - * - * @covers ::handle_delete - */ - public function test_outgoing_skips_unowned_post() { - $other_user = self::factory()->user->create(); - $post_id = self::factory()->post->create( - array( - 'post_author' => $other_user, - 'post_status' => 'publish', - 'post_title' => 'Not My Post', - ) - ); - - $permalink = \get_permalink( $post_id ); - - $data = array( - 'type' => 'Delete', - 'object' => $permalink, - ); - - Outbox_Delete::handle_delete( $data, self::$user_id ); - - $post = \get_post( $post_id ); - $this->assertEquals( 'publish', $post->post_status ); - } - - /** - * Test outgoing Delete returns early for empty object. - * - * @covers ::handle_delete - */ - public function test_outgoing_returns_early_for_empty_object() { - $data = array( - 'type' => 'Delete', - 'object' => '', - ); - - // Should not throw errors. - Outbox_Delete::handle_delete( $data, self::$user_id ); - $this->assertTrue( true ); - } - - /** - * Test outgoing Delete returns early for non-existent post. - * - * @covers ::handle_delete - */ - public function test_outgoing_returns_early_for_nonexistent_post() { - $data = array( - 'type' => 'Delete', - 'object' => 'https://example.com/nonexistent/post', - ); - - // Should not throw errors. - Outbox_Delete::handle_delete( $data, self::$user_id ); - $this->assertTrue( true ); - } - /** * Get remote metadata by actor. * diff --git a/tests/phpunit/tests/includes/handler/class-test-follow.php b/tests/phpunit/tests/includes/handler/class-test-follow.php index b013a8a9ef..ab2de4b296 100644 --- a/tests/phpunit/tests/includes/handler/class-test-follow.php +++ b/tests/phpunit/tests/includes/handler/class-test-follow.php @@ -11,7 +11,6 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Outbox; use Activitypub\Handler\Follow; -use Activitypub\Handler\Outbox\Follow as Outbox_Follow; /** * Test class for Follow handler. @@ -354,120 +353,6 @@ public function test_queue_reject() { $this->assertEquals( $actor_url, $activity_json['object']['actor'] ); } - /** - * Test outgoing Follow adds pending follow metadata. - * - * @covers ::handle_follow - */ - public function test_outgoing_adds_pending_follow() { - $actor_url = 'https://example.com/users/to-follow'; - - // Mock the HTTP request used by Remote_Actors::fetch_by_uri(). - $mock_callback = function ( $response, $url ) use ( $actor_url ) { - if ( $url === $actor_url ) { - return array( - 'id' => $actor_url, - 'type' => 'Person', - 'preferredUsername' => 'tofollow', - 'inbox' => $actor_url . '/inbox', - ); - } - return $response; - }; - \add_filter( 'activitypub_pre_http_get_remote_object', $mock_callback, 10, 2 ); - - $data = array( - 'type' => 'Follow', - 'object' => $actor_url, - ); - - Outbox_Follow::handle_follow( $data, self::$user_id ); - - // Verify pending follow was added. - $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url ); - $this->assertNotWPError( $remote_actor ); - - $pending = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); - $this->assertContains( (string) self::$user_id, $pending ); - - \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_callback ); - } - - /** - * Test outgoing Follow skips if already following. - * - * @covers ::handle_follow - */ - public function test_outgoing_skips_if_already_following() { - $actor_url = 'https://example.com/users/already-following'; - - // Mock the HTTP request used by Remote_Actors::fetch_by_uri(). - $mock_callback = function ( $response, $url ) use ( $actor_url ) { - if ( $url === $actor_url ) { - return array( - 'id' => $actor_url, - 'type' => 'Person', - 'preferredUsername' => 'alreadyfollowing', - 'inbox' => $actor_url . '/inbox', - ); - } - return $response; - }; - \add_filter( 'activitypub_pre_http_get_remote_object', $mock_callback, 10, 2 ); - - $data = array( - 'type' => 'Follow', - 'object' => $actor_url, - ); - - // First follow should succeed. - Outbox_Follow::handle_follow( $data, self::$user_id ); - - $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url ); - $pending = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); - $count_after_first = count( $pending ); - - // Second follow should be skipped (already pending). - Outbox_Follow::handle_follow( $data, self::$user_id ); - - $pending_after = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); - $this->assertCount( $count_after_first, $pending_after, 'Should not add duplicate pending follow.' ); - - \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_callback ); - } - - /** - * Test outgoing Follow returns early for empty object. - * - * @covers ::handle_follow - */ - public function test_outgoing_returns_early_for_empty_object() { - $data = array( - 'type' => 'Follow', - 'object' => '', - ); - - // Should not throw errors. - Outbox_Follow::handle_follow( $data, self::$user_id ); - $this->assertTrue( true ); - } - - /** - * Test outgoing Follow returns early for non-string object. - * - * @covers ::handle_follow - */ - public function test_outgoing_returns_early_for_non_string_object() { - $data = array( - 'type' => 'Follow', - 'object' => array( 'id' => 'https://example.com/user' ), - ); - - // Should not throw errors. - Outbox_Follow::handle_follow( $data, self::$user_id ); - $this->assertTrue( true ); - } - /** * Test that deprecated hook still fires for backward compatibility. * diff --git a/tests/phpunit/tests/includes/handler/class-test-like.php b/tests/phpunit/tests/includes/handler/class-test-like.php index eb1e44ef2e..7ae710e426 100644 --- a/tests/phpunit/tests/includes/handler/class-test-like.php +++ b/tests/phpunit/tests/includes/handler/class-test-like.php @@ -8,7 +8,6 @@ namespace Activitypub\Tests\Handler; use Activitypub\Handler\Like; -use Activitypub\Handler\Outbox\Like as Outbox_Like; /** * Test class for Activitypub Like Handler. @@ -302,59 +301,6 @@ public function test_handle_like_action_hook() { \remove_action( 'activitypub_handled_like', $handled_like_callback ); } - /** - * Test outgoing Like fires action hook. - * - * @covers ::handle_like - */ - public function test_outgoing_fires_action() { - $object_url = 'https://example.com/post/456'; - $fired = false; - - $callback = function ( $url ) use ( &$fired, $object_url ) { - if ( $url === $object_url ) { - $fired = true; - } - }; - \add_action( 'activitypub_outbox_like_sent', $callback ); - - $data = array( - 'type' => 'Like', - 'object' => $object_url, - ); - - Outbox_Like::handle_like( $data, $this->user_id ); - - $this->assertTrue( $fired, 'activitypub_outbox_like_sent action should fire.' ); - - \remove_action( 'activitypub_outbox_like_sent', $callback ); - } - - /** - * Test outgoing Like returns early for empty object. - * - * @covers ::handle_like - */ - public function test_outgoing_returns_early_for_empty_object() { - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_like_sent', $callback ); - - $data = array( - 'type' => 'Like', - 'object' => '', - ); - - Outbox_Like::handle_like( $data, $this->user_id ); - - $this->assertFalse( $fired, 'Action should not fire for empty object.' ); - - \remove_action( 'activitypub_outbox_like_sent', $callback ); - } - /** * Test outbox_activity method with Like activity. * diff --git a/tests/phpunit/tests/includes/handler/class-test-undo.php b/tests/phpunit/tests/includes/handler/class-test-undo.php index 39d46503b2..2b419f8763 100644 --- a/tests/phpunit/tests/includes/handler/class-test-undo.php +++ b/tests/phpunit/tests/includes/handler/class-test-undo.php @@ -12,7 +12,6 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Inbox as Inbox_Collection; use Activitypub\Comment; -use Activitypub\Handler\Outbox\Undo as Outbox_Undo; use Activitypub\Handler\Undo; /** @@ -200,9 +199,9 @@ public function test_handle_undo_comment_activities( $actor_url, $activity_type, Inbox_Collection::add( $activity_object, self::$user_id ); // Call the appropriate handler directly to create the comment. - $handler_class = '\\Activitypub\\Handler\\' . $activity_type; - $method = 'handle_' . strtolower( $activity_type ); - $handler_class::$method( $create_activity, self::$user_id ); + $handler_class = '\\Activitypub\\Handler\\' . $activity_type; + $handler_method = 'handle_' . strtolower( $activity_type ); + $handler_class::$handler_method( $create_activity, self::$user_id ); // Find the comment that was created. $found_comment = Comment::object_id_to_comment( $activity_id ); @@ -330,109 +329,6 @@ public function test_handle_undo_action_hook() { \remove_filter( 'pre_get_remote_metadata_by_actor', $mock_actor_metadata ); } - /** - * Test outgoing Undo Follow removes following metadata. - * - * @covers ::handle_undo - */ - public function test_outgoing_undo_follow() { - $actor_url = 'https://example.com/users/unfollow-target'; - - // Mock the HTTP request used by Remote_Actors::fetch_by_uri(). - $mock_callback = function ( $response, $url ) use ( $actor_url ) { - if ( $url === $actor_url ) { - return array( - 'id' => $actor_url, - 'type' => 'Person', - 'preferredUsername' => 'unfollowtarget', - 'inbox' => $actor_url . '/inbox', - ); - } - return $response; - }; - \add_filter( 'activitypub_pre_http_get_remote_object', $mock_callback, 10, 2 ); - - // First add the following metadata by triggering a Follow outgoing. - \Activitypub\Handler\Outbox\Follow::handle_follow( - array( - 'type' => 'Follow', - 'object' => $actor_url, - ), - self::$user_id - ); - - $remote_actor = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url ); - $this->assertNotWPError( $remote_actor ); - - // Verify pending metadata exists. - $pending = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); - $this->assertNotEmpty( $pending, 'Pending follow metadata should exist before undo.' ); - - // Now undo the follow. - $data = array( - 'type' => 'Undo', - 'object' => array( - 'type' => 'Follow', - 'object' => $actor_url, - ), - ); - - Outbox_Undo::handle_undo( $data, self::$user_id ); - - // Verify following/pending metadata was removed. - $pending_after = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::PENDING_META_KEY, false ); - $following_after = \get_post_meta( $remote_actor->ID, \Activitypub\Collection\Following::FOLLOWING_META_KEY, false ); - - $this->assertNotContains( (string) self::$user_id, $pending_after, 'Pending metadata should be removed.' ); - $this->assertNotContains( (string) self::$user_id, $following_after, 'Following metadata should be removed.' ); - - \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_callback ); - } - - /** - * Test outgoing Undo ignores non-Follow types. - * - * @covers ::handle_undo - */ - public function test_outgoing_ignores_non_follow() { - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_undo_follow_sent', $callback ); - - $data = array( - 'type' => 'Undo', - 'object' => array( - 'type' => 'Like', - 'object' => 'https://example.com/post/123', - ), - ); - - Outbox_Undo::handle_undo( $data, self::$user_id ); - - $this->assertFalse( $fired, 'Action should not fire for non-Follow undo.' ); - - \remove_action( 'activitypub_outbox_undo_follow_sent', $callback ); - } - - /** - * Test outgoing Undo returns early for non-array object. - * - * @covers ::handle_undo - */ - public function test_outgoing_returns_early_for_string_object() { - $data = array( - 'type' => 'Undo', - 'object' => 'https://example.com/activity/123', - ); - - // Should not throw errors. - Outbox_Undo::handle_undo( $data, self::$user_id ); - $this->assertTrue( true ); - } - /** * Test validate_object with various scenarios. * diff --git a/tests/phpunit/tests/includes/handler/class-test-update.php b/tests/phpunit/tests/includes/handler/class-test-update.php index 9979f30118..709b869f45 100644 --- a/tests/phpunit/tests/includes/handler/class-test-update.php +++ b/tests/phpunit/tests/includes/handler/class-test-update.php @@ -11,7 +11,6 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Remote_Actors; use Activitypub\Handler\Update; -use Activitypub\Scheduler\Post; /** * Update Handler Test Class. @@ -74,21 +73,9 @@ public function test_activitypub_inbox_create_fallback() { public function set_up() { parent::set_up(); - // Prevent wp_update_post() from triggering the full outbox chain. - \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - $this->user_id = self::factory()->user->create(); } - /** - * Tear down the test. - */ - public function tear_down() { - \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); - - parent::tear_down(); - } - /** * Test updating an actor with various scenarios. * From ae26c9356b7a608ced6581e553856a1fb37ae204 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 21:12:57 +0100 Subject: [PATCH 051/105] Add get_object_id/get_comment_id helpers and outbox handler tests Introduce get_object_id() and get_comment_id() utility functions to unify ActivityPub ID lookups for WP_Post and WP_Comment objects. Simplify Outbox_Controller::create_item() by replacing the type-specific if/else chain with a single get_object_id() call. Add tests for all outbox handlers (Announce, Delete, Follow, Like, Undo) and the new utility functions. --- includes/functions-comment.php | 11 + includes/functions.php | 21 ++ includes/rest/class-outbox-controller.php | 13 +- .../includes/class-test-functions-comment.php | 44 +++ .../tests/includes/class-test-functions.php | 59 ++++ .../handler/outbox/class-test-announce.php | 140 +++++++++ .../handler/outbox/class-test-delete.php | 204 ++++++++++++ .../handler/outbox/class-test-follow.php | 198 ++++++++++++ .../handler/outbox/class-test-like.php | 140 +++++++++ .../handler/outbox/class-test-undo.php | 291 ++++++++++++++++++ 10 files changed, 1113 insertions(+), 8 deletions(-) create mode 100644 tests/phpunit/tests/includes/handler/outbox/class-test-announce.php create mode 100644 tests/phpunit/tests/includes/handler/outbox/class-test-delete.php create mode 100644 tests/phpunit/tests/includes/handler/outbox/class-test-follow.php create mode 100644 tests/phpunit/tests/includes/handler/outbox/class-test-like.php create mode 100644 tests/phpunit/tests/includes/handler/outbox/class-test-undo.php diff --git a/includes/functions-comment.php b/includes/functions-comment.php index f441a8b5b6..500c2cb92d 100644 --- a/includes/functions-comment.php +++ b/includes/functions-comment.php @@ -32,6 +32,17 @@ function is_comment() { return false; } +/** + * Get the ActivityPub ID of a Comment by the WordPress Comment ID. + * + * @param int|\WP_Comment $id The WordPress Comment ID or object. + * + * @return string The ActivityPub ID (a URL) of the Comment. + */ +function get_comment_id( $id ) { + return Comment::generate_id( $id ); +} + /** * Get the comment from an ActivityPub Object ID. * diff --git a/includes/functions.php b/includes/functions.php index 8a552a1575..e9e01ef4f1 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -9,6 +9,27 @@ namespace Activitypub; +/** + * Get the ActivityPub ID for a WordPress object. + * + * Returns the canonical ActivityPub URI for a WP_Post or WP_Comment. + * + * @param \WP_Post|\WP_Comment $wp_object The WordPress post or comment. + * + * @return string|null The ActivityPub ID (a URL), or null if unsupported type. + */ +function get_object_id( $wp_object ) { + if ( $wp_object instanceof \WP_Post ) { + return get_post_id( $wp_object->ID ); + } + + if ( $wp_object instanceof \WP_Comment ) { + return get_comment_id( $wp_object ); + } + + return null; +} + /** * Convert a string from camelCase to snake_case. * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index f0eb80b44c..ffeae6dc60 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -11,10 +11,10 @@ use Activitypub\Activity\Base_Object; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; -use Activitypub\Comment; use function Activitypub\add_to_outbox; use function Activitypub\get_masked_wp_version; +use function Activitypub\get_object_id; use function Activitypub\get_rest_url_by_path; use function Activitypub\object_to_uri; @@ -414,13 +414,10 @@ public function create_item( $request ) { ); } - // If handler returned a WP_Post or WP_Comment, the scheduler already added it to outbox. - if ( $result instanceof \WP_Post ) { - $object_id = \Activitypub\get_post_id( $result->ID ); - $activity_type = \ucfirst( $data['type'] ?? 'Create' ); - $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type ); - } elseif ( $result instanceof \WP_Comment ) { - $object_id = Comment::generate_id( $result ); + $object_id = get_object_id( $result ); + + if ( $object_id ) { + // Handler returned a WP_Post or WP_Comment; look up its outbox entry. $activity_type = \ucfirst( $data['type'] ?? 'Create' ); $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type ); } elseif ( \is_int( $result ) && $result > 0 ) { diff --git a/tests/phpunit/tests/includes/class-test-functions-comment.php b/tests/phpunit/tests/includes/class-test-functions-comment.php index 554aa0fb34..1a0c7048c7 100644 --- a/tests/phpunit/tests/includes/class-test-functions-comment.php +++ b/tests/phpunit/tests/includes/class-test-functions-comment.php @@ -122,6 +122,50 @@ public function test_object_id_to_comment_duplicate() { $this->assertInstanceOf( \WP_Comment::class, $query_result ); } + /** + * Test get_comment_id returns ActivityPub ID for a comment. + * + * @covers \Activitypub\get_comment_id + */ + public function test_get_comment_id() { + $comment_id = \wp_insert_comment( + array( + 'comment_post_ID' => $this->post_id, + 'comment_content' => 'Test comment for ID.', + 'comment_author_email' => '', + ) + ); + + $result = \Activitypub\get_comment_id( $comment_id ); + + $this->assertIsString( $result ); + $this->assertNotEmpty( $result ); + // Should match Comment::generate_id output. + $this->assertSame( \Activitypub\Comment::generate_id( $comment_id ), $result ); + } + + /** + * Test get_comment_id with a WP_Comment object. + * + * @covers \Activitypub\get_comment_id + */ + public function test_get_comment_id_with_object() { + $comment_id = \wp_insert_comment( + array( + 'comment_post_ID' => $this->post_id, + 'comment_content' => 'Test comment object.', + 'comment_author_email' => '', + ) + ); + + $comment = \get_comment( $comment_id ); + $result = \Activitypub\get_comment_id( $comment ); + + $this->assertIsString( $result ); + $this->assertNotEmpty( $result ); + $this->assertSame( \Activitypub\Comment::generate_id( $comment ), $result ); + } + /** * Test get comment ancestors. * diff --git a/tests/phpunit/tests/includes/class-test-functions.php b/tests/phpunit/tests/includes/class-test-functions.php index f96bd0e76f..2efc8470b6 100644 --- a/tests/phpunit/tests/includes/class-test-functions.php +++ b/tests/phpunit/tests/includes/class-test-functions.php @@ -244,6 +244,65 @@ public function test_esc_hashtag_with_quotes() { $this->assertSame( '#testSTag', $result ); } + /** + * Test get_object_id with a WP_Post. + * + * @covers \Activitypub\get_object_id + */ + public function test_get_object_id_with_post() { + $post_id = self::factory()->post->create( + array( + 'post_author' => 1, + 'post_status' => 'publish', + ) + ); + + $post = \get_post( $post_id ); + $result = \Activitypub\get_object_id( $post ); + + $this->assertIsString( $result ); + $this->assertNotEmpty( $result ); + $this->assertSame( \Activitypub\get_post_id( $post_id ), $result ); + + \wp_delete_post( $post_id, true ); + } + + /** + * Test get_object_id with a WP_Comment. + * + * @covers \Activitypub\get_object_id + */ + public function test_get_object_id_with_comment() { + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + ) + ); + + $comment = \get_comment( $comment_id ); + $result = \Activitypub\get_object_id( $comment ); + + $this->assertIsString( $result ); + $this->assertNotEmpty( $result ); + $this->assertSame( \Activitypub\get_comment_id( $comment ), $result ); + + \wp_delete_comment( $comment_id, true ); + \wp_delete_post( $post_id, true ); + } + + /** + * Test get_object_id with unsupported type returns null. + * + * @covers \Activitypub\get_object_id + */ + public function test_get_object_id_with_unsupported_type() { + $this->assertNull( \Activitypub\get_object_id( 'string' ) ); + $this->assertNull( \Activitypub\get_object_id( 42 ) ); + $this->assertNull( \Activitypub\get_object_id( null ) ); + $this->assertNull( \Activitypub\get_object_id( new \stdClass() ) ); + } + /** * Data provider for seconds_to_iso8601 tests. * diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-announce.php b/tests/phpunit/tests/includes/handler/outbox/class-test-announce.php new file mode 100644 index 0000000000..6850e694fa --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-announce.php @@ -0,0 +1,140 @@ + 'Announce', + 'object' => 'https://example.com/note/123', + ); + + Announce::handle_announce( $data, 1 ); + + $this->assertTrue( $fired, 'activitypub_outbox_announce_sent action should fire.' ); + $this->assertEquals( 'https://example.com/note/123', $hook_url ); + $this->assertEquals( $data, $hook_data ); + $this->assertEquals( 1, $hook_user ); + + \remove_action( 'activitypub_outbox_announce_sent', $callback ); + } + + /** + * Test that handle_announce extracts ID from object array. + * + * @covers ::handle_announce + */ + public function test_handle_announce_with_object_array() { + $hook_url = null; + + $callback = function ( $object_url ) use ( &$hook_url ) { + $hook_url = $object_url; + }; + \add_action( 'activitypub_outbox_announce_sent', $callback, 10, 1 ); + + $data = array( + 'type' => 'Announce', + 'object' => array( + 'type' => 'Note', + 'id' => 'https://example.com/note/456', + ), + ); + + Announce::handle_announce( $data, 1 ); + + $this->assertEquals( 'https://example.com/note/456', $hook_url ); + + \remove_action( 'activitypub_outbox_announce_sent', $callback ); + } + + /** + * Test that handle_announce returns early for empty object. + * + * @covers ::handle_announce + */ + public function test_handle_announce_empty_object() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_announce_sent', $callback ); + + Announce::handle_announce( array( 'type' => 'Announce' ), 1 ); + + $this->assertFalse( $fired, 'Action should not fire for empty object.' ); + + \remove_action( 'activitypub_outbox_announce_sent', $callback ); + } + + /** + * Test that handle_announce returns early for missing object. + * + * @covers ::handle_announce + */ + public function test_handle_announce_missing_object() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_announce_sent', $callback ); + + Announce::handle_announce( + array( + 'type' => 'Announce', + 'object' => '', + ), + 1 + ); + + $this->assertFalse( $fired, 'Action should not fire for empty string object.' ); + + \remove_action( 'activitypub_outbox_announce_sent', $callback ); + } + + /** + * Test that init registers the filter. + * + * @covers ::init + */ + public function test_init_registers_filter() { + Announce::init(); + + $this->assertNotFalse( + \has_filter( 'activitypub_outbox_announce', array( Announce::class, 'handle_announce' ) ), + 'Filter should be registered.' + ); + } +} diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php b/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php new file mode 100644 index 0000000000..f642495a17 --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php @@ -0,0 +1,204 @@ +user_id = self::factory()->user->create(); + } + + /** + * Tear down the test. + */ + public function tear_down() { + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + + parent::tear_down(); + } + + /** + * Test outgoing Delete trashes the post. + * + * @covers ::handle_delete + */ + public function test_handle_delete_trashes_post() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_status' => 'publish', + ) + ); + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Delete', + 'object' => $permalink, + ); + + Delete::handle_delete( $data, $this->user_id ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'trash', $post->post_status ); + } + + /** + * Test outgoing Delete fires action hook. + * + * @covers ::handle_delete + */ + public function test_handle_delete_fires_action() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_status' => 'publish', + ) + ); + $permalink = \get_permalink( $post_id ); + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_deleted_post', $callback ); + + $data = array( + 'type' => 'Delete', + 'object' => $permalink, + ); + + Delete::handle_delete( $data, $this->user_id ); + + $this->assertTrue( $fired, 'activitypub_outbox_deleted_post action should fire.' ); + + \remove_action( 'activitypub_outbox_deleted_post', $callback ); + } + + /** + * Test outgoing Delete skips posts not owned by user. + * + * @covers ::handle_delete + */ + public function test_handle_delete_skips_unowned_post() { + $other_user = self::factory()->user->create(); + $post_id = self::factory()->post->create( + array( + 'post_author' => $other_user, + 'post_status' => 'publish', + ) + ); + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Delete', + 'object' => $permalink, + ); + + Delete::handle_delete( $data, $this->user_id ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'publish', $post->post_status, 'Post should not be trashed by non-owner.' ); + } + + /** + * Test outgoing Delete with empty object does nothing. + * + * @covers ::handle_delete + */ + public function test_handle_delete_empty_object() { + $data = array( + 'type' => 'Delete', + 'object' => '', + ); + + // Should not throw errors. + Delete::handle_delete( $data, $this->user_id ); + $this->assertTrue( true ); + } + + /** + * Test outgoing Delete with non-existent post does nothing. + * + * @covers ::handle_delete + */ + public function test_handle_delete_nonexistent_post() { + $data = array( + 'type' => 'Delete', + 'object' => 'https://example.com/nonexistent-post', + ); + + // Should not throw errors. + Delete::handle_delete( $data, $this->user_id ); + $this->assertTrue( true ); + } + + /** + * Test outgoing Delete with object as array extracts ID. + * + * @covers ::handle_delete + */ + public function test_handle_delete_with_object_array() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + 'post_status' => 'publish', + ) + ); + $permalink = \get_permalink( $post_id ); + + $data = array( + 'type' => 'Delete', + 'object' => array( + 'type' => 'Tombstone', + 'id' => $permalink, + ), + ); + + Delete::handle_delete( $data, $this->user_id ); + + $post = \get_post( $post_id ); + $this->assertEquals( 'trash', $post->post_status ); + } + + /** + * Test that init registers the filter. + * + * @covers ::init + */ + public function test_init_registers_filter() { + Delete::init(); + + $this->assertNotFalse( + \has_filter( 'activitypub_outbox_delete', array( Delete::class, 'handle_delete' ) ), + 'Filter should be registered.' + ); + } +} diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-follow.php b/tests/phpunit/tests/includes/handler/outbox/class-test-follow.php new file mode 100644 index 0000000000..036943eff5 --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-follow.php @@ -0,0 +1,198 @@ +user_id = self::factory()->user->create(); + } + + /** + * Test that handle_follow returns early for empty object. + * + * @covers ::handle_follow + */ + public function test_handle_follow_empty_object() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_follow_sent', $callback ); + + Follow::handle_follow( + array( + 'type' => 'Follow', + 'object' => '', + ), + $this->user_id + ); + + $this->assertFalse( $fired, 'Action should not fire for empty object.' ); + + \remove_action( 'activitypub_outbox_follow_sent', $callback ); + } + + /** + * Test that handle_follow returns early for non-string object. + * + * @covers ::handle_follow + */ + public function test_handle_follow_non_string_object() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_follow_sent', $callback ); + + $data = array( + 'type' => 'Follow', + 'object' => array( 'id' => 'https://example.com/user/1' ), + ); + + Follow::handle_follow( $data, $this->user_id ); + + $this->assertFalse( $fired, 'Action should not fire for non-string object.' ); + + \remove_action( 'activitypub_outbox_follow_sent', $callback ); + } + + /** + * Test that handle_follow adds pending following. + * + * @covers ::handle_follow + */ + public function test_handle_follow_adds_pending() { + $actor_url = 'https://example.com/users/testuser'; + + // Mock the HTTP request that fetch_by_uri makes. + $fake_response = array( + 'type' => 'Person', + 'id' => $actor_url, + 'name' => 'Test User', + 'preferredUsername' => 'testuser', + 'inbox' => $actor_url . '/inbox', + 'outbox' => $actor_url . '/outbox', + 'followers' => $actor_url . '/followers', + 'following' => $actor_url . '/following', + 'publicKey' => array( + 'id' => $actor_url . '#main-key', + 'owner' => $actor_url, + 'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Rdj53hR4AdsiRcqt1Fd\n-----END PUBLIC KEY-----", + ), + ); + + $filter = function () use ( $fake_response ) { + return $fake_response; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $filter ); + + $data = array( + 'type' => 'Follow', + 'object' => $actor_url, + ); + + Follow::handle_follow( $data, $this->user_id ); + + // Check the remote actor was created and pending meta was added. + $remote_actor = Remote_Actors::get_by_uri( $actor_url ); + + if ( ! \is_wp_error( $remote_actor ) ) { + $pending = \get_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, false ); + $this->assertContains( (string) $this->user_id, $pending, 'User should be in pending following.' ); + } + + \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); + } + + /** + * Test that handle_follow does not duplicate pending entries. + * + * @covers ::handle_follow + */ + public function test_handle_follow_does_not_duplicate() { + $actor_url = 'https://example.com/users/nodup'; + + $fake_response = array( + 'type' => 'Person', + 'id' => $actor_url, + 'name' => 'No Dup', + 'preferredUsername' => 'nodup', + 'inbox' => $actor_url . '/inbox', + 'outbox' => $actor_url . '/outbox', + 'followers' => $actor_url . '/followers', + 'following' => $actor_url . '/following', + 'publicKey' => array( + 'id' => $actor_url . '#main-key', + 'owner' => $actor_url, + 'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Rdj53hR4AdsiRcqt1Fd\n-----END PUBLIC KEY-----", + ), + ); + + $filter = function () use ( $fake_response ) { + return $fake_response; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $filter ); + + $data = array( + 'type' => 'Follow', + 'object' => $actor_url, + ); + + // Follow twice. + Follow::handle_follow( $data, $this->user_id ); + Follow::handle_follow( $data, $this->user_id ); + + $remote_actor = Remote_Actors::get_by_uri( $actor_url ); + + if ( ! \is_wp_error( $remote_actor ) ) { + $pending = \get_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, false ); + $count = array_count_values( $pending ); + $this->assertEquals( 1, $count[ (string) $this->user_id ] ?? 0, 'User should only appear once in pending.' ); + } + + \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); + } + + /** + * Test that init registers the filter. + * + * @covers ::init + */ + public function test_init_registers_filter() { + Follow::init(); + + $this->assertNotFalse( + \has_filter( 'activitypub_outbox_follow', array( Follow::class, 'handle_follow' ) ), + 'Filter should be registered.' + ); + } +} diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-like.php b/tests/phpunit/tests/includes/handler/outbox/class-test-like.php new file mode 100644 index 0000000000..037f4d194c --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-like.php @@ -0,0 +1,140 @@ + 'Like', + 'object' => 'https://example.com/note/123', + ); + + Like::handle_like( $data, 1 ); + + $this->assertTrue( $fired, 'activitypub_outbox_like_sent action should fire.' ); + $this->assertEquals( 'https://example.com/note/123', $hook_url ); + $this->assertEquals( $data, $hook_data ); + $this->assertEquals( 1, $hook_user ); + + \remove_action( 'activitypub_outbox_like_sent', $callback ); + } + + /** + * Test that handle_like extracts ID from object array. + * + * @covers ::handle_like + */ + public function test_handle_like_with_object_array() { + $hook_url = null; + + $callback = function ( $object_url ) use ( &$hook_url ) { + $hook_url = $object_url; + }; + \add_action( 'activitypub_outbox_like_sent', $callback, 10, 1 ); + + $data = array( + 'type' => 'Like', + 'object' => array( + 'type' => 'Note', + 'id' => 'https://example.com/note/456', + ), + ); + + Like::handle_like( $data, 1 ); + + $this->assertEquals( 'https://example.com/note/456', $hook_url ); + + \remove_action( 'activitypub_outbox_like_sent', $callback ); + } + + /** + * Test that handle_like returns early for empty object. + * + * @covers ::handle_like + */ + public function test_handle_like_empty_object() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_like_sent', $callback ); + + Like::handle_like( array( 'type' => 'Like' ), 1 ); + + $this->assertFalse( $fired, 'Action should not fire for missing object.' ); + + \remove_action( 'activitypub_outbox_like_sent', $callback ); + } + + /** + * Test that handle_like returns early for empty string object. + * + * @covers ::handle_like + */ + public function test_handle_like_empty_string_object() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_like_sent', $callback ); + + Like::handle_like( + array( + 'type' => 'Like', + 'object' => '', + ), + 1 + ); + + $this->assertFalse( $fired, 'Action should not fire for empty string object.' ); + + \remove_action( 'activitypub_outbox_like_sent', $callback ); + } + + /** + * Test that init registers the filter. + * + * @covers ::init + */ + public function test_init_registers_filter() { + Like::init(); + + $this->assertNotFalse( + \has_filter( 'activitypub_outbox_like', array( Like::class, 'handle_like' ) ), + 'Filter should be registered.' + ); + } +} diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-undo.php b/tests/phpunit/tests/includes/handler/outbox/class-test-undo.php new file mode 100644 index 0000000000..a1981470b8 --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-undo.php @@ -0,0 +1,291 @@ +user_id = self::factory()->user->create(); + } + + /** + * Test that handle_undo removes following relationship. + * + * @covers ::handle_undo + */ + public function test_handle_undo_follow_removes_following() { + $actor_url = 'https://example.com/users/unfollow-test'; + + // Mock the HTTP request. + $fake_response = array( + 'type' => 'Person', + 'id' => $actor_url, + 'name' => 'Unfollow Test', + 'preferredUsername' => 'unfollowtest', + 'inbox' => $actor_url . '/inbox', + 'outbox' => $actor_url . '/outbox', + 'followers' => $actor_url . '/followers', + 'following' => $actor_url . '/following', + 'publicKey' => array( + 'id' => $actor_url . '#main-key', + 'owner' => $actor_url, + 'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Rdj53hR4AdsiRcqt1Fd\n-----END PUBLIC KEY-----", + ), + ); + + $filter = function () use ( $fake_response ) { + return $fake_response; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $filter ); + + // Create the remote actor and add following meta. + $remote_actor = Remote_Actors::fetch_by_uri( $actor_url ); + + if ( \is_wp_error( $remote_actor ) ) { + $this->fail( 'Could not create remote actor: ' . $remote_actor->get_error_message() ); + } + + \add_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, (string) $this->user_id ); + + // Verify following exists. + $following = \get_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, false ); + $this->assertContains( (string) $this->user_id, $following, 'User should be in following before undo.' ); + + // Send Undo Follow. + $data = array( + 'type' => 'Undo', + 'object' => array( + 'type' => 'Follow', + 'object' => $actor_url, + ), + ); + + Undo::handle_undo( $data, $this->user_id ); + + // Verify following was removed. + $following = \get_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, false ); + $this->assertNotContains( (string) $this->user_id, $following, 'User should be removed from following.' ); + + \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); + } + + /** + * Test that handle_undo removes pending following. + * + * @covers ::handle_undo + */ + public function test_handle_undo_follow_removes_pending() { + $actor_url = 'https://example.com/users/pending-undo'; + + $fake_response = array( + 'type' => 'Person', + 'id' => $actor_url, + 'name' => 'Pending Undo', + 'preferredUsername' => 'pendingundo', + 'inbox' => $actor_url . '/inbox', + 'outbox' => $actor_url . '/outbox', + 'followers' => $actor_url . '/followers', + 'following' => $actor_url . '/following', + 'publicKey' => array( + 'id' => $actor_url . '#main-key', + 'owner' => $actor_url, + 'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Rdj53hR4AdsiRcqt1Fd\n-----END PUBLIC KEY-----", + ), + ); + + $filter = function () use ( $fake_response ) { + return $fake_response; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $filter ); + + $remote_actor = Remote_Actors::fetch_by_uri( $actor_url ); + + if ( \is_wp_error( $remote_actor ) ) { + $this->fail( 'Could not create remote actor: ' . $remote_actor->get_error_message() ); + } + + \add_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, (string) $this->user_id ); + + // Verify pending exists. + $pending = \get_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, false ); + $this->assertContains( (string) $this->user_id, $pending, 'User should be in pending before undo.' ); + + $data = array( + 'type' => 'Undo', + 'object' => array( + 'type' => 'Follow', + 'object' => $actor_url, + ), + ); + + Undo::handle_undo( $data, $this->user_id ); + + $pending = \get_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, false ); + $this->assertNotContains( (string) $this->user_id, $pending, 'User should be removed from pending.' ); + + \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); + } + + /** + * Test that handle_undo fires action hook on success. + * + * @covers ::handle_undo + */ + public function test_handle_undo_fires_action() { + $actor_url = 'https://example.com/users/undo-action-test'; + + $fake_response = array( + 'type' => 'Person', + 'id' => $actor_url, + 'name' => 'Undo Action', + 'preferredUsername' => 'undoaction', + 'inbox' => $actor_url . '/inbox', + 'outbox' => $actor_url . '/outbox', + 'followers' => $actor_url . '/followers', + 'following' => $actor_url . '/following', + 'publicKey' => array( + 'id' => $actor_url . '#main-key', + 'owner' => $actor_url, + 'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Rdj53hR4AdsiRcqt1Fd\n-----END PUBLIC KEY-----", + ), + ); + + $filter = function () use ( $fake_response ) { + return $fake_response; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $filter ); + + $remote_actor = Remote_Actors::fetch_by_uri( $actor_url ); + + if ( \is_wp_error( $remote_actor ) ) { + $this->fail( 'Could not create remote actor.' ); + } + + \add_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, (string) $this->user_id ); + + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_undo_follow_sent', $callback ); + + $data = array( + 'type' => 'Undo', + 'object' => array( + 'type' => 'Follow', + 'object' => $actor_url, + ), + ); + + Undo::handle_undo( $data, $this->user_id ); + + $this->assertTrue( $fired, 'activitypub_outbox_undo_follow_sent action should fire.' ); + + \remove_action( 'activitypub_outbox_undo_follow_sent', $callback ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); + } + + /** + * Test that handle_undo ignores non-Follow types. + * + * @covers ::handle_undo + */ + public function test_handle_undo_ignores_non_follow() { + $fired = false; + + $callback = function () use ( &$fired ) { + $fired = true; + }; + \add_action( 'activitypub_outbox_undo_follow_sent', $callback ); + + $data = array( + 'type' => 'Undo', + 'object' => array( + 'type' => 'Like', + 'object' => 'https://example.com/note/123', + ), + ); + + Undo::handle_undo( $data, $this->user_id ); + + $this->assertFalse( $fired, 'Action should not fire for non-Follow undo.' ); + + \remove_action( 'activitypub_outbox_undo_follow_sent', $callback ); + } + + /** + * Test that handle_undo returns early for non-array object. + * + * @covers ::handle_undo + */ + public function test_handle_undo_non_array_object() { + $data = array( + 'type' => 'Undo', + 'object' => 'https://example.com/follow/123', + ); + + // Should not throw errors. + Undo::handle_undo( $data, $this->user_id ); + $this->assertTrue( true ); + } + + /** + * Test that handle_undo returns early for empty target. + * + * @covers ::handle_undo + */ + public function test_handle_undo_empty_target() { + $data = array( + 'type' => 'Undo', + 'object' => array( + 'type' => 'Follow', + 'object' => '', + ), + ); + + // Should not throw errors. + Undo::handle_undo( $data, $this->user_id ); + $this->assertTrue( true ); + } + + /** + * Test that init registers the filter. + * + * @covers ::init + */ + public function test_init_registers_filter() { + Undo::init(); + + $this->assertNotFalse( + \has_filter( 'activitypub_outbox_undo', array( Undo::class, 'handle_undo' ) ), + 'Filter should be registered.' + ); + } +} From 17b2bb293f668a5f541294a84a25712f6a12bc66 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 21:15:12 +0100 Subject: [PATCH 052/105] Add SWICG ActivityPub API spec to federation resources --- .claude/skills/federation/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/skills/federation/SKILL.md b/.claude/skills/federation/SKILL.md index 036515ec4a..50b84958aa 100644 --- a/.claude/skills/federation/SKILL.md +++ b/.claude/skills/federation/SKILL.md @@ -311,6 +311,7 @@ curl https://site.com/.well-known/nodeinfo ## Resources - [ActivityPub Spec](https://www.w3.org/TR/activitypub/) +- [ActivityPub API (C2S)](https://github.com/swicg/activitypub-api) — SWICG task force specs for Client-to-Server API: OAuth profile, feature discovery, media upload, CORS, collections/filtering, and more. - [ActivityStreams Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/) - [Project FEDERATION.md](../../../FEDERATION.md) - [FEPs Repository](https://codeberg.org/fediverse/fep) \ No newline at end of file From 8a30e805c07ea4e36af0fb897245fbf421d283c0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 21:41:20 +0100 Subject: [PATCH 053/105] Support comment deletion in Outbox Delete Add comment deletion support to the Outbox Delete handler and refactor deletion flow. Import url_to_commentid and split logic into maybe_delete_comment and maybe_delete_post helpers; handle_delete now tries comments first then posts, returns the deleted object on success, null for empty object input, or false when nothing was deleted, and fires a unified activitypub_outbox_handled_delete action with the deleted object, activity data, and user ID. Update tests to expect return values, use the new action, and add coverage for trashing comments and skipping comments not owned by the acting user. --- includes/handler/outbox/class-delete.php | 106 +++++++++++++----- .../handler/outbox/class-test-delete.php | 83 ++++++++++++-- 2 files changed, 147 insertions(+), 42 deletions(-) diff --git a/includes/handler/outbox/class-delete.php b/includes/handler/outbox/class-delete.php index ad6b688cb3..ed8218dfa0 100644 --- a/includes/handler/outbox/class-delete.php +++ b/includes/handler/outbox/class-delete.php @@ -10,6 +10,7 @@ use Activitypub\Collection\Posts; use function Activitypub\object_to_uri; +use function Activitypub\url_to_commentid; /** * Handle outgoing Delete activities. @@ -25,25 +26,84 @@ public static function init() { /** * Handle outgoing "Delete" activities from local actors. * - * Deletes a WordPress post. + * Deletes a WordPress post or comment. * * @param array $data The activity data array. * @param int $user_id The user ID. + * + * @return \WP_Post|\WP_Comment|null The deleted object, or null on failure. */ public static function handle_delete( $data, $user_id = null ) { - $object = $data['object'] ?? ''; - - // Get the object ID (can be a string URL or an object with an id). - $object_id = object_to_uri( $object ); + $object_id = object_to_uri( $data['object'] ?? '' ); if ( empty( $object_id ) ) { - return; + return null; + } + + // Try to delete a comment first, then fall back to a post. + $result = self::maybe_delete_comment( $object_id, $user_id ); + + if ( ! $result ) { + $result = self::maybe_delete_post( $object_id, $user_id ); + } + + if ( $result ) { + /** + * Fires after content has been deleted via an outgoing Delete activity. + * + * @param \WP_Post|\WP_Comment $result The deleted object. + * @param array $data The activity data. + * @param int $user_id The user ID. + */ + \do_action( 'activitypub_outbox_handled_delete', $result, $data, $user_id ); + } + + return $result; + } + + /** + * Try to delete a comment by its ActivityPub ID. + * + * @param string $object_id The ActivityPub object ID (URL). + * @param int $user_id The user ID. + * + * @return \WP_Comment|false The deleted comment, or false on failure. + */ + private static function maybe_delete_comment( $object_id, $user_id ) { + $comment_id = url_to_commentid( $object_id ); + + if ( ! $comment_id ) { + return false; + } + + $comment = \get_comment( $comment_id ); + + if ( ! $comment ) { + return false; + } + + // Verify the user owns this comment. + if ( (int) $comment->user_id !== $user_id && $user_id > 0 ) { + return false; + } + + if ( \wp_trash_comment( $comment ) ) { + return $comment; } - /* - * Find the post by its ActivityPub ID. - * First try to find a local post by permalink (for C2S-created posts). - */ + return false; + } + + /** + * Try to delete a post by its ActivityPub ID. + * + * @param string $object_id The ActivityPub object ID (URL). + * @param int $user_id The user ID. + * + * @return \WP_Post|false The deleted post, or false on failure. + */ + private static function maybe_delete_post( $object_id, $user_id ) { + // Try to find a local post by permalink. $post_id = \url_to_postid( $object_id ); $post = $post_id ? \get_post( $post_id ) : null; @@ -53,32 +113,18 @@ public static function handle_delete( $data, $user_id = null ) { } if ( ! $post instanceof \WP_Post ) { - return; + return false; } - /* - * Verify the user owns this post. - * The blog actor ($user_id === 0) can delete any post since it - * represents the site itself. - */ + // Verify the user owns this post. if ( (int) $post->post_author !== $user_id && $user_id > 0 ) { - return; + return false; } - // Trash the post (use wp_delete_post with false to move to trash). - $result = \wp_trash_post( $post->ID ); - - if ( ! $result ) { - return; + if ( \wp_trash_post( $post->ID ) ) { + return $post; } - /** - * Fires after a post has been deleted from an outgoing Delete activity. - * - * @param int $post_id The deleted post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - */ - \do_action( 'activitypub_outbox_deleted_post', $post->ID, $data, $user_id ); + return false; } } diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php b/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php index f642495a17..9a2c734515 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php @@ -64,10 +64,11 @@ public function test_handle_delete_trashes_post() { 'object' => $permalink, ); - Delete::handle_delete( $data, $this->user_id ); + $result = Delete::handle_delete( $data, $this->user_id ); $post = \get_post( $post_id ); $this->assertEquals( 'trash', $post->post_status ); + $this->assertInstanceOf( \WP_Post::class, $result ); } /** @@ -88,7 +89,7 @@ public function test_handle_delete_fires_action() { $callback = function () use ( &$fired ) { $fired = true; }; - \add_action( 'activitypub_outbox_deleted_post', $callback ); + \add_action( 'activitypub_outbox_handled_delete', $callback ); $data = array( 'type' => 'Delete', @@ -97,9 +98,9 @@ public function test_handle_delete_fires_action() { Delete::handle_delete( $data, $this->user_id ); - $this->assertTrue( $fired, 'activitypub_outbox_deleted_post action should fire.' ); + $this->assertTrue( $fired, 'activitypub_outbox_handled_delete action should fire.' ); - \remove_action( 'activitypub_outbox_deleted_post', $callback ); + \remove_action( 'activitypub_outbox_handled_delete', $callback ); } /** @@ -139,25 +140,23 @@ public function test_handle_delete_empty_object() { 'object' => '', ); - // Should not throw errors. - Delete::handle_delete( $data, $this->user_id ); - $this->assertTrue( true ); + $result = Delete::handle_delete( $data, $this->user_id ); + $this->assertNull( $result ); } /** - * Test outgoing Delete with non-existent post does nothing. + * Test outgoing Delete with non-existent object does nothing. * * @covers ::handle_delete */ - public function test_handle_delete_nonexistent_post() { + public function test_handle_delete_nonexistent_object() { $data = array( 'type' => 'Delete', 'object' => 'https://example.com/nonexistent-post', ); - // Should not throw errors. - Delete::handle_delete( $data, $this->user_id ); - $this->assertTrue( true ); + $result = Delete::handle_delete( $data, $this->user_id ); + $this->assertFalse( $result ); } /** @@ -188,6 +187,66 @@ public function test_handle_delete_with_object_array() { $this->assertEquals( 'trash', $post->post_status ); } + /** + * Test outgoing Delete trashes a comment. + * + * @covers ::handle_delete + */ + public function test_handle_delete_trashes_comment() { + $post_id = self::factory()->post->create( + array( + 'post_author' => $this->user_id, + ) + ); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'user_id' => $this->user_id, + ) + ); + + $comment_url = \add_query_arg( 'c', $comment_id, \trailingslashit( \home_url() ) ); + + $data = array( + 'type' => 'Delete', + 'object' => $comment_url, + ); + + $result = Delete::handle_delete( $data, $this->user_id ); + + $comment = \get_comment( $comment_id ); + $this->assertEquals( 'trash', $comment->comment_approved ); + $this->assertInstanceOf( \WP_Comment::class, $result ); + } + + /** + * Test outgoing Delete skips comments not owned by user. + * + * @covers ::handle_delete + */ + public function test_handle_delete_skips_unowned_comment() { + $other_user = self::factory()->user->create(); + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'user_id' => $other_user, + ) + ); + + $comment_url = \add_query_arg( 'c', $comment_id, \trailingslashit( \home_url() ) ); + + $data = array( + 'type' => 'Delete', + 'object' => $comment_url, + ); + + Delete::handle_delete( $data, $this->user_id ); + + $comment = \get_comment( $comment_id ); + $this->assertEquals( '1', $comment->comment_approved, 'Comment should not be trashed by non-owner.' ); + } + /** * Test that init registers the filter. * From 8e5060f7a33d8fb6a71827418b047110162cecf7 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 12 Feb 2026 21:59:34 +0100 Subject: [PATCH 054/105] Delegate Undo Follow to unfollow() and revert Outbox::undo() Use the unfollow() helper in the Undo handler for Follow activities, which handles side effects and triggers Outbox::undo() internally. Revert Outbox::undo() to its original state (no side-effect processing). --- includes/handler/outbox/class-undo.php | 59 +++-- .../handler/outbox/class-test-undo.php | 229 +++++++----------- 2 files changed, 121 insertions(+), 167 deletions(-) diff --git a/includes/handler/outbox/class-undo.php b/includes/handler/outbox/class-undo.php index 14ce724316..2c71ecb025 100644 --- a/includes/handler/outbox/class-undo.php +++ b/includes/handler/outbox/class-undo.php @@ -7,8 +7,10 @@ namespace Activitypub\Handler\Outbox; -use Activitypub\Collection\Following; -use Activitypub\Collection\Remote_Actors; +use Activitypub\Collection\Outbox as Outbox_Collection; + +use function Activitypub\object_to_uri; +use function Activitypub\unfollow; /** * Handle outgoing Undo activities. @@ -24,50 +26,43 @@ public static function init() { /** * Handle outgoing "Undo" activities from local actors. * - * Handles Undo Follow (unfollow) activities. + * Resolves the referenced activity from the outbox and delegates + * to the appropriate collection method to reverse its side effects + * and create the Undo activity. * * @param array $data The activity data array. * @param int $user_id The user ID. + * + * @return int|\WP_Error The undo outbox item ID, or WP_Error on failure. */ public static function handle_undo( $data, $user_id = null ) { - $object = $data['object'] ?? array(); + $id = object_to_uri( $data['object'] ?? '' ); - if ( ! \is_array( $object ) ) { - return; + if ( empty( $id ) ) { + return $data; } - $type = $object['type'] ?? ''; + $outbox_item = Outbox_Collection::get_by_guid( $id ); - // Only handle Undo Follow for now. - if ( 'Follow' !== $type ) { - return; + if ( \is_wp_error( $outbox_item ) ) { + return $data; } - // Get the target actor from the original Follow activity. - $target = $object['object'] ?? ''; - - if ( empty( $target ) || ! \is_string( $target ) ) { - return; - } + $activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); - // Get the remote actor. - $remote_actor = Remote_Actors::get_by_uri( $target ); + switch ( $activity_type ) { + case 'Follow': + $stored = \json_decode( $outbox_item->post_content, true ); + $target = object_to_uri( $stored['object'] ?? '' ); - if ( \is_wp_error( $remote_actor ) ) { - return; - } + if ( $target ) { + return unfollow( $target, $user_id ); + } - // Remove following relationship. - \delete_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, $user_id ); - \delete_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, $user_id ); + return $data; - /** - * Fires after an outgoing Undo Follow activity has been processed. - * - * @param int $remote_actor_id The remote actor post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - */ - \do_action( 'activitypub_outbox_undo_follow_sent', $remote_actor->ID, $data, $user_id ); + default: + return Outbox_Collection::undo( $outbox_item ); + } } } diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-undo.php b/tests/phpunit/tests/includes/handler/outbox/class-test-undo.php index a1981470b8..aae0f74539 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-undo.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-undo.php @@ -8,8 +8,10 @@ namespace Activitypub\Tests\Handler\Outbox; use Activitypub\Collection\Following; +use Activitypub\Collection\Outbox; use Activitypub\Collection\Remote_Actors; use Activitypub\Handler\Outbox\Undo; +use Activitypub\Scheduler\Post; /** * Test class for Outbox Undo Handler. @@ -31,23 +33,66 @@ class Test_Undo extends \WP_UnitTestCase { public function set_up() { parent::set_up(); + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + $this->user_id = self::factory()->user->create(); } /** - * Test that handle_undo removes following relationship. + * Tear down the test. + */ + public function tear_down() { + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + + parent::tear_down(); + } + + /** + * Create a fake outbox Follow item and return its GUID. * - * @covers ::handle_undo + * @param string $target_url The follow target actor URL. + * @return string The outbox item GUID. */ - public function test_handle_undo_follow_removes_following() { - $actor_url = 'https://example.com/users/unfollow-test'; + private function create_outbox_follow( $target_url ) { + $activity = array( + 'type' => 'Follow', + 'object' => $target_url, + ); + + $guid = 'http://example.org/outbox/follow-' . \wp_generate_password( 8, false ); + + $post_id = \wp_insert_post( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_title' => '[Follow] Test', + 'post_content' => \wp_json_encode( $activity ), + 'post_author' => $this->user_id, + 'post_status' => 'publish', + 'guid' => $guid, + 'meta_input' => array( + '_activitypub_activity_type' => 'Follow', + '_activitypub_activity_actor' => 'user', + '_activitypub_object_id' => $target_url, + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); - // Mock the HTTP request. + return \get_the_guid( $post_id ); + } + + /** + * Helper to create a mock remote actor. + * + * @param string $actor_url The actor URL. + * @return \WP_Post The remote actor post. + */ + private function create_remote_actor( $actor_url ) { $fake_response = array( 'type' => 'Person', 'id' => $actor_url, - 'name' => 'Unfollow Test', - 'preferredUsername' => 'unfollowtest', + 'name' => 'Test Actor', + 'preferredUsername' => 'testactor', 'inbox' => $actor_url . '/inbox', 'outbox' => $actor_url . '/outbox', 'followers' => $actor_url . '/followers', @@ -64,26 +109,36 @@ public function test_handle_undo_follow_removes_following() { }; \add_filter( 'activitypub_pre_http_get_remote_object', $filter ); - // Create the remote actor and add following meta. $remote_actor = Remote_Actors::fetch_by_uri( $actor_url ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); + if ( \is_wp_error( $remote_actor ) ) { $this->fail( 'Could not create remote actor: ' . $remote_actor->get_error_message() ); } + return $remote_actor; + } + + /** + * Test that handle_undo removes following relationship. + * + * @covers ::handle_undo + */ + public function test_handle_undo_follow_removes_following() { + $actor_url = 'https://example.com/users/unfollow-test'; + $remote_actor = $this->create_remote_actor( $actor_url ); + $follow_guid = $this->create_outbox_follow( $actor_url ); + \add_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, (string) $this->user_id ); // Verify following exists. $following = \get_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, false ); $this->assertContains( (string) $this->user_id, $following, 'User should be in following before undo.' ); - // Send Undo Follow. $data = array( 'type' => 'Undo', - 'object' => array( - 'type' => 'Follow', - 'object' => $actor_url, - ), + 'object' => $follow_guid, ); Undo::handle_undo( $data, $this->user_id ); @@ -91,8 +146,6 @@ public function test_handle_undo_follow_removes_following() { // Verify following was removed. $following = \get_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, false ); $this->assertNotContains( (string) $this->user_id, $following, 'User should be removed from following.' ); - - \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); } /** @@ -101,34 +154,9 @@ public function test_handle_undo_follow_removes_following() { * @covers ::handle_undo */ public function test_handle_undo_follow_removes_pending() { - $actor_url = 'https://example.com/users/pending-undo'; - - $fake_response = array( - 'type' => 'Person', - 'id' => $actor_url, - 'name' => 'Pending Undo', - 'preferredUsername' => 'pendingundo', - 'inbox' => $actor_url . '/inbox', - 'outbox' => $actor_url . '/outbox', - 'followers' => $actor_url . '/followers', - 'following' => $actor_url . '/following', - 'publicKey' => array( - 'id' => $actor_url . '#main-key', - 'owner' => $actor_url, - 'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Rdj53hR4AdsiRcqt1Fd\n-----END PUBLIC KEY-----", - ), - ); - - $filter = function () use ( $fake_response ) { - return $fake_response; - }; - \add_filter( 'activitypub_pre_http_get_remote_object', $filter ); - - $remote_actor = Remote_Actors::fetch_by_uri( $actor_url ); - - if ( \is_wp_error( $remote_actor ) ) { - $this->fail( 'Could not create remote actor: ' . $remote_actor->get_error_message() ); - } + $actor_url = 'https://example.com/users/pending-undo'; + $remote_actor = $this->create_remote_actor( $actor_url ); + $follow_guid = $this->create_outbox_follow( $actor_url ); \add_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, (string) $this->user_id ); @@ -138,141 +166,72 @@ public function test_handle_undo_follow_removes_pending() { $data = array( 'type' => 'Undo', - 'object' => array( - 'type' => 'Follow', - 'object' => $actor_url, - ), + 'object' => $follow_guid, ); Undo::handle_undo( $data, $this->user_id ); $pending = \get_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, false ); $this->assertNotContains( (string) $this->user_id, $pending, 'User should be removed from pending.' ); - - \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); } /** - * Test that handle_undo fires action hook on success. + * Test that handle_undo returns data for unknown outbox item. * * @covers ::handle_undo */ - public function test_handle_undo_fires_action() { - $actor_url = 'https://example.com/users/undo-action-test'; - - $fake_response = array( - 'type' => 'Person', - 'id' => $actor_url, - 'name' => 'Undo Action', - 'preferredUsername' => 'undoaction', - 'inbox' => $actor_url . '/inbox', - 'outbox' => $actor_url . '/outbox', - 'followers' => $actor_url . '/followers', - 'following' => $actor_url . '/following', - 'publicKey' => array( - 'id' => $actor_url . '#main-key', - 'owner' => $actor_url, - 'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Rdj53hR4AdsiRcqt1Fd\n-----END PUBLIC KEY-----", - ), - ); - - $filter = function () use ( $fake_response ) { - return $fake_response; - }; - \add_filter( 'activitypub_pre_http_get_remote_object', $filter ); - - $remote_actor = Remote_Actors::fetch_by_uri( $actor_url ); - - if ( \is_wp_error( $remote_actor ) ) { - $this->fail( 'Could not create remote actor.' ); - } - - \add_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, (string) $this->user_id ); - - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_undo_follow_sent', $callback ); - + public function test_handle_undo_unknown_guid_returns_data() { $data = array( 'type' => 'Undo', - 'object' => array( - 'type' => 'Follow', - 'object' => $actor_url, - ), + 'object' => 'https://example.com/unknown-activity/999', ); - Undo::handle_undo( $data, $this->user_id ); - - $this->assertTrue( $fired, 'activitypub_outbox_undo_follow_sent action should fire.' ); + $result = Undo::handle_undo( $data, $this->user_id ); - \remove_action( 'activitypub_outbox_undo_follow_sent', $callback ); - \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); + $this->assertSame( $data, $result, 'Should return original data for unknown GUID.' ); } /** - * Test that handle_undo ignores non-Follow types. + * Test that handle_undo returns data for empty object. * * @covers ::handle_undo */ - public function test_handle_undo_ignores_non_follow() { - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_undo_follow_sent', $callback ); - + public function test_handle_undo_empty_object() { $data = array( 'type' => 'Undo', - 'object' => array( - 'type' => 'Like', - 'object' => 'https://example.com/note/123', - ), + 'object' => '', ); - Undo::handle_undo( $data, $this->user_id ); + $result = Undo::handle_undo( $data, $this->user_id ); - $this->assertFalse( $fired, 'Action should not fire for non-Follow undo.' ); - - \remove_action( 'activitypub_outbox_undo_follow_sent', $callback ); + $this->assertSame( $data, $result, 'Should return original data for empty object.' ); } /** - * Test that handle_undo returns early for non-array object. + * Test that handle_undo resolves object from array with id. * * @covers ::handle_undo */ - public function test_handle_undo_non_array_object() { - $data = array( - 'type' => 'Undo', - 'object' => 'https://example.com/follow/123', - ); + public function test_handle_undo_resolves_object_array() { + $actor_url = 'https://example.com/users/undo-array-test'; + $remote_actor = $this->create_remote_actor( $actor_url ); + $follow_guid = $this->create_outbox_follow( $actor_url ); - // Should not throw errors. - Undo::handle_undo( $data, $this->user_id ); - $this->assertTrue( true ); - } + \add_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, (string) $this->user_id ); - /** - * Test that handle_undo returns early for empty target. - * - * @covers ::handle_undo - */ - public function test_handle_undo_empty_target() { + // Pass object as array with id (object_to_uri resolves this). $data = array( 'type' => 'Undo', 'object' => array( - 'type' => 'Follow', - 'object' => '', + 'id' => $follow_guid, + 'type' => 'Follow', ), ); - // Should not throw errors. Undo::handle_undo( $data, $this->user_id ); - $this->assertTrue( true ); + + $following = \get_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, false ); + $this->assertNotContains( (string) $this->user_id, $following, 'User should be removed from following.' ); } /** From f553cbff6299ff82982bb61a615c53284e195ef5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 10:45:11 +0100 Subject: [PATCH 055/105] Support Bearer token auth in introspection permission check Public OAuth clients (no client_secret) authenticate introspection requests via Bearer token. The rest_authentication_errors filter should handle this, but can fail if the Authorization header is stripped by the web server or another filter interferes. Add explicit Bearer token validation as a fallback in introspect_permissions_check. --- includes/rest/class-oauth-controller.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index f3624d316d..5802cea1a0 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -361,6 +361,18 @@ public function introspect_permissions_check() { return true; } + // Support Bearer token auth for public OAuth clients. + $token = OAuth_Server::get_bearer_token(); + + if ( $token ) { + $validated = Token::validate( $token ); + + if ( ! \is_wp_error( $validated ) ) { + \wp_set_current_user( $validated->get_user_id() ); + return true; + } + } + return new \WP_Error( 'activitypub_unauthorized', \__( 'Authentication required.', 'activitypub' ), From 2abe7a24f18ce1ce68e1f657eb26283cf7fb8012 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 11:00:20 +0100 Subject: [PATCH 056/105] Fall back to blog actor when user actors are disabled When user actors are disabled in plugin settings, Actors::get_by_id() returns a WP_Error for the authenticated user, causing `me` to be null in both token and introspection responses. This breaks C2S actor discovery since no method can find the actor URI. Fall back to the blog actor so C2S clients always get a usable `me`. --- includes/oauth/class-token.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index ed5612d603..f30cd34ed9 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -126,8 +126,12 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D self::track_user( $user_id ); // Get the actor URI for the 'me' parameter (IndieAuth convention). + // Fall back to blog actor when user actors are disabled. $actor = Actors::get_by_id( $user_id ); - $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null; + if ( \is_wp_error( $actor ) ) { + $actor = Actors::get_by_id( Actors::BLOG_USER_ID ); + } + $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null; return array( 'access_token' => $access_token, @@ -695,8 +699,12 @@ public static function introspect( $token ) { $user = \get_userdata( $user_id ); // Get the actor URI for the 'me' parameter (IndieAuth convention). + // Fall back to blog actor when user actors are disabled. $actor = Actors::get_by_id( $user_id ); - $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null; + if ( \is_wp_error( $actor ) ) { + $actor = Actors::get_by_id( Actors::BLOG_USER_ID ); + } + $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null; return array( 'active' => true, From 69ecdba7c096f380b4658ad89a56b6ed73a2b5e3 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 11:11:27 +0100 Subject: [PATCH 057/105] Fall back to post when C2S reply target is not local When a C2S client sends a Create with inReplyTo pointing to a remote URL, create_comment() fails because the target can't be resolved to a local post. Instead of returning false (403), fall back to creating a regular post so the reply gets added to the outbox and federated with the inReplyTo preserved. --- includes/handler/outbox/class-create.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/includes/handler/outbox/class-create.php b/includes/handler/outbox/class-create.php index 1157933edb..86b1a153dc 100644 --- a/includes/handler/outbox/class-create.php +++ b/includes/handler/outbox/class-create.php @@ -56,7 +56,13 @@ public static function handle_create( $activity, $user_id = null, $visibility = } if ( is_activity_reply( $activity ) ) { - return self::create_comment( $activity, $user_id ); + $result = self::create_comment( $activity, $user_id ); + + // If the reply target is not found locally (e.g. remote post), + // fall back to creating a post so it gets federated with inReplyTo. + if ( false !== $result ) { + return $result; + } } // TODO: Handle quotes differently. From 290efea01eb6c10ced047f25af2568cc4e6c9509 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 11:17:54 +0100 Subject: [PATCH 058/105] Revert "Fall back to post when C2S reply target is not local" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing code in add_comment() already checks for local comments by source_url/source_id meta via url_to_commentid(). The fallback to creating a post was the wrong approach — the reply target lookup chain should work if the remote post was federated locally. --- includes/handler/outbox/class-create.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/includes/handler/outbox/class-create.php b/includes/handler/outbox/class-create.php index 86b1a153dc..1157933edb 100644 --- a/includes/handler/outbox/class-create.php +++ b/includes/handler/outbox/class-create.php @@ -56,13 +56,7 @@ public static function handle_create( $activity, $user_id = null, $visibility = } if ( is_activity_reply( $activity ) ) { - $result = self::create_comment( $activity, $user_id ); - - // If the reply target is not found locally (e.g. remote post), - // fall back to creating a post so it gets federated with inReplyTo. - if ( false !== $result ) { - return $result; - } + return self::create_comment( $activity, $user_id ); } // TODO: Handle quotes differently. From 757b6b9061cbfe83d93e6ed554611c5291deb6f4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 11:22:27 +0100 Subject: [PATCH 059/105] Add outbox entry directly when scheduler skips C2S activities When user actors are disabled, should_comment_be_federated() returns false and the scheduler never calls add_to_outbox(). The C2S outbox controller then can't find an outbox entry and returns a 500 error, even though the comment was created successfully. Fall back to calling add_to_outbox() directly from the controller. This function already handles the blog user fallback when user actors are disabled. --- includes/rest/class-outbox-controller.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index ffeae6dc60..2b8e1cd014 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -420,6 +420,12 @@ public function create_item( $request ) { // Handler returned a WP_Post or WP_Comment; look up its outbox entry. $activity_type = \ucfirst( $data['type'] ?? 'Create' ); $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type ); + + // If the scheduler didn't create an outbox entry (e.g. user actors + // disabled), add directly so C2S activities are always federated. + if ( ! $outbox_item ) { + $outbox_item = \get_post( add_to_outbox( $result, $activity_type, $user_id, $visibility ) ); + } } elseif ( \is_int( $result ) && $result > 0 ) { // Handler returned an outbox post ID directly. $outbox_item = \get_post( $result ); From 3f735784d3760a0e0b6cfeebc478bd96c7554ea4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 11:24:09 +0100 Subject: [PATCH 060/105] Fall back to blog user in should_be_federated() for comments When user actors are disabled, should_be_federated() blocked comment federation entirely because user_can_activitypub() returned false. This prevented the scheduler from adding C2S comments to the outbox. Apply the same blog user fallback that add_to_outbox() already uses: if the comment author has no ActivityPub profile, fall back to the blog user instead of refusing to federate. Also reverts the outbox controller hack from the previous commit. --- includes/class-comment.php | 5 +++-- includes/rest/class-outbox-controller.php | 6 ------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/includes/class-comment.php b/includes/class-comment.php index ebb31483a8..e11e9fb1a0 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -289,8 +289,9 @@ public static function should_be_federated( $comment ) { return false; } - if ( is_single_user() && \user_can( $user_id, 'activitypub' ) ) { - // On a single user site, comments by users with the `activitypub` capability will be federated as the blog user. + // Fall back to the blog user when the comment author has no + // individual ActivityPub profile (e.g. user actors disabled). + if ( ! user_can_activitypub( $user_id ) ) { $user_id = Actors::BLOG_USER_ID; } diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 2b8e1cd014..ffeae6dc60 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -420,12 +420,6 @@ public function create_item( $request ) { // Handler returned a WP_Post or WP_Comment; look up its outbox entry. $activity_type = \ucfirst( $data['type'] ?? 'Create' ); $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type ); - - // If the scheduler didn't create an outbox entry (e.g. user actors - // disabled), add directly so C2S activities are always federated. - if ( ! $outbox_item ) { - $outbox_item = \get_post( add_to_outbox( $result, $activity_type, $user_id, $visibility ) ); - } } elseif ( \is_int( $result ) && $result > 0 ) { // Handler returned an outbox post ID directly. $outbox_item = \get_post( $result ); From accea8d6c94210162360fcf4d01970195b2227c4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 11:30:45 +0100 Subject: [PATCH 061/105] Revert "Fall back to blog user in should_be_federated() for comments" This reverts commit 3f735784d3760a0e0b6cfeebc478bd96c7554ea4. --- includes/class-comment.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/includes/class-comment.php b/includes/class-comment.php index e11e9fb1a0..ebb31483a8 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -289,9 +289,8 @@ public static function should_be_federated( $comment ) { return false; } - // Fall back to the blog user when the comment author has no - // individual ActivityPub profile (e.g. user actors disabled). - if ( ! user_can_activitypub( $user_id ) ) { + if ( is_single_user() && \user_can( $user_id, 'activitypub' ) ) { + // On a single user site, comments by users with the `activitypub` capability will be federated as the blog user. $user_id = Actors::BLOG_USER_ID; } From 33f40591c82b0a75eb32357438f8ed28874861af Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 11:36:06 +0100 Subject: [PATCH 062/105] Only set protocol meta on remote comments, not C2S The `protocol => activitypub` meta was set unconditionally for all comments created via `Interactions::add_comment()`. This caused `was_received()` to return true for C2S comments from local users, which made `should_be_federated()` bail out and skip adding the comment to the outbox. Move the protocol meta into the remote-only branch so C2S comments from local users are correctly identified as outgoing. --- includes/collection/class-interactions.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 43686164e2..6fbb1a63bf 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -409,14 +409,13 @@ public static function activity_to_comment( $activity, $user_id = null ) { 'comment_author_email' => $comment_author_email, 'comment_date' => \get_date_from_gmt( $gm_date ), 'comment_date_gmt' => $gm_date, - 'comment_meta' => array( - 'protocol' => 'activitypub', - ), + 'comment_meta' => array(), ); if ( $user_id ) { $comment_data['user_id'] = $user_id; } else { + $comment_data['comment_meta']['protocol'] = 'activitypub'; $comment_data['comment_meta']['source_id'] = \esc_url_raw( object_to_uri( $activity['object'] ) ); // Store reference to remote actor post. From e73a9e30454ada8a6b4925a5ecddb9255b590584 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 12:30:29 +0100 Subject: [PATCH 063/105] Skip 406 for OPTIONS preflight on outbox item URLs Browser CORS preflights don't send the Accept header, so is_activitypub_request() returns false for outbox item URLs, causing a 406 that fails the preflight check. Skip the 406 for OPTIONS requests so the CORS headers from add_headers() are returned with a 200 status. --- includes/class-router.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/includes/class-router.php b/includes/class-router.php index ee0327815c..8d9511cb90 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -82,10 +82,16 @@ public static function render_activitypub_template( $template ) { } if ( ! is_activitypub_request() || ! should_negotiate_content() ) { - if ( \get_query_var( 'p' ) && Outbox::POST_TYPE === \get_post_type( \get_query_var( 'p' ) ) ) { + // Return 406 for non-ActivityPub requests to outbox items, + // but skip for OPTIONS preflight so CORS succeeds with 200. + $is_outbox_item = \get_query_var( 'p' ) && Outbox::POST_TYPE === \get_post_type( \get_query_var( 'p' ) ); + $is_preflight = isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD']; + + if ( $is_outbox_item && ! $is_preflight ) { \set_query_var( 'is_404', true ); \status_header( 406 ); } + return $template; } From a9c7a5f15b2096e77f50dd590c51311cf92dd1a5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 12:36:03 +0100 Subject: [PATCH 064/105] Revert redundant early CORS headers for outbox items --- includes/class-router.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-router.php b/includes/class-router.php index 8d9511cb90..c8c9cf88d6 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -164,7 +164,7 @@ public static function add_headers() { return; } - if ( ! headers_sent() ) { + if ( ! \headers_sent() ) { \header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false ); if ( \get_option( 'activitypub_vary_header', '1' ) ) { From 28255b2565cf00953945902168725acdb7d05315 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 12:45:17 +0100 Subject: [PATCH 065/105] Authenticate Bearer tokens for outbox item permalinks Allow authors to view their own private outbox items when accessing them via permalink with a Bearer token. Reuses the existing authenticate_oauth() flow for non-REST requests. --- includes/collection/class-outbox.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 06753cca4d..e50cc7a76a 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -360,6 +360,16 @@ public static function maybe_get_activity( $outbox_item ) { return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' ); } + // Authenticate via Bearer token for non-REST requests (e.g. permalink access). + if ( ! \is_user_logged_in() && ! \wp_is_serving_rest_request() ) { + \Activitypub\OAuth\Server::authenticate_oauth( null ); + } + + // Allow the author to view their own outbox items regardless of visibility. + if ( \get_current_user_id() === (int) $outbox_item->post_author ) { + return self::get_activity( $outbox_item ); + } + // Check if Outbox Activity is public. $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true ); From 1d66f481ed40f18bca93781e156457dc1872627a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 12:46:32 +0100 Subject: [PATCH 066/105] Fix test: C2S comments no longer have protocol meta --- .../tests/includes/handler/outbox/class-test-create.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-create.php b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php index ee430fbb63..31c6d5f5a2 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php @@ -297,7 +297,8 @@ public function test_outgoing_reply_to_local_post() { $this->assertEquals( 0, (int) $result->comment_parent ); $this->assertEquals( $user_id, (int) $result->user_id ); $this->assertStringContainsString( 'This is a reply.', $result->comment_content ); - $this->assertEquals( 'activitypub', \get_comment_meta( $result->comment_ID, 'protocol', true ) ); + // C2S comments should NOT have protocol meta (only remote/inbox comments do). + $this->assertEmpty( \get_comment_meta( $result->comment_ID, 'protocol', true ) ); \add_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ), 10, 2 ); } From 68564bccc78723c6a14882017a58391923c41a0b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 12:50:18 +0100 Subject: [PATCH 067/105] Send CORS headers for outbox item permalinks unconditionally Browser preflight requests don't carry Authorization, so the object ID may not resolve for private outbox items. Send CORS headers based on the URL being an outbox item, independent of whether the ActivityPub object resolves. --- includes/class-router.php | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/includes/class-router.php b/includes/class-router.php index c8c9cf88d6..4f1dda5417 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -160,6 +160,21 @@ public static function render_activitypub_template( $template ) { public static function add_headers() { $id = Query::get_instance()->get_activitypub_object_id(); + /* + * Send CORS headers for resolved ActivityPub objects and outbox + * items. Outbox items need CORS even when the object ID doesn't + * resolve, because browser preflight requests don't carry the + * Authorization header needed to authenticate private items. + */ + $post_id = \get_query_var( 'p' ); + $is_outbox_url = $post_id && Outbox::POST_TYPE === \get_post_type( $post_id ); + + if ( ! \headers_sent() && ( $id || $is_outbox_url ) ) { + \header( 'Access-Control-Allow-Origin: *' ); + \header( 'Access-Control-Allow-Methods: GET, OPTIONS' ); + \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' ); + } + if ( ! $id ) { return; } @@ -171,13 +186,9 @@ public static function add_headers() { // Send Vary header for Accept header. \header( 'Vary: Accept', false ); } - - \header( 'Access-Control-Allow-Origin: *' ); - \header( 'Access-Control-Allow-Methods: GET, OPTIONS' ); - \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' ); } - add_action( + \add_action( 'wp_head', static function () use ( $id ) { echo PHP_EOL . '' . PHP_EOL; From f9fcd9570f2e135d23e5e9d293b99ef839b2f9eb Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 12:55:46 +0100 Subject: [PATCH 068/105] Return 200 for OPTIONS preflight on outbox item URLs WordPress returns 404 for non-public post types when there is no Accept header. Browser preflights require a 2xx response to proceed, so override the status to 200 for OPTIONS requests. --- includes/class-router.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/includes/class-router.php b/includes/class-router.php index 4f1dda5417..02c5e49e0b 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -82,12 +82,17 @@ public static function render_activitypub_template( $template ) { } if ( ! is_activitypub_request() || ! should_negotiate_content() ) { - // Return 406 for non-ActivityPub requests to outbox items, - // but skip for OPTIONS preflight so CORS succeeds with 200. $is_outbox_item = \get_query_var( 'p' ) && Outbox::POST_TYPE === \get_post_type( \get_query_var( 'p' ) ); $is_preflight = isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD']; - if ( $is_outbox_item && ! $is_preflight ) { + if ( $is_outbox_item && $is_preflight ) { + /* + * CORS preflight: override WordPress 404 so the browser + * accepts the preflight response (must be 2xx). + */ + \status_header( 200 ); + } elseif ( $is_outbox_item ) { + // Return 406 for non-ActivityPub requests to outbox items since they only support ActivityPub requests. \set_query_var( 'is_404', true ); \status_header( 406 ); } From 5b7c45fcdf585a35bbbb24940da33d3ef84ea453 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 13:59:22 +0100 Subject: [PATCH 069/105] Delegate outbox Follow handler to follow() for proper delivery The C2S Follow handler duplicated pending-state logic and returned void, causing the outbox controller to fall through to raw add_to_outbox() without the 'to' field set. The Dispatcher's add_inboxes_by_mentioned_actors filter had nothing to resolve, so Follows were never sent to the remote server. Now delegates to follow() which sets proper addressing and returns the outbox post ID, enabling the existing dispatch pipeline to deliver the Follow activity to the target actor's inbox. --- includes/handler/outbox/class-follow.php | 44 +++--------- .../handler/outbox/class-test-follow.php | 72 ++++++------------- 2 files changed, 32 insertions(+), 84 deletions(-) diff --git a/includes/handler/outbox/class-follow.php b/includes/handler/outbox/class-follow.php index 1cdf8f5ad9..86bab9825c 100644 --- a/includes/handler/outbox/class-follow.php +++ b/includes/handler/outbox/class-follow.php @@ -7,8 +7,8 @@ namespace Activitypub\Handler\Outbox; -use Activitypub\Collection\Following; -use Activitypub\Collection\Remote_Actors; +use function Activitypub\follow; +use function Activitypub\object_to_uri; /** * Handle outgoing Follow activities. @@ -24,45 +24,21 @@ public static function init() { /** * Handle outgoing "Follow" activities from local actors. * - * Adds the target actor to the user's following list (pending until accepted). + * Delegates to the follow() function which handles pending state, + * proper activity addressing, and adding to the outbox. * * @param array $data The activity data array. * @param int $user_id The user ID. + * + * @return int|\WP_Error The outbox post ID on success, or WP_Error on failure. */ public static function handle_follow( $data, $user_id = null ) { - $object = $data['object'] ?? ''; - - // The object should be the actor URL to follow. - if ( empty( $object ) || ! \is_string( $object ) ) { - return; - } - - // Fetch or create the remote actor. - $remote_actor = Remote_Actors::fetch_by_uri( $object ); + $object = object_to_uri( $data['object'] ?? '' ); - if ( \is_wp_error( $remote_actor ) ) { - return; + if ( empty( $object ) ) { + return $data; } - // Check if already following. - $all_meta = \get_post_meta( $remote_actor->ID ); - $following = $all_meta[ Following::FOLLOWING_META_KEY ] ?? array(); - $pending = $all_meta[ Following::PENDING_META_KEY ] ?? array(); - - if ( \in_array( (string) $user_id, $following, true ) || \in_array( (string) $user_id, $pending, true ) ) { - return; - } - - // Add to pending following. - \add_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, (string) $user_id ); - - /** - * Fires after an outgoing Follow activity has been processed. - * - * @param int $remote_actor_id The remote actor post ID. - * @param array $data The activity data. - * @param int $user_id The user ID. - */ - \do_action( 'activitypub_outbox_follow_sent', $remote_actor->ID, $data, $user_id ); + return follow( $object, $user_id ); } } diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-follow.php b/tests/phpunit/tests/includes/handler/outbox/class-test-follow.php index 036943eff5..eb7d0ecf1e 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-follow.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-follow.php @@ -31,58 +31,29 @@ class Test_Follow extends \WP_UnitTestCase { public function set_up() { parent::set_up(); - $this->user_id = self::factory()->user->create(); - } - - /** - * Test that handle_follow returns early for empty object. - * - * @covers ::handle_follow - */ - public function test_handle_follow_empty_object() { - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_follow_sent', $callback ); - - Follow::handle_follow( - array( - 'type' => 'Follow', - 'object' => '', - ), - $this->user_id - ); + $this->user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - $this->assertFalse( $fired, 'Action should not fire for empty object.' ); + $user = \get_user_by( 'id', $this->user_id ); + $user->add_cap( 'activitypub' ); - \remove_action( 'activitypub_outbox_follow_sent', $callback ); + // Prevent outbox processing from dispatching during tests. + \remove_all_actions( 'activitypub_process_outbox' ); } /** - * Test that handle_follow returns early for non-string object. + * Test that handle_follow returns data for empty object. * * @covers ::handle_follow */ - public function test_handle_follow_non_string_object() { - $fired = false; - - $callback = function () use ( &$fired ) { - $fired = true; - }; - \add_action( 'activitypub_outbox_follow_sent', $callback ); - + public function test_handle_follow_empty_object() { $data = array( 'type' => 'Follow', - 'object' => array( 'id' => 'https://example.com/user/1' ), + 'object' => '', ); - Follow::handle_follow( $data, $this->user_id ); - - $this->assertFalse( $fired, 'Action should not fire for non-string object.' ); + $result = Follow::handle_follow( $data, $this->user_id ); - \remove_action( 'activitypub_outbox_follow_sent', $callback ); + $this->assertSame( $data, $result, 'Should return original data for empty object.' ); } /** @@ -93,7 +64,6 @@ public function test_handle_follow_non_string_object() { public function test_handle_follow_adds_pending() { $actor_url = 'https://example.com/users/testuser'; - // Mock the HTTP request that fetch_by_uri makes. $fake_response = array( 'type' => 'Person', 'id' => $actor_url, @@ -120,15 +90,18 @@ public function test_handle_follow_adds_pending() { 'object' => $actor_url, ); - Follow::handle_follow( $data, $this->user_id ); + $result = Follow::handle_follow( $data, $this->user_id ); + + // Should return an outbox post ID. + $this->assertIsInt( $result ); + $this->assertGreaterThan( 0, $result ); // Check the remote actor was created and pending meta was added. $remote_actor = Remote_Actors::get_by_uri( $actor_url ); + $this->assertNotWPError( $remote_actor ); - if ( ! \is_wp_error( $remote_actor ) ) { - $pending = \get_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, false ); - $this->assertContains( (string) $this->user_id, $pending, 'User should be in pending following.' ); - } + $pending = \get_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, false ); + $this->assertContains( (string) $this->user_id, $pending, 'User should be in pending following.' ); \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); } @@ -172,12 +145,11 @@ public function test_handle_follow_does_not_duplicate() { Follow::handle_follow( $data, $this->user_id ); $remote_actor = Remote_Actors::get_by_uri( $actor_url ); + $this->assertNotWPError( $remote_actor ); - if ( ! \is_wp_error( $remote_actor ) ) { - $pending = \get_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, false ); - $count = array_count_values( $pending ); - $this->assertEquals( 1, $count[ (string) $this->user_id ] ?? 0, 'User should only appear once in pending.' ); - } + $pending = \get_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, false ); + $count = array_count_values( $pending ); + $this->assertEquals( 1, $count[ (string) $this->user_id ] ?? 0, 'User should only appear once in pending.' ); \remove_filter( 'activitypub_pre_http_get_remote_object', $filter ); } From 63053e72b3e5176fecece9e1045cb039c8cb1eec Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 15:07:12 +0100 Subject: [PATCH 070/105] Fix rewrite rule patterns to match trunk conventions Remove unnecessary backslash escaping from .well-known rewrite rules to match trunk's convention and fix CI test failures. --- includes/class-router.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/class-router.php b/includes/class-router.php index 4dad808cc1..e51cced7d1 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -50,7 +50,7 @@ public static function add_rewrite_rules() { if ( ! \class_exists( 'Webfinger' ) ) { \add_rewrite_rule( - '^\\.well-known/webfinger', + '^.well-known/webfinger', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger', 'top' ); @@ -58,7 +58,7 @@ public static function add_rewrite_rules() { if ( ! \class_exists( 'Nodeinfo_Endpoint' ) && true === (bool) \get_option( 'blog_public', 1 ) ) { \add_rewrite_rule( - '^\\.well-known/nodeinfo', + '^.well-known/nodeinfo', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo', 'top' ); @@ -66,7 +66,7 @@ public static function add_rewrite_rules() { // Authorization Server Metadata (RFC 8414). \add_rewrite_rule( - '^\\.well-known/oauth-authorization-server', + '^.well-known/oauth-authorization-server', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorization-server-metadata', 'top' ); From d4941caaa90371f5beddcce6bbd00a118b6f00ef Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 15:50:01 +0100 Subject: [PATCH 071/105] Move proxy controller test to correct directory Move from tests/rest/ to tests/includes/rest/ to match the convention used by all other REST controller tests. Fix namespace accordingly. --- .../tests/{ => includes}/rest/class-test-proxy-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/phpunit/tests/{ => includes}/rest/class-test-proxy-controller.php (99%) diff --git a/tests/phpunit/tests/rest/class-test-proxy-controller.php b/tests/phpunit/tests/includes/rest/class-test-proxy-controller.php similarity index 99% rename from tests/phpunit/tests/rest/class-test-proxy-controller.php rename to tests/phpunit/tests/includes/rest/class-test-proxy-controller.php index 3d9a70f9c2..f3397faaaf 100644 --- a/tests/phpunit/tests/rest/class-test-proxy-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-proxy-controller.php @@ -5,7 +5,7 @@ * @package Activitypub */ -namespace Activitypub\Tests; +namespace Activitypub\Tests\Rest; use Activitypub\Rest\Proxy_Controller; From 06141e06965dc9a08b07835201effc0647089b3a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 17 Feb 2026 16:10:35 +0100 Subject: [PATCH 072/105] Harden C2S OAuth and outbox endpoints Security fixes from code review: - Bearer token auth takes priority over cookies - Rate-limit client discovery and token endpoints - Sanitize C2S content with wp_kses_post - Capability checks in Create/Update handlers - Ownership checks in Update/Undo handlers - Scope introspection to same client - Use wp_hash_password for client secrets - Validate activity types, PKCE methods, redirect URIs - Reflect CORS origin instead of wildcard - Fix CORS exclusion matching authorization-server-metadata Add changelog entry for the C2S feature branch. --- .github/changelog/c2s-support | 4 + includes/collection/class-interactions.php | 12 +- includes/handler/outbox/class-create.php | 15 +- includes/handler/outbox/class-undo.php | 9 ++ includes/handler/outbox/class-update.php | 15 +- includes/oauth/class-client.php | 20 ++- includes/oauth/class-server.php | 136 ++++++++++-------- includes/oauth/class-token.php | 12 +- includes/rest/class-oauth-controller.php | 73 +++++++++- includes/rest/class-outbox-controller.php | 8 +- templates/oauth-authorize.php | 2 +- .../handler/outbox/class-test-create.php | 17 +-- .../handler/outbox/class-test-update.php | 2 +- .../tests/includes/oauth/class-test-token.php | 6 +- 14 files changed, 235 insertions(+), 96 deletions(-) create mode 100644 .github/changelog/c2s-support diff --git a/.github/changelog/c2s-support b/.github/changelog/c2s-support new file mode 100644 index 0000000000..3a939fc123 --- /dev/null +++ b/.github/changelog/c2s-support @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Support for ActivityPub Client-to-Server (C2S) protocol, allowing apps like federated clients to create, edit, and delete posts on your behalf. diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 6fbb1a63bf..c6bf6d03ca 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -119,8 +119,10 @@ public static function update_comment( $activity ) { // Found a local comment id. $comment_data['comment_author'] = \esc_attr( empty( $meta['name'] ) ? $meta['preferredUsername'] : $meta['name'] ); - // Wrap emoji in content with blocks for runtime replacement. - // Note: Remote images in comments are stripped for security (only emoji allowed). + /* + * Wrap emoji in content with blocks for runtime replacement. + * Note: Remote images in comments are stripped for security (only emoji allowed). + */ $content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] ); $comment_data['comment_content'] = \addslashes( $content ); @@ -391,8 +393,10 @@ public static function activity_to_comment( $activity, $user_id = null ) { } if ( isset( $activity['object']['content'] ) ) { - // Wrap emoji in content with blocks for runtime replacement. - // Note: Remote images in comments are stripped for security (only emoji allowed). + /* + * Wrap emoji in content with blocks for runtime replacement. + * Note: Remote images in comments are stripped for security (only emoji allowed). + */ $content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] ); $comment_content = \addslashes( $content ); } diff --git a/includes/handler/outbox/class-create.php b/includes/handler/outbox/class-create.php index 1157933edb..c92695a5e7 100644 --- a/includes/handler/outbox/class-create.php +++ b/includes/handler/outbox/class-create.php @@ -79,12 +79,21 @@ public static function handle_create( $activity, $user_id = null, $visibility = * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure. */ private static function create_post( $activity, $user_id, $visibility ) { + // Verify the user has permission to create posts. + if ( $user_id > 0 && ! \user_can( $user_id, 'publish_posts' ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You do not have permission to create posts.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + $object = $activity['object'] ?? array(); $object_type = $object['type'] ?? ''; - $content = $object['content'] ?? ''; - $name = $object['name'] ?? ''; - $summary = $object['summary'] ?? ''; + $content = \wp_kses_post( $object['content'] ?? '' ); + $name = \sanitize_text_field( $object['name'] ?? '' ); + $summary = \wp_kses_post( $object['summary'] ?? '' ); // Use name as title for Articles, or generate from content for Notes. $title = $name; diff --git a/includes/handler/outbox/class-undo.php b/includes/handler/outbox/class-undo.php index 2c71ecb025..3d01133e4f 100644 --- a/includes/handler/outbox/class-undo.php +++ b/includes/handler/outbox/class-undo.php @@ -48,6 +48,15 @@ public static function handle_undo( $data, $user_id = null ) { return $data; } + // Verify the user owns this outbox item (blog actor user_id === 0 can undo any). + if ( $user_id > 0 && (int) $outbox_item->post_author !== $user_id ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You can only undo your own activities.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + $activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); switch ( $activity_type ) { diff --git a/includes/handler/outbox/class-update.php b/includes/handler/outbox/class-update.php index 4866af841d..c1bde78ba8 100644 --- a/includes/handler/outbox/class-update.php +++ b/includes/handler/outbox/class-update.php @@ -85,9 +85,18 @@ public static function handle_update( $activity, $user_id = null, $visibility = return null; } - $content = $object['content'] ?? ''; - $name = $object['name'] ?? ''; - $summary = $object['summary'] ?? ''; + // Verify the user has permission to edit this post. + if ( $user_id > 0 && ! \user_can( $user_id, 'edit_post', $post->ID ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You do not have permission to edit this post.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $content = \wp_kses_post( $object['content'] ?? '' ); + $name = \sanitize_text_field( $object['name'] ?? '' ); + $summary = \wp_kses_post( $object['summary'] ?? '' ); // Use name as title for Articles, or generate from content for Notes. $title = $name; diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index 2f9da483aa..223fc1ceac 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -98,7 +98,7 @@ public static function register( $data ) { 'post_content' => $description, 'meta_input' => array( '_activitypub_client_id' => $client_id, - '_activitypub_client_secret_hash' => $client_secret ? Token::hash_token( $client_secret ) : '', + '_activitypub_client_secret_hash' => $client_secret ? \wp_hash_password( $client_secret ) : '', '_activitypub_redirect_uris' => array_map( 'sanitize_url', $redirect_uris ), '_activitypub_allowed_scopes' => Scope::validate( $scopes ), '_activitypub_is_public' => (bool) $is_public, @@ -164,11 +164,27 @@ public static function get( $client_id ) { * Discover client metadata from URL and auto-register. * * Fetches the Client ID Metadata Document (CIMD) from the client_id URL. + * Rate-limited via transients to prevent SSRF abuse. * * @param string $client_id The client ID URL. * @return Client|\WP_Error The client or error. */ private static function discover_and_register( $client_id ) { + // Rate-limit auto-discovery to prevent SSRF abuse (max 10 per minute per IP). + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; + $transient_key = 'ap_oauth_disc_' . \md5( $ip ); + $count = (int) \get_transient( $transient_key ); + + if ( $count >= 10 ) { + return new \WP_Error( + 'activitypub_rate_limited', + \__( 'Too many client discovery requests. Please try again later.', 'activitypub' ), + array( 'status' => 429 ) + ); + } + + \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS ); + $metadata = self::fetch_client_metadata( $client_id ); if ( \is_wp_error( $metadata ) ) { @@ -369,7 +385,7 @@ public static function validate( $client_id, $client_secret = null ) { $stored_hash = \get_post_meta( $client->post_id, '_activitypub_client_secret_hash', true ); - return hash_equals( $stored_hash, Token::hash_token( $client_secret ) ); + return \wp_check_password( $client_secret, $stored_hash ); } /** diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index a26434190f..81e95a4c44 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -52,35 +52,30 @@ public static function authenticate_oauth( $result ) { */ self::$current_token = null; - // If another authentication method already succeeded, use that. - if ( true === $result || \is_user_logged_in() ) { - return $result; - } - - // If a previous auth filter returned an error, respect it. + // Respect errors from earlier auth filters. if ( \is_wp_error( $result ) ) { return $result; } - // Check for Bearer token. + /* + * Check for Bearer token first — it takes priority over cookie auth. + * Cookie-based `is_user_logged_in()` can be true in browsers, but + * REST cookie auth requires a nonce that C2S clients won't have. + * Skipping this check would silently drop valid Bearer tokens. + */ $token = self::get_bearer_token(); if ( ! $token ) { - // No Bearer token present - let other auth methods handle it. return $result; } - // Validate the token. $validated = Token::validate( $token ); if ( \is_wp_error( $validated ) ) { return $validated; } - // Store the validated token for later use. self::$current_token = $validated; - - // Set the current user. \wp_set_current_user( $validated->get_user_id() ); return true; @@ -144,27 +139,31 @@ public static function get_bearer_token() { * @return string|null The authorization header value or null. */ private static function get_authorization_header() { - // Check for standard header. + /* + * Only wp_unslash() is used here — sanitize_text_field() could + * corrupt opaque bearer tokens by stripping characters. + */ + + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Opaque auth token, must not be altered. if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { - return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); + return \wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ); } - // Check for redirect header (some servers use this). if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { - return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); + return \wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ); } + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - // Try to get from Apache. - if ( function_exists( 'apache_request_headers' ) ) { - $headers = apache_request_headers(); - if ( isset( $headers['Authorization'] ) ) { - return sanitize_text_field( $headers['Authorization'] ); - } - // Check case-insensitive. - foreach ( $headers as $key => $value ) { - if ( 'authorization' === strtolower( $key ) ) { - return sanitize_text_field( $value ); - } + // Fallback: read from Apache's own header API (case-insensitive). + if ( ! function_exists( 'apache_request_headers' ) ) { + return null; + } + + $headers = apache_request_headers(); + + foreach ( $headers as $key => $value ) { + if ( 'authorization' === strtolower( $key ) ) { + return $value; } } @@ -211,11 +210,11 @@ public static function check_oauth_permission( $request, $scope = null ) { * @param string|null $scope Required scope. */ $override = \apply_filters( 'activitypub_oauth_check_permission', null, $request, $scope ); + if ( null !== $override ) { return $override; } - // Must be authenticated via OAuth. if ( ! self::is_oauth_request() ) { return new \WP_Error( 'activitypub_oauth_required', @@ -224,7 +223,6 @@ public static function check_oauth_permission( $request, $scope = null ) { ); } - // Check scope if specified. if ( $scope && ! self::has_scope( $scope ) ) { return new \WP_Error( 'activitypub_insufficient_scope', @@ -259,17 +257,23 @@ public static function cleanup() { * @return \WP_REST_Response The modified response. */ public static function add_cors_headers( $response, $server, $request ) { - $route = $request->get_route(); - - // Check if route needs CORS headers. - if ( ! self::route_needs_cors( $route ) ) { + if ( ! self::route_needs_cors( $request->get_route() ) ) { return $response; } - $response->header( 'Access-Control-Allow-Origin', '*' ); + /* + * Reflect the request Origin instead of using a wildcard to avoid + * leaking private data to arbitrary origins on authenticated endpoints. + */ + $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? \esc_url_raw( \wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; + $response->header( 'Access-Control-Allow-Origin', $origin ? $origin : '*' ); $response->header( 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS' ); $response->header( 'Access-Control-Allow-Headers', 'Content-Type, Authorization' ); + if ( $origin ) { + $response->header( 'Vary', 'Origin' ); + } + return $response; } @@ -282,9 +286,9 @@ public static function add_cors_headers( $response, $server, $request ) { private static function route_needs_cors( $route ) { $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE; - // OAuth endpoints (except authorize which redirects). - if ( 0 === strpos( $route, $namespace . '/oauth' ) ) { - return false === strpos( $route, '/oauth/authorize' ); + // OAuth endpoints (except the interactive authorize endpoint which redirects). + if ( 0 === strpos( $route, $namespace . '/oauth/' ) ) { + return $namespace . '/oauth/authorize' !== $route; } // Proxy endpoint for fetching remote objects. @@ -361,7 +365,7 @@ private static function render_authorize_form() { $client_id = isset( $_GET['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_GET['client_id'] ) ) : ''; $redirect_uri = isset( $_GET['redirect_uri'] ) ? \esc_url_raw( \wp_unslash( $_GET['redirect_uri'] ) ) : ''; $scope = isset( $_GET['scope'] ) ? \sanitize_text_field( \wp_unslash( $_GET['scope'] ) ) : ''; - $state = isset( $_GET['state'] ) ? \sanitize_text_field( \wp_unslash( $_GET['state'] ) ) : ''; + $state = isset( $_GET['state'] ) ? \wp_unslash( $_GET['state'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly. $code_challenge = isset( $_GET['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge'] ) ) : ''; $code_challenge_method = isset( $_GET['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge_method'] ) ) : 'S256'; // phpcs:enable WordPress.Security.NonceVerification.Recommended @@ -426,14 +430,19 @@ private static function process_authorize_form() { $client_id = isset( $_POST['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_POST['client_id'] ) ) : ''; $redirect_uri = isset( $_POST['redirect_uri'] ) ? \esc_url_raw( \wp_unslash( $_POST['redirect_uri'] ) ) : ''; $scope = isset( $_POST['scope'] ) ? \sanitize_text_field( \wp_unslash( $_POST['scope'] ) ) : ''; - $state = isset( $_POST['state'] ) ? \sanitize_text_field( \wp_unslash( $_POST['state'] ) ) : ''; + $state = isset( $_POST['state'] ) ? \wp_unslash( $_POST['state'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly. $code_challenge = isset( $_POST['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge'] ) ) : ''; $code_challenge_method = isset( $_POST['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge_method'] ) ) : 'S256'; $approve = isset( $_POST['approve'] ); // phpcs:enable WordPress.Security.NonceVerification.Missing + if ( ! \in_array( $code_challenge_method, array( 'S256', 'plain' ), true ) ) { + $code_challenge_method = 'S256'; + } + // Re-validate client and redirect URI (form fields could be tampered with). $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { \wp_die( \esc_html( $client->get_error_message() ), @@ -452,22 +461,14 @@ private static function process_authorize_form() { // User denied authorization. if ( ! $approve ) { - $error_url = \add_query_arg( + self::redirect_to_client( + $redirect_uri, array( 'error' => 'access_denied', - 'error_description' => \rawurlencode( 'The user denied the authorization request.' ), + 'error_description' => 'The user denied the authorization request.', 'state' => $state, - ), - $redirect_uri + ) ); - - /* - * wp_safe_redirect() blocks external domains, but OAuth redirect_uris - * are always external. The URI is pre-validated against the registered - * client's redirect_uris by render_authorize_form(). - */ - \wp_redirect( $error_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect - exit; } // Create authorization code. @@ -482,28 +483,37 @@ private static function process_authorize_form() { ); if ( \is_wp_error( $code ) ) { - $error_url = \add_query_arg( + self::redirect_to_client( + $redirect_uri, array( 'error' => 'server_error', - 'error_description' => \rawurlencode( $code->get_error_message() ), + 'error_description' => $code->get_error_message(), 'state' => $state, - ), - $redirect_uri + ) ); - // See comment above regarding wp_redirect vs wp_safe_redirect. - \wp_redirect( $error_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect - exit; } - // Redirect to client with authorization code. - $success_url = \add_query_arg( + self::redirect_to_client( + $redirect_uri, array( 'code' => $code, 'state' => $state, - ), - $redirect_uri + ) ); - \wp_redirect( $success_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Redirecting to external client. + } + + /** + * Redirect to an OAuth client's redirect URI with query parameters. + * + * Uses wp_redirect() because OAuth redirect URIs are external domains + * that wp_safe_redirect() would block. The URI is pre-validated against + * the registered client's redirect_uris before this method is called. + * + * @param string $redirect_uri The client's redirect URI. + * @param array $params Query parameters to append. + */ + private static function redirect_to_client( $redirect_uri, $params ) { + \wp_redirect( \add_query_arg( $params, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } } diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index f30cd34ed9..5fbd5cdad2 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -125,8 +125,10 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D // Track user for cleanup. self::track_user( $user_id ); - // Get the actor URI for the 'me' parameter (IndieAuth convention). - // Fall back to blog actor when user actors are disabled. + /* + * Get the actor URI for the 'me' parameter (IndieAuth convention). + * Fall back to blog actor when user actors are disabled. + */ $actor = Actors::get_by_id( $user_id ); if ( \is_wp_error( $actor ) ) { $actor = Actors::get_by_id( Actors::BLOG_USER_ID ); @@ -698,8 +700,10 @@ public static function introspect( $token ) { $user_id = $validated->get_user_id(); $user = \get_userdata( $user_id ); - // Get the actor URI for the 'me' parameter (IndieAuth convention). - // Fall back to blog actor when user actors are disabled. + /* + * Get the actor URI for the 'me' parameter (IndieAuth convention). + * Fall back to blog actor when user actors are disabled. + */ $actor = Actors::get_by_id( $user_id ); if ( \is_wp_error( $actor ) ) { $actor = Actors::get_by_id( Actors::BLOG_USER_ID ); diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index 5802cea1a0..f254600efc 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -89,7 +89,7 @@ public function register_routes() { ) ); - // Revocation endpoint. + // Revocation endpoint (RFC 7009 — requires authentication). \register_rest_route( $this->namespace, '/' . $this->rest_base . '/revoke', @@ -97,7 +97,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'revoke' ), - 'permission_callback' => '__return_true', + 'permission_callback' => array( $this, 'revoke_permissions_check' ), 'args' => array( 'token' => array( 'description' => 'The token to revoke.', @@ -275,6 +275,20 @@ public function authorize_submit( \WP_REST_Request $request ) { $code_challenge_method = $request->get_param( 'code_challenge_method' ) ?: 'S256'; $approve = $request->get_param( 'approve' ); + // Re-validate client and redirect URI (form fields could be tampered with). + $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { + return $client; + } + + if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { + return new \WP_Error( + 'activitypub_invalid_redirect_uri', + \__( 'Invalid redirect URI for this client.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + // User denied authorization. if ( ! $approve ) { return $this->redirect_with_error( @@ -349,6 +363,37 @@ public function authorize_submit_permissions_check( \WP_REST_Request $request ) return true; } + /** + * Permission check for token revocation. + * + * Per RFC 7009, the revocation endpoint must be protected. + * Requires either a logged-in user or a valid Bearer token. + * + * @return bool|\WP_Error True if allowed, error otherwise. + */ + public function revoke_permissions_check() { + if ( \is_user_logged_in() ) { + return true; + } + + $token = OAuth_Server::get_bearer_token(); + + if ( $token ) { + $validated = Token::validate( $token ); + + if ( ! \is_wp_error( $validated ) ) { + \wp_set_current_user( $validated->get_user_id() ); + return true; + } + } + + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'Authentication required.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + /** * Permission check for token introspection. * @@ -387,6 +432,17 @@ public function introspect_permissions_check() { * @return \WP_REST_Response|\WP_Error */ public function token( \WP_REST_Request $request ) { + // Rate-limit token requests to prevent brute-force attacks (max 20 per minute per IP). + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders + $transient_key = 'ap_oauth_tok_' . \md5( $ip ); + $count = (int) \get_transient( $transient_key ); + + if ( $count >= 20 ) { + return $this->token_error( 'invalid_request', 'Too many token requests. Please try again later.' ); + } + + \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS ); + $grant_type = $request->get_param( 'grant_type' ); $client_id = $request->get_param( 'client_id' ); @@ -493,6 +549,15 @@ public function introspect( \WP_REST_Request $request ) { // Introspect the token. $response = Token::introspect( $token ); + // Scope introspection to same client: non-admin users can only + // introspect tokens belonging to the same client as their own. + if ( $response['active'] && ! \current_user_can( 'manage_options' ) ) { + $current_token = OAuth_Server::get_current_token(); + if ( $current_token && $current_token->get_client_id() !== $response['client_id'] ) { + $response = array( 'active' => false ); + } + } + return new \WP_REST_Response( $response, 200 ); } @@ -503,8 +568,8 @@ public function introspect( \WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public function register_client( \WP_REST_Request $request ) { - // Check if dynamic registration is allowed. - if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', true ) ) { + // Check if dynamic registration is allowed (disabled by default for security). + if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', false ) ) { return new \WP_Error( 'activitypub_registration_disabled', \__( 'Dynamic client registration is not allowed.', 'activitypub' ), diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index ffeae6dc60..900c53bb37 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -146,7 +146,7 @@ public function get_items( $request ) { ), ); - if ( get_current_user_id() !== $user_id && ! current_user_can( 'activitypub' ) ) { + if ( \get_current_user_id() !== (int) $user_id && ! \current_user_can( 'activitypub' ) ) { $args['meta_query'][] = array( 'key' => '_activitypub_activity_type', 'value' => $activity_types, @@ -384,6 +384,12 @@ public function create_item( $request ) { $type = \strtolower( $data['type'] ?? 'create' ); + // Validate type against known activity types to prevent hook name pollution. + $allowed_types = \array_map( 'strtolower', Activity::TYPES ); + if ( ! \in_array( $type, $allowed_types, true ) ) { + $type = 'create'; + } + /** * Filters the activity to add to outbox. * diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php index 6dee2a8715..997d516fc4 100644 --- a/templates/oauth-authorize.php +++ b/templates/oauth-authorize.php @@ -103,7 +103,7 @@ - + diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-create.php b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php index 31c6d5f5a2..7067678c55 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php @@ -26,7 +26,7 @@ public function test_outgoing_note_creates_post_with_status_format() { // Prevent wp_insert_post() from triggering the full outbox chain. \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - $user_id = self::factory()->user->create(); + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'type' => 'Create', 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), @@ -53,7 +53,7 @@ public function test_outgoing_note_creates_post_with_status_format() { public function test_outgoing_article_creates_post_without_format() { \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - $user_id = self::factory()->user->create(); + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'type' => 'Create', 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), @@ -162,7 +162,7 @@ public function test_outgoing_invalid_object_returns_error() { public function test_outgoing_post_content_and_title() { \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - $user_id = self::factory()->user->create(); + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'type' => 'Create', 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), @@ -192,7 +192,7 @@ public function test_outgoing_post_content_and_title() { public function test_outgoing_post_generates_title_from_content() { \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - $user_id = self::factory()->user->create(); + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'type' => 'Create', 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), @@ -219,7 +219,7 @@ public function test_outgoing_post_generates_title_from_content() { public function test_outgoing_post_fires_action() { \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - $user_id = self::factory()->user->create(); + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $fired = false; $callback = function () use ( &$fired ) { @@ -252,7 +252,7 @@ public function test_outgoing_post_fires_action() { public function test_outgoing_post_sets_author() { \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - $user_id = self::factory()->user->create(); + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'type' => 'Create', 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), @@ -278,7 +278,7 @@ public function test_outgoing_post_sets_author() { public function test_outgoing_reply_to_local_post() { \remove_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ) ); - $user_id = self::factory()->user->create(); + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $post_id = self::factory()->post->create( array( 'post_author' => $user_id ) ); $activity = array( 'type' => 'Create', @@ -311,7 +311,7 @@ public function test_outgoing_reply_to_local_post() { public function test_outgoing_reply_to_local_comment() { \remove_action( 'wp_insert_comment', array( \Activitypub\Scheduler\Comment::class, 'schedule_comment_activity_on_insert' ) ); - $user_id = self::factory()->user->create(); + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $post_id = self::factory()->post->create( array( 'post_author' => $user_id ) ); $comment_id = self::factory()->comment->create( array( @@ -353,6 +353,7 @@ public function test_outgoing_reply_uses_local_user_data() { $user_id = self::factory()->user->create( array( + 'role' => 'editor', 'display_name' => 'Test Author', 'user_email' => 'test@example.org', 'user_url' => 'https://example.org', diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-update.php b/tests/phpunit/tests/includes/handler/outbox/class-test-update.php index 62154d540f..15dcc69e55 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-update.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-update.php @@ -33,7 +33,7 @@ public function set_up() { // Prevent wp_update_post() from triggering the full outbox chain. \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); - $this->user_id = self::factory()->user->create(); + $this->user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); } /** diff --git a/tests/phpunit/tests/includes/oauth/class-test-token.php b/tests/phpunit/tests/includes/oauth/class-test-token.php index 1e08b3ef50..9be7d76280 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-token.php +++ b/tests/phpunit/tests/includes/oauth/class-test-token.php @@ -345,8 +345,10 @@ public function test_cleanup_expired() { // Create a token that expires immediately. Token::create( $this->user_id, $this->client_id, array( Scope::READ ), 0 ); - // Wait for expiration plus grace period buffer (normally 1 day, but we can't wait that long). - // For this test, we'll just verify the method runs without error. + /* + * Wait for expiration plus grace period buffer (normally 1 day, but we can't wait that long). + * For this test, we'll just verify the method runs without error. + */ $count = Token::cleanup_expired(); $this->assertIsInt( $count ); } From cf8ae9f874ef5de8598e19121d8771fe840d01b9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 17 Feb 2026 16:16:48 +0100 Subject: [PATCH 073/105] Re-enable dynamic client registration by default Reverting the default to true since C2S clients rely on RFC 7591 dynamic registration to work out of the box. --- includes/rest/class-oauth-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index f254600efc..9819e88432 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -569,7 +569,7 @@ public function introspect( \WP_REST_Request $request ) { */ public function register_client( \WP_REST_Request $request ) { // Check if dynamic registration is allowed (disabled by default for security). - if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', false ) ) { + if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', true ) ) { return new \WP_Error( 'activitypub_registration_disabled', \__( 'Dynamic client registration is not allowed.', 'activitypub' ), From 20ba8ba72af138555c1117990383327bee6260cf Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 17 Feb 2026 16:17:52 +0100 Subject: [PATCH 074/105] Document the dynamic client registration filter Replace stale inline comment with a proper docblock explaining the filter default and how to disable it. --- includes/rest/class-oauth-controller.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index 9819e88432..b963721f5b 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -568,7 +568,14 @@ public function introspect( \WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public function register_client( \WP_REST_Request $request ) { - // Check if dynamic registration is allowed (disabled by default for security). + /** + * Filters whether RFC 7591 dynamic client registration is allowed. + * + * Enabled by default so C2S clients can register on the fly. + * Return false to restrict registration to pre-configured clients only. + * + * @param bool $allowed Whether dynamic registration is allowed. Default true. + */ if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', true ) ) { return new \WP_Error( 'activitypub_registration_disabled', From c80b4053f68a696eb5cfce96ecc82d561a25b74b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 17 Feb 2026 18:27:23 +0100 Subject: [PATCH 075/105] Add Connected Applications UI to user profile page Add an OAuth client registration form and token management section to the user profile, following the WordPress core Application Passwords UI pattern. Users can register new OAuth clients, view credentials with copy-to-clipboard support, manage registered applications, and revoke active tokens. --- assets/js/activitypub-connected-apps.js | 364 ++++++++++++++++++ includes/oauth/class-client.php | 52 +++ includes/wp-admin/class-admin.php | 196 ++++++++++ .../wp-admin/class-user-settings-fields.php | 153 ++++++++ 4 files changed, 765 insertions(+) create mode 100644 assets/js/activitypub-connected-apps.js diff --git a/assets/js/activitypub-connected-apps.js b/assets/js/activitypub-connected-apps.js new file mode 100644 index 0000000000..84062db621 --- /dev/null +++ b/assets/js/activitypub-connected-apps.js @@ -0,0 +1,364 @@ +/** + * ActivityPub Connected Applications JavaScript. + * + * Handles registering OAuth clients, deleting clients, and revoking + * OAuth tokens from the user profile, following the WordPress core + * Application Passwords UI pattern. + */ + +/* global activitypubConnectedApps, jQuery, ClipboardJS */ + +( function( $ ) { + var $section = $( '#activitypub-connected-apps-section' ), + $newAppForm = $section.find( '.create-application-password' ), + $newAppFields = $newAppForm.find( '.input' ), + $newAppButton = $newAppForm.find( '.button' ), + $appsWrapper = $section.find( '#activitypub-registered-apps-wrapper' ), + $appsTbody = $section.find( '#activitypub-registered-apps-tbody' ), + $tokensWrapper = $section.find( '.activitypub-connected-apps-list-table-wrapper' ), + $tokensTbody = $section.find( '#activitypub-connected-apps-tbody' ), + $revokeAll = $section.find( '#activitypub-revoke-all-tokens' ), + $deleteAll = $section.find( '#activitypub-delete-all-clients' ); + + // Register a new application. + $newAppButton.on( 'click', function( e ) { + e.preventDefault(); + + if ( $newAppButton.prop( 'aria-disabled' ) ) { + return; + } + + var $name = $( '#activitypub-new-app-name' ); + var $redirectUri = $( '#activitypub-new-app-redirect-uri' ); + + if ( 0 === $name.val().trim().length ) { + $name.trigger( 'focus' ); + return; + } + + if ( 0 === $redirectUri.val().trim().length ) { + $redirectUri.trigger( 'focus' ); + return; + } + + clearNotices(); + $newAppButton.prop( 'aria-disabled', true ).addClass( 'disabled' ); + + $.ajax( { + url: activitypubConnectedApps.ajaxUrl, + method: 'POST', + data: { + action: 'activitypub_register_oauth_client', + name: $name.val().trim(), + redirect_uri: $redirectUri.val().trim(), + _wpnonce: activitypubConnectedApps.nonce + } + } ).always( function() { + $newAppButton.removeProp( 'aria-disabled' ).removeClass( 'disabled' ); + } ).done( function( response ) { + if ( ! response.success ) { + addNotice( + response.data && response.data.message ? response.data.message : activitypubConnectedApps.registerError, + 'error' + ); + return; + } + + // Build credential notice (matches core's tmpl-new-application-password). + var $notice = $( '
' ) + .attr( 'role', 'alert' ) + .attr( 'tabindex', '-1' ) + .addClass( 'notice notice-success is-dismissible new-application-password-notice' ); + + // Client ID row. + var $clientIdRow = $( '

' ).addClass( 'application-password-display' ) + .append( $( '' ).text( activitypubConnectedApps.clientIdLabel ) ) + .append( $( '' ).attr( { type: 'text', readonly: 'readonly' } ).addClass( 'code' ).val( response.data.client_id ) ) + .append( + $( '' ) + .attr( 'type', 'button' ) + .addClass( 'notice-dismiss' ) + .append( $( '' ).addClass( 'screen-reader-text' ).text( activitypubConnectedApps.dismiss ) ) + ); + + $newAppForm.after( $notice ); + + return $notice; + } + + /** + * Clears notice messages from the Connected Applications section. + */ + function clearNotices() { + $( '.notice', $section ).remove(); + } +}( jQuery ) ); diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index 223fc1ceac..dab0aa65d3 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -475,6 +475,58 @@ private static function is_loopback_redirect_match( $allowed_uri, $redirect_uri return $allowed_path === $redirect_path; } + /** + * Get all manually registered (non-discovered) clients. + * + * @since unreleased + * + * @return Client[] Array of Client objects. + */ + public static function get_manually_registered() { + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary to filter out discovered clients. + $posts = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'numberposts' => 100, + 'meta_query' => array( + 'relation' => 'OR', + array( + 'key' => '_activitypub_discovered', + 'compare' => 'NOT EXISTS', + ), + array( + 'key' => '_activitypub_discovered', + 'value' => '', + ), + array( + 'key' => '_activitypub_discovered', + 'value' => '0', + ), + ), + ) + ); + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query + + return array_map( + function ( $post ) { + return new self( $post->ID ); + }, + $posts + ); + } + + /** + * Get the post ID of the client. + * + * @since unreleased + * + * @return int The post ID. + */ + public function get_post_id() { + return $this->post_id; + } + /** * Get client name. * diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 9d6806491e..8c73aeaaf0 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -13,6 +13,8 @@ use Activitypub\Comment; use Activitypub\Model\Blog; use Activitypub\Moderation; +use Activitypub\OAuth\Client; +use Activitypub\OAuth\Token; use Activitypub\Scheduler\Actor; use Activitypub\Tombstone; @@ -60,6 +62,7 @@ public static function init() { if ( user_can_activitypub( \get_current_user_id() ) ) { \add_action( 'show_user_profile', array( self::class, 'add_profile' ) ); + \add_action( 'show_user_profile', array( User_Settings_Fields::class, 'connected_apps_section' ) ); } \add_filter( 'dashboard_glance_items', array( self::class, 'dashboard_glance_items' ) ); @@ -71,6 +74,7 @@ public static function init() { } \add_action( 'admin_print_scripts-profile.php', array( self::class, 'enqueue_moderation_scripts' ) ); + \add_action( 'admin_print_scripts-profile.php', array( self::class, 'enqueue_connected_apps_scripts' ) ); \add_action( 'admin_print_scripts-settings_page_activitypub', array( self::class, 'enqueue_moderation_scripts' ) ); \add_action( 'admin_print_footer_scripts-settings_page_activitypub', array( self::class, 'open_help_tab' ) ); @@ -78,6 +82,11 @@ public static function init() { \add_action( 'wp_ajax_activitypub_moderation_settings', array( self::class, 'ajax_moderation_settings' ) ); \add_action( 'wp_ajax_activitypub_blocklist_subscription', array( self::class, 'ajax_blocklist_subscription' ) ); + \add_action( 'wp_ajax_activitypub_register_oauth_client', array( self::class, 'ajax_register_oauth_client' ) ); + \add_action( 'wp_ajax_activitypub_delete_oauth_client', array( self::class, 'ajax_delete_oauth_client' ) ); + \add_action( 'wp_ajax_activitypub_delete_all_oauth_clients', array( self::class, 'ajax_delete_all_oauth_clients' ) ); + \add_action( 'wp_ajax_activitypub_revoke_oauth_token', array( self::class, 'ajax_revoke_oauth_token' ) ); + \add_action( 'wp_ajax_activitypub_revoke_all_oauth_tokens', array( self::class, 'ajax_revoke_all_oauth_tokens' ) ); } /** @@ -373,6 +382,46 @@ public static function enqueue_moderation_scripts() { ); } + /** + * Enqueue connected apps admin scripts on the profile page. + * + * @since unreleased + */ + public static function enqueue_connected_apps_scripts() { + \wp_enqueue_script( + 'activitypub-connected-apps', + ACTIVITYPUB_PLUGIN_URL . 'assets/js/activitypub-connected-apps.js', + array( 'jquery' ), + ACTIVITYPUB_PLUGIN_VERSION, + true + ); + + \wp_localize_script( + 'activitypub-connected-apps', + 'activitypubConnectedApps', + array( + 'ajaxUrl' => \admin_url( 'admin-ajax.php' ), + 'nonce' => \wp_create_nonce( 'activitypub_connected_apps' ), + 'confirm' => \__( 'Are you sure you want to revoke this application token? This action cannot be undone.', 'activitypub' ), + 'confirmAll' => \__( 'Are you sure you want to revoke all connected applications? This action cannot be undone.', 'activitypub' ), + 'confirmDelete' => \__( 'Are you sure you want to delete this application? This action cannot be undone.', 'activitypub' ), + 'confirmDeleteAll' => \__( 'Are you sure you want to delete all registered applications? This action cannot be undone.', 'activitypub' ), + 'registerError' => \__( 'Failed to register application.', 'activitypub' ), + 'deleteLabel' => \__( 'Delete', 'activitypub' ), + 'dismiss' => \__( 'Dismiss this notice.', 'activitypub' ), + 'clientIdLabel' => \__( 'Your new Client ID:', 'activitypub' ), + 'clientSecretLabel' => \__( 'Your new Client Secret:', 'activitypub' ), + 'copy' => \__( 'Copy', 'activitypub' ), + 'copied' => \__( 'Copied!', 'activitypub' ), + 'saveWarning' => \__( 'Be sure to save this in a safe location. You will not be able to retrieve it.', 'activitypub' ), + 'appRevoked' => \__( 'Application token revoked.', 'activitypub' ), + 'allAppsRevoked' => \__( 'All application tokens revoked.', 'activitypub' ), + 'appDeleted' => \__( 'Application deleted.', 'activitypub' ), + 'allAppsDeleted' => \__( 'All registered applications deleted.', 'activitypub' ), + ) + ); + } + /** * Hook into the edit_comment functionality. * @@ -1133,4 +1182,151 @@ public static function ajax_blocklist_subscription() { \wp_send_json_error( array( 'message' => \__( 'Failed to remove subscription.', 'activitypub' ) ) ); } } + + /** + * AJAX handler for registering a new OAuth client from the user profile. + * + * @since unreleased + */ + public static function ajax_register_oauth_client() { + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'activitypub_connected_apps' ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid nonce.', 'activitypub' ) ) ); + } + + $name = \sanitize_text_field( \wp_unslash( $_POST['name'] ?? '' ) ); + $redirect_uri = \sanitize_url( \wp_unslash( $_POST['redirect_uri'] ?? '' ) ); + + if ( empty( $name ) ) { + \wp_send_json_error( array( 'message' => \__( 'Application name is required.', 'activitypub' ) ) ); + } + + if ( empty( $redirect_uri ) ) { + \wp_send_json_error( array( 'message' => \__( 'Redirect URI is required.', 'activitypub' ) ) ); + } + + $result = Client::register( + array( + 'name' => $name, + 'redirect_uris' => array( $redirect_uri ), + 'is_public' => false, + ) + ); + + if ( \is_wp_error( $result ) ) { + \wp_send_json_error( array( 'message' => $result->get_error_message() ) ); + } + + $data = array( + 'client_id' => $result['client_id'], + 'created' => \date_i18n( \get_option( 'date_format' ) ), + ); + + if ( ! empty( $result['client_secret'] ) ) { + $data['client_secret'] = $result['client_secret']; + } + + \wp_send_json_success( $data ); + } + + /** + * AJAX handler for deleting a registered OAuth client. + * + * @since unreleased + */ + public static function ajax_delete_oauth_client() { + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'activitypub_connected_apps' ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid nonce.', 'activitypub' ) ) ); + } + + $client_id = \sanitize_text_field( \wp_unslash( $_POST['client_id'] ?? '' ) ); + + if ( empty( $client_id ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid client ID.', 'activitypub' ) ) ); + } + + $deleted = Client::delete( $client_id ); + + if ( ! $deleted ) { + \wp_send_json_error( array( 'message' => \__( 'Failed to delete application.', 'activitypub' ) ) ); + } + + \wp_send_json_success( array( 'deleted' => true ) ); + } + + /** + * AJAX handler for deleting all manually registered OAuth clients. + * + * @since unreleased + */ + public static function ajax_delete_all_oauth_clients() { + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'activitypub_connected_apps' ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid nonce.', 'activitypub' ) ) ); + } + + $clients = Client::get_manually_registered(); + + foreach ( $clients as $client ) { + Client::delete( $client->get_client_id() ); + } + + \wp_send_json_success( array( 'deleted' => ! empty( $clients ) ) ); + } + + /** + * AJAX handler for revoking an OAuth token from the user profile. + * + * Follows the WordPress core Application Passwords pattern. + * + * @since unreleased + */ + public static function ajax_revoke_oauth_token() { + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'activitypub_connected_apps' ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid nonce.', 'activitypub' ) ) ); + } + + $meta_key = \sanitize_text_field( \wp_unslash( $_POST['meta_key'] ?? '' ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a DB query parameter. + + // Verify the meta key belongs to our token prefix. + if ( 0 !== strpos( $meta_key, Token::META_PREFIX ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid token.', 'activitypub' ) ) ); + } + + $user_id = \get_current_user_id(); + $token_data = \get_user_meta( $user_id, $meta_key, true ); + + // Verify the token belongs to the current user. + if ( empty( $token_data ) || ! is_array( $token_data ) ) { + \wp_send_json_error( array( 'message' => \__( 'Token not found.', 'activitypub' ) ) ); + } + + // Delete the token. + \delete_user_meta( $user_id, $meta_key ); + + // Delete the associated refresh token index. + if ( ! empty( $token_data['refresh_token_hash'] ) ) { + \delete_user_meta( $user_id, Token::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] ); + } + + \wp_send_json_success( array( 'deleted' => true ) ); + } + + /** + * AJAX handler for revoking all OAuth tokens for the current user. + * + * @since unreleased + */ + public static function ajax_revoke_all_oauth_tokens() { + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'activitypub_connected_apps' ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid nonce.', 'activitypub' ) ) ); + } + + $count = Token::revoke_all_for_user( \get_current_user_id() ); + + \wp_send_json_success( array( 'deleted' => $count > 0 ) ); + } } diff --git a/includes/wp-admin/class-user-settings-fields.php b/includes/wp-admin/class-user-settings-fields.php index a011651e8f..b3f270f190 100644 --- a/includes/wp-admin/class-user-settings-fields.php +++ b/includes/wp-admin/class-user-settings-fields.php @@ -10,6 +10,9 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Extra_Fields; use Activitypub\Moderation; +use Activitypub\OAuth\Client; +use Activitypub\OAuth\Scope; +use Activitypub\OAuth\Token; /** * Class to handle all user settings fields and callbacks. @@ -389,6 +392,156 @@ public static function blocked_keywords_callback() { +
+

+

+ +
+
+ + +
+
+ + +
+ +
+ +
> +

+ + + + + + + + + + + + get_redirect_uris(); + $redirect_uri = ! empty( $redirect_uris ) ? $redirect_uris[0] : '—'; + $post = \get_post( $client->get_post_id() ); + $created = $post ? \date_i18n( \get_option( 'date_format' ), \strtotime( $post->post_date ) ) : '—'; + ?> + + + + + + + + + + + + + + + + +
get_name() ); ?> + %2$s', + /* translators: %s: the application name */ + \esc_attr( \sprintf( \__( 'Delete "%s"', 'activitypub' ), $client->get_name() ) ), + \esc_html__( 'Delete', 'activitypub' ) + ); + ?> +
+
+
+ +
+
+
+ + +
+

+ + + + + + + + + + + + + get_name() : $client_id; + $scopes = isset( $token['scopes'] ) ? Scope::to_string( $token['scopes'] ) : ''; + $created = ! empty( $token['created_at'] ) ? \date_i18n( \get_option( 'date_format' ), $token['created_at'] ) : '—'; + $last_used = ! empty( $token['last_used_at'] ) ? \date_i18n( \get_option( 'date_format' ), $token['last_used_at'] ) : '—'; + $meta_key = $token['meta_key'] ?? ''; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a DB query, just array key. + ?> + + + + + + + + + + + + + + + + + + +
+ %2$s', + /* translators: %s: the application name */ + \esc_attr( \sprintf( \__( 'Revoke "%s"', 'activitypub' ), $client_name ) ), + \esc_html__( 'Revoke', 'activitypub' ) + ); + ?> +
+
+
+ +
+
+
+ +
+ Date: Wed, 18 Feb 2026 08:17:36 +0100 Subject: [PATCH 076/105] Clarify OAuth applications section text Rename the user settings section header to "OAuth Applications" and update the explanatory paragraph to clarify that users can register OAuth applications to connect third-party clients or revoke access for existing ones. This improves wording and clarity in the account settings UI (includes/wp-admin/class-user-settings-fields.php). --- includes/wp-admin/class-user-settings-fields.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/wp-admin/class-user-settings-fields.php b/includes/wp-admin/class-user-settings-fields.php index b3f270f190..d5b33f7565 100644 --- a/includes/wp-admin/class-user-settings-fields.php +++ b/includes/wp-admin/class-user-settings-fields.php @@ -406,8 +406,8 @@ public static function connected_apps_section() { ?>
-

-

+

+

From 4d0bd51f5bacfcc58cd1984fa65a142d679429d1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 18 Feb 2026 10:26:22 +0100 Subject: [PATCH 077/105] Rename Posts collection to Remote_Posts and add local Posts collection Split the Posts collection into two distinct classes: - Remote_Posts: manages remote ActivityPub posts (ap_post type) received via federation (renamed from Posts) - Posts: provides CRUD for local WordPress posts created via C2S outbox, with a prepare_content pipeline (wpautop, autolink, hashtags, blocks) Also adds Blocks::convert_from_html() for converting HTML to WordPress block markup, and updates all callers and tests accordingly. --- includes/cache/class-media.php | 4 +- includes/class-activitypub.php | 4 +- includes/class-blocks.php | 154 ++ includes/class-comment.php | 4 +- includes/class-post-types.php | 16 +- includes/class-scheduler.php | 4 +- includes/cli/class-self-destruct-command.php | 4 +- includes/collection/class-interactions.php | 4 +- includes/collection/class-posts.php | 639 +----- includes/collection/class-remote-posts.php | 619 ++++++ includes/debug.php | 6 +- includes/functions-post.php | 4 +- includes/handler/class-create.php | 6 +- includes/handler/class-delete.php | 8 +- includes/handler/class-update.php | 4 +- includes/handler/outbox/class-create.php | 56 +- includes/handler/outbox/class-delete.php | 4 +- includes/handler/outbox/class-update.php | 37 +- .../tests/includes/class-test-blocks.php | 97 + .../tests/includes/class-test-comment.php | 10 +- .../tests/includes/class-test-scheduler.php | 38 +- .../includes/collection/class-test-posts.php | 1871 ++-------------- .../collection/class-test-remote-posts.php | 1914 +++++++++++++++++ .../includes/handler/class-test-create.php | 16 +- .../includes/handler/class-test-delete.php | 6 +- 25 files changed, 3129 insertions(+), 2400 deletions(-) create mode 100644 includes/collection/class-remote-posts.php create mode 100644 tests/phpunit/tests/includes/collection/class-test-remote-posts.php diff --git a/includes/cache/class-media.php b/includes/cache/class-media.php index 096c857523..7503844387 100644 --- a/includes/cache/class-media.php +++ b/includes/cache/class-media.php @@ -7,7 +7,7 @@ namespace Activitypub\Cache; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; /** * Media cache class. @@ -231,7 +231,7 @@ public static function cache_url( $url, $entity_id ) { * @param int $post_id The post ID being deleted. */ public static function maybe_cleanup( $post_id ) { - if ( Posts::POST_TYPE !== \get_post_type( $post_id ) ) { + if ( Remote_Posts::POST_TYPE !== \get_post_type( $post_id ) ) { return; } diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index f5d0c1e5e2..aebefb8b95 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -9,7 +9,7 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Following; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; /** * ActivityPub Class. @@ -86,7 +86,7 @@ public static function uninstall() { \remove_filter( 'pre_wp_update_comment_count_now', array( Comment::class, 'pre_wp_update_comment_count_now' ), 5 ); Migration::update_comment_counts( 2000 ); - Posts::delete_all(); + Remote_Posts::delete_all(); Options::delete(); } diff --git a/includes/class-blocks.php b/includes/class-blocks.php index 4bf0aa8b27..855afe62ee 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -13,6 +13,53 @@ * Block class. */ class Blocks { + + /** + * HTML tags to skip during block conversion. + * + * @var array + */ + const SKIP_TAGS = array( 'BR', 'CITE', 'SOURCE' ); + + /** + * HTML void elements that have no closing tag. + * + * @var array + */ + const VOID_TAGS = array( 'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR' ); + + /** + * Map of HTML tag names to WordPress block types. + * + * @var array + */ + const BLOCK_MAP = array( + 'UL' => 'list', + 'OL' => 'list', + 'IMG' => 'image', + 'BLOCKQUOTE' => 'quote', + 'H1' => 'heading', + 'H2' => 'heading', + 'H3' => 'heading', + 'H4' => 'heading', + 'H5' => 'heading', + 'H6' => 'heading', + 'P' => 'paragraph', + 'A' => 'paragraph', + 'ABBR' => 'paragraph', + 'B' => 'paragraph', + 'CODE' => 'paragraph', + 'EM' => 'paragraph', + 'I' => 'paragraph', + 'STRONG' => 'paragraph', + 'SUB' => 'paragraph', + 'SUP' => 'paragraph', + 'SPAN' => 'paragraph', + 'U' => 'paragraph', + 'FIGURE' => 'image', + 'HR' => 'separator', + ); + /** * Initialize the class, registering WordPress hooks. */ @@ -949,4 +996,111 @@ public static function revert_embed_links( $block_content, $block ) { } return '

' . $block['attrs']['url'] . '

'; } + + /** + * Convert HTML content to blocks. + * + * Tokenizes the content with wp_html_split(), tracks nesting depth, + * and wraps each top-level element in block comment delimiters. + * + * @since unreleased + * + * @param string $content The HTML content. + * + * @return string The content converted to blocks. + */ + public static function convert_from_html( $content ) { + if ( empty( $content ) ) { + return ''; + } + + $tokens = \wp_html_split( $content ); + $_content = ''; + $depth = 0; + $current_tag = ''; + $current_html = ''; + + foreach ( $tokens as $token ) { + if ( '' === $token ) { + continue; + } + + // Text content — accumulate only inside a top-level element. + if ( '<' !== $token[0] ) { + if ( $depth > 0 ) { + $current_html .= $token; + } + continue; + } + + // Closing tag. + if ( '/' === $token[1] ) { + $current_html .= $token; + --$depth; + + if ( 0 === $depth && '' !== $current_tag ) { + $_content .= self::to_block( $current_tag, $current_html ); + $current_tag = ''; + $current_html = ''; + } + continue; + } + + // Extract the tag name from the opening tag. + if ( ! \preg_match( '/^<([a-zA-Z][a-zA-Z0-9]*)/', $token, $m ) ) { + if ( $depth > 0 ) { + $current_html .= $token; + } + continue; + } + + $tag = \strtoupper( $m[1] ); + + // Start of a new top-level element. + if ( 0 === $depth ) { + $current_tag = $tag; + $current_html = $token; + } else { + $current_html .= $token; + } + + // Void elements don't increase depth — flush immediately at top level. + if ( \in_array( $tag, self::VOID_TAGS, true ) ) { + if ( 0 === $depth && '' !== $current_tag ) { + $_content .= self::to_block( $current_tag, $current_html ); + $current_tag = ''; + $current_html = ''; + } + } else { + ++$depth; + } + } + + return $_content; + } + + /** + * Wrap an HTML element in block comment delimiters. + * + * @since unreleased + * + * @param string $tag The uppercase tag name. + * @param string $html The element HTML. + * + * @return string The block-wrapped HTML, or empty string for skipped tags. + */ + private static function to_block( $tag, $html ) { + if ( \in_array( $tag, self::SKIP_TAGS, true ) ) { + return ''; + } + + $block_type = self::BLOCK_MAP[ $tag ] ?? 'html'; + $block_attrs = array(); + + if ( 'OL' === $tag ) { + $block_attrs['ordered'] = true; + } + + return \get_comment_delimited_block_content( $block_type, $block_attrs, \trim( $html ) ); + } } diff --git a/includes/class-comment.php b/includes/class-comment.php index de9d6d3615..e38a6aa548 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -8,7 +8,7 @@ namespace Activitypub; use Activitypub\Collection\Actors; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; /** * ActivityPub Comment Class. @@ -973,7 +973,7 @@ public static function is_comment_type_enabled( $comment_type ) { * @return string[] Array of post type names to hide comments for. */ public static function hide_for() { - $post_types = array( Posts::POST_TYPE ); + $post_types = array( Remote_Posts::POST_TYPE ); /** * Filters the list of post types to hide comments for. diff --git a/includes/class-post-types.php b/includes/class-post-types.php index 69fb14927a..a6a3009b93 100644 --- a/includes/class-post-types.php +++ b/includes/class-post-types.php @@ -13,8 +13,8 @@ use Activitypub\Collection\Following; use Activitypub\Collection\Inbox; use Activitypub\Collection\Outbox; -use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Actors; +use Activitypub\Collection\Remote_Posts; use Activitypub\OAuth\Client; use Activitypub\OAuth\Scope; @@ -348,7 +348,7 @@ public static function register_outbox_post_type() { */ public static function register_post_post_type() { \register_post_type( - Posts::POST_TYPE, + Remote_Posts::POST_TYPE, array( 'labels' => array( 'name' => \_x( 'Posts', 'post_type plural name', 'activitypub' ), @@ -372,7 +372,7 @@ public static function register_post_post_type() { \register_taxonomy( 'ap_tag', - array( Posts::POST_TYPE ), + array( Remote_Posts::POST_TYPE ), array( 'public' => false, 'query_var' => true, @@ -382,7 +382,7 @@ public static function register_post_post_type() { \register_taxonomy( 'ap_object_type', - array( Posts::POST_TYPE ), + array( Remote_Posts::POST_TYPE ), array( 'public' => false, 'query_var' => true, @@ -391,7 +391,7 @@ public static function register_post_post_type() { ); \register_post_meta( - Posts::POST_TYPE, + Remote_Posts::POST_TYPE, '_activitypub_remote_actor_id', array( 'type' => 'integer', @@ -402,7 +402,7 @@ public static function register_post_post_type() { ); \register_post_meta( - Posts::POST_TYPE, + Remote_Posts::POST_TYPE, '_activitypub_user_id', array( 'type' => 'integer', @@ -765,7 +765,7 @@ public static function filter_ap_actor_query_by_follower( $args, $request ) { */ public static function register_ap_post_actor_rest_field() { \register_rest_field( - Posts::POST_TYPE, + Remote_Posts::POST_TYPE, 'actor_info', array( /** @@ -805,7 +805,7 @@ public static function register_ap_post_actor_rest_field() { */ public static function register_ap_post_rest_params() { \add_filter( - 'rest_' . Posts::POST_TYPE . '_collection_params', + 'rest_' . Remote_Posts::POST_TYPE . '_collection_params', function ( $params ) { $params['user_id'] = array( 'description' => __( 'Filter posts by user ID (0 for site/blog actor).', 'activitypub' ), diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index d42e8c8ecb..9168242807 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -12,8 +12,8 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Inbox; use Activitypub\Collection\Outbox; -use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Actors; +use Activitypub\Collection\Remote_Posts; use Activitypub\Scheduler\Actor; use Activitypub\Scheduler\Collection_Sync; use Activitypub\Scheduler\Comment; @@ -329,7 +329,7 @@ public static function purge_inbox() { */ public static function purge_ap_posts() { $days = (int) \get_option( 'activitypub_ap_post_purge_days', 30 ); - Posts::purge( $days ); + Remote_Posts::purge( $days ); } /** diff --git a/includes/cli/class-self-destruct-command.php b/includes/cli/class-self-destruct-command.php index 43050c2f0f..58ea941482 100644 --- a/includes/cli/class-self-destruct-command.php +++ b/includes/cli/class-self-destruct-command.php @@ -10,7 +10,7 @@ use Activitypub\Activity\Activity; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; use function Activitypub\add_to_outbox; @@ -107,7 +107,7 @@ private function execute_self_destruct( $assoc_args ) { $processed = $this->process_user_deletions( $user_ids ); // Delete all remote posts. - $deleted_posts = Posts::delete_all(); + $deleted_posts = Remote_Posts::delete_all(); if ( $deleted_posts > 0 ) { \WP_CLI::line( \WP_CLI::colorize( "%G✓%n Deleted {$deleted_posts} remote post(s)." ) ); } diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index c6bf6d03ca..d7586830ca 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -75,7 +75,7 @@ public static function add_comment( $activity, $user_id = null ) { if ( ! $comment_post_id ) { // Check for `ap_post`. - $comment_post = Posts::get_by_guid( $target_url ); + $comment_post = Remote_Posts::get_by_guid( $target_url ); if ( $comment_post instanceof \WP_Post ) { $comment_post_id = $comment_post->ID; } @@ -143,7 +143,7 @@ public static function add_reaction( $activity ) { if ( ! $comment_post_id ) { // Check for `ap_post`. - $comment_post = Posts::get_by_guid( $url ); + $comment_post = Remote_Posts::get_by_guid( $url ); if ( $comment_post instanceof \WP_Post ) { $comment_post_id = $comment_post->ID; } diff --git a/includes/collection/class-posts.php b/includes/collection/class-posts.php index 1eec47d578..bc29e7a04b 100644 --- a/includes/collection/class-posts.php +++ b/includes/collection/class-posts.php @@ -7,613 +7,182 @@ namespace Activitypub\Collection; -use Activitypub\Emoji; -use Activitypub\Sanitize; +use Activitypub\Blocks; +use Activitypub\Hashtag; +use Activitypub\Link; -use function Activitypub\generate_post_summary; -use function Activitypub\object_to_uri; -use function Activitypub\process_remote_media; +use function Activitypub\get_content_visibility; /** * Posts collection. * - * Provides methods to retrieve, create, update, and manage ActivityPub posts (articles, notes, media, etc.). + * Provides CRUD methods for local WordPress posts created + * via ActivityPub (C2S outbox). */ class Posts { /** - * The post type for the posts. + * Create a WordPress post from an ActivityPub activity. * - * @var string - */ - const POST_TYPE = 'ap_post'; - - /** - * Maximum number of remote post items to keep. + * @since unreleased * - * @var int - */ - const MAX_ITEMS = 5000; - - /** - * Number of items to process per batch during purge. + * @param array $activity The activity data. + * @param int $user_id The local user ID. + * @param string|null $visibility Content visibility. * - * @var int + * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure. */ - const PURGE_BATCH_SIZE = 100; - - /** - * Maximum seconds a purge run may take before yielding. - * - * @var int - */ - const PURGE_TIMEOUT = 30; - - /** - * Add an object to the collection. - * - * @param array $activity The activity object data. - * @param int|int[] $recipients The id(s) of the local blog-user(s). - * - * @return \WP_Post|\WP_Error The object post or WP_Error on failure. - */ - public static function add( $activity, $recipients ) { - $recipients = (array) $recipients; - $activity_object = $activity['object']; - - $existing = self::get_by_guid( $activity_object['id'] ); - // If post exists, call update instead. - if ( ! \is_wp_error( $existing ) ) { - return self::update( $activity, $recipients ); + public static function create( $activity, $user_id, $visibility = null ) { + // Verify the user has permission to create posts. + if ( $user_id > 0 && ! \user_can( $user_id, 'publish_posts' ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You do not have permission to create posts.', 'activitypub' ), + array( 'status' => 403 ) + ); } - // Post doesn't exist, create new post. - $actor = Remote_Actors::fetch_by_uri( object_to_uri( $activity_object['attributedTo'] ) ); + $object = $activity['object'] ?? array(); - if ( \is_wp_error( $actor ) ) { - return $actor; - } + $object_type = $object['type'] ?? ''; + $content = \wp_kses_post( $object['content'] ?? '' ); + $name = \sanitize_text_field( $object['name'] ?? '' ); + $summary = \wp_kses_post( $object['summary'] ?? '' ); - $post_array = self::activity_to_post( $activity_object ); - $post_id = \wp_insert_post( $post_array, true ); + // Process content: autop, autolink, hashtags, and convert to blocks. + $content = self::prepare_content( $content ); - if ( \is_wp_error( $post_id ) ) { - return $post_id; + // Use name as title for Articles, or generate from content for Notes. + $title = $name; + if ( empty( $title ) && ! empty( $content ) ) { + $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); } - \add_post_meta( $post_id, '_activitypub_remote_actor_id', $actor->ID ); - - // Add recipients as separate meta entries after post is created. - foreach ( $recipients as $user_id ) { - self::add_recipient( $post_id, $user_id ); + // Determine visibility if not provided. + if ( null === $visibility ) { + $visibility = get_content_visibility( $activity ); } - self::add_taxonomies( $post_id, $activity_object ); - - return \get_post( $post_id ); - } - - /** - * Get an object from the collection. - * - * @param int $id The object ID. - * - * @return \WP_Post|null The post object or null on failure. - */ - public static function get( $id ) { - return \get_post( $id ); - } - - /** - * Get an object by its GUID. - * - * @param string $guid The object GUID. - * - * @return \WP_Post|\WP_Error The object post or WP_Error on failure. - */ - public static function get_by_guid( $guid ) { - global $wpdb; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $post_id = $wpdb->get_var( - $wpdb->prepare( - "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s", - \esc_url( $guid ), - self::POST_TYPE - ) + $post_data = array( + 'post_author' => $user_id > 0 ? $user_id : 0, + 'post_title' => $title, + 'post_content' => $content, + 'post_excerpt' => $summary, + 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish', + 'post_type' => 'post', + 'meta_input' => array( + 'activitypub_content_visibility' => $visibility, + ), ); - if ( ! $post_id ) { - return new \WP_Error( - 'activitypub_post_not_found', - \__( 'Post not found', 'activitypub' ), - array( 'status' => 404 ) - ); - } - - return \get_post( $post_id ); - } - - /** - * Update an object in the collection. - * - * @param array $activity The activity object data. - * @param int|int[] $recipients The id(s) of the local blog-user(s). - * - * @return \WP_Post|\WP_Error The updated object post or WP_Error on failure. - */ - public static function update( $activity, $recipients ) { - $recipients = (array) $recipients; - - $post = self::get_by_guid( $activity['object']['id'] ); - if ( \is_wp_error( $post ) ) { - return $post; - } - - $post_array = self::activity_to_post( $activity['object'] ); - $post_array['ID'] = $post->ID; - $post_id = \wp_update_post( $post_array, true ); + $post_id = \wp_insert_post( $post_data, true ); if ( \is_wp_error( $post_id ) ) { return $post_id; } - // Add new recipients using add_recipient (handles deduplication). - foreach ( $recipients as $user_id ) { - self::add_recipient( $post_id, $user_id ); + // Set post format to 'status' for Notes so the transformer maps it back correctly. + if ( 'Note' === $object_type ) { + \set_post_format( $post_id, 'status' ); } - self::add_taxonomies( $post_id, $activity['object'] ); - return \get_post( $post_id ); } /** - * Delete an object from the collection. - * - * @param int $id The object ID. - * - * @return \WP_Post|false|null Post data on success, false or null on failure. - */ - public static function delete( $id ) { - return \wp_delete_post( $id, true ); - } - - /** - * Delete an object from the collection by its GUID. - * - * @param string $guid The object GUID. - * - * @return \WP_Post|\WP_Error|false|null Post data on success, false or null on failure, or WP_Error if no post to delete. - */ - public static function delete_by_guid( $guid ) { - $post = self::get_by_guid( $guid ); - if ( \is_wp_error( $post ) ) { - return $post; - } - - return self::delete( $post->ID ); - } - - /** - * Extract hashtag names from ActivityPub tag array. - * - * @param array $tags Array of ActivityPub tags. - * - * @return array Array of normalized hashtag names (without # prefix, trimmed, sanitized). - */ - public static function extract_hashtags( $tags ) { - $hashtags = array(); - - if ( empty( $tags ) || ! \is_array( $tags ) ) { - return $hashtags; - } - - foreach ( $tags as $tag ) { - if ( isset( $tag['type'] ) && 'Hashtag' === $tag['type'] && isset( $tag['name'] ) ) { - // Strip # prefix, trim whitespace, and sanitize. - $normalized = \trim( \ltrim( $tag['name'], '#' ) ); - $normalized = \wp_strip_all_tags( $normalized ); - - if ( ! empty( $normalized ) ) { - $hashtags[] = $normalized; - } - } - } - - return $hashtags; - } - - /** - * Remove hashtags from content. - * - * Removes hashtags that appear at the end of the content. - * Handles both plain text and HTML content, including hashtags within anchor tags. - * - * @param string $content The content to process. - * @param array $tags Array of tag objects from activity (with 'type' and 'name' keys). - * - * @return string The content with trailing hashtags removed. - */ - public static function remove_hashtags( $content, $tags ) { - if ( empty( $content ) || empty( $tags ) || ! \is_array( $tags ) ) { - return $content; - } - - // Extract and normalize hashtags from tag objects. - $normalized_tags = self::extract_hashtags( $tags ); - - if ( empty( $normalized_tags ) ) { - return $content; - } - - // Build pattern to match trailing hashtags (at end of content or before closing tags). - $tag_patterns = array(); - foreach ( $normalized_tags as $tag ) { - $escaped_tag = \preg_quote( $tag, '/' ); - $tag_patterns[] = '(?:]*>\s*)?#' . $escaped_tag . '(?=\s|<|$)(?:\s*<\/a>)?'; - } - - /* - * Pattern explanation: - * Match one or more hashtags (plain or in anchor tags) at the end of content. - * The pattern matches trailing hashtags before closing HTML tags or at end of string. - */ - $pattern = '/(?:\s+(?:' . \implode( '|', $tag_patterns ) . '))+(?=\s*(?:<\/[^>]+>)*\s*$)/i'; - $content = \preg_replace( $pattern, '', $content ); - - // Clean up any extra whitespace at end of paragraphs. - $content = \preg_replace( '/

\s*<\/p>/', '', $content ); - $content = \preg_replace( '/\s+<\/p>/', '

', $content ); - $content = \preg_replace( '/\s+<\/strong>/', '', $content ); - - return \trim( $content ); - } - - /** - * Convert an activity to a post array. - * - * @param array $activity The activity array. - * - * @return array|\WP_Error The post array or WP_Error on failure. - */ - private static function activity_to_post( $activity ) { - if ( ! \is_array( $activity ) ) { - return new \WP_Error( 'invalid_activity', \__( 'Invalid activity format', 'activitypub' ) ); - } - - $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $activity['published'] ?? 'now' ) ); - - // Sanitize content and remove hashtags. - $content = isset( $activity['content'] ) ? Sanitize::content( $activity['content'] ) : ''; - $content = self::remove_hashtags( $content, $activity['tag'] ?? array() ); - $content = Emoji::wrap_in_content( $content, $activity ); - - // Process remote media: wrap inline images and append attachments. - $attachments = self::extract_attachments( $activity ); - $content = process_remote_media( $content, $attachments ); - - return array( - 'post_title' => isset( $activity['name'] ) ? \wp_strip_all_tags( $activity['name'] ) : '', - 'post_content' => $content, - 'post_excerpt' => isset( $activity['summary'] ) ? \wp_strip_all_tags( $activity['summary'] ) : generate_post_summary( $activity['content'] ?? '' ), - 'post_status' => 'publish', - 'post_type' => self::POST_TYPE, - 'post_date_gmt' => $gm_date, - 'post_date' => \get_date_from_gmt( $gm_date ), - 'guid' => isset( $activity['id'] ) ? \esc_url_raw( $activity['id'] ) : '', - ); - } - - /** - * Add taxonomies to the object post. + * Update a WordPress post from an ActivityPub activity. * - * @param int $post_id The post ID. - * @param array $activity_object The activity object data. - */ - private static function add_taxonomies( $post_id, $activity_object ) { - // Save Object Type as Taxonomy item. - \wp_set_post_terms( $post_id, array( $activity_object['type'] ), 'ap_object_type' ); - - // Save the Hashtags as Taxonomy items. - $tags = self::extract_hashtags( $activity_object['tag'] ?? array() ); - - \wp_set_post_terms( $post_id, $tags, 'ap_tag' ); - } - - /** - * Extract media attachments from an activity object. + * @since unreleased * - * Extracts attachments with URL, alt text, and media type for appending to content. + * @param \WP_Post $post The post to update. + * @param array $activity The activity data. + * @param string|null $visibility Content visibility. * - * @param array $activity_object The activity object data. - * - * @return array Array of attachments with 'url', 'alt', and 'type' keys. + * @return \WP_Post|\WP_Error The updated post on success, WP_Error on failure. */ - private static function extract_attachments( $activity_object ) { - if ( empty( $activity_object['attachment'] ) || ! \is_array( $activity_object['attachment'] ) ) { - return array(); - } - - $attachments = array(); - foreach ( $activity_object['attachment'] as $attachment ) { - if ( \is_object( $attachment ) ) { - $attachment = \get_object_vars( $attachment ); - } + public static function update( $post, $activity, $visibility = null ) { + $object = $activity['object'] ?? array(); - if ( empty( $attachment['url'] ) ) { - continue; - } + $content = \wp_kses_post( $object['content'] ?? '' ); + $name = \sanitize_text_field( $object['name'] ?? '' ); + $summary = \wp_kses_post( $object['summary'] ?? '' ); - $mime_type = $attachment['mediaType'] ?? ''; + // Process content: autop, autolink, hashtags, and convert to blocks. + $content = self::prepare_content( $content ); - if ( \str_starts_with( $mime_type, 'video/' ) ) { - $type = 'video'; - } elseif ( \str_starts_with( $mime_type, 'audio/' ) ) { - $type = 'audio'; - } else { - $type = 'image'; - } - - $attachments[] = array( - 'url' => $attachment['url'], - 'alt' => $attachment['name'] ?? '', - 'type' => $type, - ); + // Use name as title for Articles, or generate from content for Notes. + $title = $name; + if ( empty( $title ) && ! empty( $content ) ) { + $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); } - return $attachments; - } - - /** - * Get posts by remote actor. - * - * @param string $actor The remote actor URI. - * - * @return array Array of WP_Post objects. - */ - public static function get_by_remote_actor( $actor ) { - $remote_actor = Remote_Actors::fetch_by_uri( $actor ); - - if ( \is_wp_error( $remote_actor ) ) { - return array(); + // Determine visibility if not provided. + if ( null === $visibility ) { + $visibility = get_content_visibility( $activity ); } - return self::get_by_remote_actor_id( $remote_actor->ID ); - } - - /** - * Get posts by remote actor ID. - * - * @param int $actor_id The remote actor post ID. - * - * @return array Array of WP_Post objects. - */ - public static function get_by_remote_actor_id( $actor_id ) { - $query = new \WP_Query( - array( - 'post_type' => self::POST_TYPE, - 'posts_per_page' => -1, - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - 'meta_key' => '_activitypub_remote_actor_id', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value - 'meta_value' => $actor_id, - ) + $post_data = array( + 'ID' => $post->ID, + 'post_title' => $title, + 'post_content' => $content, + 'post_excerpt' => $summary, + 'meta_input' => array( + 'activitypub_content_visibility' => $visibility, + ), ); - return $query->posts; - } - - /** - * Get all recipients for a post. - * - * @param int $post_id The post ID. - * - * @return int[] Array of user IDs who are recipients. - */ - public static function get_recipients( $post_id ) { - // Get all meta values with key '_activitypub_user_id' (single => false). - $recipients = \get_post_meta( $post_id, '_activitypub_user_id', false ); - $recipients = \array_map( 'intval', $recipients ); - - return $recipients; - } + $post_id = \wp_update_post( $post_data, true ); - /** - * Check if a user is a recipient of a post. - * - * @param int $post_id The post ID. - * @param int $user_id The user ID to check. - * - * @return bool True if user is a recipient, false otherwise. - */ - public static function has_recipient( $post_id, $user_id ) { - $recipients = self::get_recipients( $post_id ); - - return \in_array( (int) $user_id, $recipients, true ); - } - - /** - * Add a recipient to an existing post. - * - * @param int $post_id The post ID. - * @param int $user_id The user ID to add. - * - * @return bool True on success, false on failure. - */ - public static function add_recipient( $post_id, $user_id ) { - $user_id = (int) $user_id; - // Allow 0 for blog user, but reject negative values. - if ( $user_id < 0 ) { - return false; - } - - // Check if already a recipient. - if ( self::has_recipient( $post_id, $user_id ) ) { - return true; + if ( \is_wp_error( $post_id ) ) { + return $post_id; } - // Add new recipient as separate meta entry. - return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false ); + return \get_post( $post_id ); } /** - * Add multiple recipients to an existing post. + * Delete (trash) a WordPress post. * - * @param int $post_id The post ID. - * @param int[] $user_ids The user ID or array of user IDs to add. - */ - public static function add_recipients( $post_id, $user_ids ) { - foreach ( $user_ids as $user_id ) { - self::add_recipient( $post_id, $user_id ); - } - } - - /** - * Remove a recipient from a post. + * @since unreleased * * @param int $post_id The post ID. - * @param int $user_id The user ID to remove. * - * @return bool True on success, false on failure. + * @return \WP_Post|false|null Post data on success, false or null on failure. */ - public static function remove_recipient( $post_id, $user_id ) { - $user_id = (int) $user_id; - - // Allow 0 for blog user, but reject negative values. - if ( $user_id < 0 ) { - return false; - } - - // Delete the specific meta entry with this value. - return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id ); + public static function delete( $post_id ) { + return \wp_trash_post( $post_id ); } /** - * Delete all posts. - * - * Used during plugin uninstall to clean up all remote posts. + * Prepare content for storage as a WordPress post. * - * @return int The number of posts deleted. - */ - public static function delete_all() { - $post_ids = \get_posts( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => array( 'any', 'trash', 'auto-draft' ), - 'fields' => 'ids', - 'numberposts' => -1, - ) - ); - - foreach ( $post_ids as $post_id ) { - \wp_delete_post( $post_id, true ); - } - - return count( $post_ids ); - } - - /** - * Purge old remote posts. + * Applies wpautop (for plain text), autolinks bare URLs, + * converts hashtags to links, and wraps in block markup. * - * Deletes remote posts older than the specified number of days, - * but preserves posts that have comments from local users - * as these indicate meaningful local interactions. + * @since unreleased * - * @param int $days Number of days to keep items. Items older than this will be deleted. + * @param string $content The HTML or plain-text content. * - * @return int The number of items deleted. + * @return string The processed content with block markup. */ - public static function purge( $days ) { - if ( $days <= 0 ) { - return 0; - } - - $counts = \wp_count_posts( self::POST_TYPE ); - $total = 0; - foreach ( $counts as $count ) { - $total += (int) $count; + public static function prepare_content( $content ) { + if ( empty( $content ) ) { + return ''; } - if ( $total <= 200 ) { - return 0; - } - - global $wpdb; - - $deleted = 0; - $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) ); - $start_time = \time(); - $exclude = array(); - - // If total exceeds the hard cap, drop the date filter to purge oldest items first. - $overflow = $total > self::MAX_ITEMS; - $date_query = array( - array( - 'before' => $cutoff, - ), - ); - - $query_args = array( - 'post_type' => self::POST_TYPE, - 'post_status' => 'any', - 'fields' => 'ids', - 'numberposts' => self::PURGE_BATCH_SIZE, - 'orderby' => 'date', - 'order' => 'ASC', - ); - - if ( ! $overflow ) { - $query_args['date_query'] = $date_query; + // Wrap plain text in paragraphs if it has no block-level HTML. + if ( ! \preg_match( '/<(p|h[1-6]|ul|ol|blockquote|figure|hr|img|div|pre|table)\b/i', $content ) ) { + $content = \wpautop( $content ); } - do { - $query_args['exclude'] = $exclude; - $post_ids = \get_posts( $query_args ); - - if ( empty( $post_ids ) ) { - break; - } - - // Batch-fetch post IDs that have local user comments (single query per batch). - $placeholders = \implode( ',', \array_fill( 0, \count( $post_ids ), '%d' ) ); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $commented_post_ids = $wpdb->get_col( - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders - $wpdb->prepare( "SELECT DISTINCT comment_post_ID FROM $wpdb->comments WHERE comment_post_ID IN ($placeholders) AND user_id > 0", $post_ids ) - ); - $commented_post_ids = \array_flip( $commented_post_ids ); - - foreach ( $post_ids as $post_id ) { - /** - * Filter whether to preserve a specific ap_post from being purged. - * - * @param bool $preserve Whether to preserve this post. Default false. - * @param int $post_id The ap_post ID being considered for deletion. - * - * @return bool Whether to preserve this post from deletion. - */ - if ( \apply_filters( 'activitypub_preserve_ap_post', false, $post_id ) ) { - $exclude[] = $post_id; - continue; - } - - // Preserve posts with comments from local users. - if ( isset( $commented_post_ids[ $post_id ] ) ) { - $exclude[] = $post_id; - continue; - } + // Convert bare URLs to links. + $content = Link::the_content( $content ); - \wp_delete_post( $post_id, true ); - ++$deleted; - } + // Convert #hashtags to links. + $content = Hashtag::the_content( $content ); - // Once we're back under the cap, re-apply the date filter. - if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) { - $overflow = false; - $query_args['date_query'] = $date_query; - } - } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT ); + // Convert HTML to block markup. + $content = Blocks::convert_from_html( $content ); - return $deleted; + return $content; } } diff --git a/includes/collection/class-remote-posts.php b/includes/collection/class-remote-posts.php new file mode 100644 index 0000000000..4dbe8150ea --- /dev/null +++ b/includes/collection/class-remote-posts.php @@ -0,0 +1,619 @@ +ID ); + + // Add recipients as separate meta entries after post is created. + foreach ( $recipients as $user_id ) { + self::add_recipient( $post_id, $user_id ); + } + + self::add_taxonomies( $post_id, $activity_object ); + + return \get_post( $post_id ); + } + + /** + * Get an object from the collection. + * + * @param int $id The object ID. + * + * @return \WP_Post|null The post object or null on failure. + */ + public static function get( $id ) { + return \get_post( $id ); + } + + /** + * Get an object by its GUID. + * + * @param string $guid The object GUID. + * + * @return \WP_Post|\WP_Error The object post or WP_Error on failure. + */ + public static function get_by_guid( $guid ) { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $post_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s", + \esc_url( $guid ), + self::POST_TYPE + ) + ); + + if ( ! $post_id ) { + return new \WP_Error( + 'activitypub_post_not_found', + \__( 'Post not found', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + return \get_post( $post_id ); + } + + /** + * Update an object in the collection. + * + * @param array $activity The activity object data. + * @param int|int[] $recipients The id(s) of the local blog-user(s). + * + * @return \WP_Post|\WP_Error The updated object post or WP_Error on failure. + */ + public static function update( $activity, $recipients ) { + $recipients = (array) $recipients; + + $post = self::get_by_guid( $activity['object']['id'] ); + if ( \is_wp_error( $post ) ) { + return $post; + } + + $post_array = self::activity_to_post( $activity['object'] ); + $post_array['ID'] = $post->ID; + $post_id = \wp_update_post( $post_array, true ); + + if ( \is_wp_error( $post_id ) ) { + return $post_id; + } + + // Add new recipients using add_recipient (handles deduplication). + foreach ( $recipients as $user_id ) { + self::add_recipient( $post_id, $user_id ); + } + + self::add_taxonomies( $post_id, $activity['object'] ); + + return \get_post( $post_id ); + } + + /** + * Delete an object from the collection. + * + * @param int $id The object ID. + * + * @return \WP_Post|false|null Post data on success, false or null on failure. + */ + public static function delete( $id ) { + return \wp_delete_post( $id, true ); + } + + /** + * Delete an object from the collection by its GUID. + * + * @param string $guid The object GUID. + * + * @return \WP_Post|\WP_Error|false|null Post data on success, false or null on failure, or WP_Error if no post to delete. + */ + public static function delete_by_guid( $guid ) { + $post = self::get_by_guid( $guid ); + if ( \is_wp_error( $post ) ) { + return $post; + } + + return self::delete( $post->ID ); + } + + /** + * Extract hashtag names from ActivityPub tag array. + * + * @param array $tags Array of ActivityPub tags. + * + * @return array Array of normalized hashtag names (without # prefix, trimmed, sanitized). + */ + public static function extract_hashtags( $tags ) { + $hashtags = array(); + + if ( empty( $tags ) || ! \is_array( $tags ) ) { + return $hashtags; + } + + foreach ( $tags as $tag ) { + if ( isset( $tag['type'] ) && 'Hashtag' === $tag['type'] && isset( $tag['name'] ) ) { + // Strip # prefix, trim whitespace, and sanitize. + $normalized = \trim( \ltrim( $tag['name'], '#' ) ); + $normalized = \wp_strip_all_tags( $normalized ); + + if ( ! empty( $normalized ) ) { + $hashtags[] = $normalized; + } + } + } + + return $hashtags; + } + + /** + * Remove hashtags from content. + * + * Removes hashtags that appear at the end of the content. + * Handles both plain text and HTML content, including hashtags within anchor tags. + * + * @param string $content The content to process. + * @param array $tags Array of tag objects from activity (with 'type' and 'name' keys). + * + * @return string The content with trailing hashtags removed. + */ + public static function remove_hashtags( $content, $tags ) { + if ( empty( $content ) || empty( $tags ) || ! \is_array( $tags ) ) { + return $content; + } + + // Extract and normalize hashtags from tag objects. + $normalized_tags = self::extract_hashtags( $tags ); + + if ( empty( $normalized_tags ) ) { + return $content; + } + + // Build pattern to match trailing hashtags (at end of content or before closing tags). + $tag_patterns = array(); + foreach ( $normalized_tags as $tag ) { + $escaped_tag = \preg_quote( $tag, '/' ); + $tag_patterns[] = '(?:]*>\s*)?#' . $escaped_tag . '(?=\s|<|$)(?:\s*<\/a>)?'; + } + + /* + * Pattern explanation: + * Match one or more hashtags (plain or in anchor tags) at the end of content. + * The pattern matches trailing hashtags before closing HTML tags or at end of string. + */ + $pattern = '/(?:\s+(?:' . \implode( '|', $tag_patterns ) . '))+(?=\s*(?:<\/[^>]+>)*\s*$)/i'; + $content = \preg_replace( $pattern, '', $content ); + + // Clean up any extra whitespace at end of paragraphs. + $content = \preg_replace( '/

\s*<\/p>/', '', $content ); + $content = \preg_replace( '/\s+<\/p>/', '

', $content ); + $content = \preg_replace( '/\s+<\/strong>/', '', $content ); + + return \trim( $content ); + } + + /** + * Convert an activity to a post array. + * + * @param array $activity The activity array. + * + * @return array|\WP_Error The post array or WP_Error on failure. + */ + private static function activity_to_post( $activity ) { + if ( ! \is_array( $activity ) ) { + return new \WP_Error( 'invalid_activity', \__( 'Invalid activity format', 'activitypub' ) ); + } + + $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $activity['published'] ?? 'now' ) ); + + // Sanitize content and remove hashtags. + $content = isset( $activity['content'] ) ? Sanitize::content( $activity['content'] ) : ''; + $content = self::remove_hashtags( $content, $activity['tag'] ?? array() ); + $content = Emoji::wrap_in_content( $content, $activity ); + + // Process remote media: wrap inline images and append attachments. + $attachments = self::extract_attachments( $activity ); + $content = process_remote_media( $content, $attachments ); + + return array( + 'post_title' => isset( $activity['name'] ) ? \wp_strip_all_tags( $activity['name'] ) : '', + 'post_content' => $content, + 'post_excerpt' => isset( $activity['summary'] ) ? \wp_strip_all_tags( $activity['summary'] ) : generate_post_summary( $activity['content'] ?? '' ), + 'post_status' => 'publish', + 'post_type' => self::POST_TYPE, + 'post_date_gmt' => $gm_date, + 'post_date' => \get_date_from_gmt( $gm_date ), + 'guid' => isset( $activity['id'] ) ? \esc_url_raw( $activity['id'] ) : '', + ); + } + + /** + * Add taxonomies to the object post. + * + * @param int $post_id The post ID. + * @param array $activity_object The activity object data. + */ + private static function add_taxonomies( $post_id, $activity_object ) { + // Save Object Type as Taxonomy item. + \wp_set_post_terms( $post_id, array( $activity_object['type'] ), 'ap_object_type' ); + + // Save the Hashtags as Taxonomy items. + $tags = self::extract_hashtags( $activity_object['tag'] ?? array() ); + + \wp_set_post_terms( $post_id, $tags, 'ap_tag' ); + } + + /** + * Extract media attachments from an activity object. + * + * Extracts attachments with URL, alt text, and media type for appending to content. + * + * @param array $activity_object The activity object data. + * + * @return array Array of attachments with 'url', 'alt', and 'type' keys. + */ + private static function extract_attachments( $activity_object ) { + if ( empty( $activity_object['attachment'] ) || ! \is_array( $activity_object['attachment'] ) ) { + return array(); + } + + $attachments = array(); + foreach ( $activity_object['attachment'] as $attachment ) { + if ( \is_object( $attachment ) ) { + $attachment = \get_object_vars( $attachment ); + } + + if ( empty( $attachment['url'] ) ) { + continue; + } + + $mime_type = $attachment['mediaType'] ?? ''; + + if ( \str_starts_with( $mime_type, 'video/' ) ) { + $type = 'video'; + } elseif ( \str_starts_with( $mime_type, 'audio/' ) ) { + $type = 'audio'; + } else { + $type = 'image'; + } + + $attachments[] = array( + 'url' => $attachment['url'], + 'alt' => $attachment['name'] ?? '', + 'type' => $type, + ); + } + + return $attachments; + } + + /** + * Get posts by remote actor. + * + * @param string $actor The remote actor URI. + * + * @return array Array of WP_Post objects. + */ + public static function get_by_remote_actor( $actor ) { + $remote_actor = Remote_Actors::fetch_by_uri( $actor ); + + if ( \is_wp_error( $remote_actor ) ) { + return array(); + } + + return self::get_by_remote_actor_id( $remote_actor->ID ); + } + + /** + * Get posts by remote actor ID. + * + * @param int $actor_id The remote actor post ID. + * + * @return array Array of WP_Post objects. + */ + public static function get_by_remote_actor_id( $actor_id ) { + $query = new \WP_Query( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => -1, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_key' => '_activitypub_remote_actor_id', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'meta_value' => $actor_id, + ) + ); + + return $query->posts; + } + + /** + * Get all recipients for a post. + * + * @param int $post_id The post ID. + * + * @return int[] Array of user IDs who are recipients. + */ + public static function get_recipients( $post_id ) { + // Get all meta values with key '_activitypub_user_id' (single => false). + $recipients = \get_post_meta( $post_id, '_activitypub_user_id', false ); + $recipients = \array_map( 'intval', $recipients ); + + return $recipients; + } + + /** + * Check if a user is a recipient of a post. + * + * @param int $post_id The post ID. + * @param int $user_id The user ID to check. + * + * @return bool True if user is a recipient, false otherwise. + */ + public static function has_recipient( $post_id, $user_id ) { + $recipients = self::get_recipients( $post_id ); + + return \in_array( (int) $user_id, $recipients, true ); + } + + /** + * Add a recipient to an existing post. + * + * @param int $post_id The post ID. + * @param int $user_id The user ID to add. + * + * @return bool True on success, false on failure. + */ + public static function add_recipient( $post_id, $user_id ) { + $user_id = (int) $user_id; + // Allow 0 for blog user, but reject negative values. + if ( $user_id < 0 ) { + return false; + } + + // Check if already a recipient. + if ( self::has_recipient( $post_id, $user_id ) ) { + return true; + } + + // Add new recipient as separate meta entry. + return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false ); + } + + /** + * Add multiple recipients to an existing post. + * + * @param int $post_id The post ID. + * @param int[] $user_ids The user ID or array of user IDs to add. + */ + public static function add_recipients( $post_id, $user_ids ) { + foreach ( $user_ids as $user_id ) { + self::add_recipient( $post_id, $user_id ); + } + } + + /** + * Remove a recipient from a post. + * + * @param int $post_id The post ID. + * @param int $user_id The user ID to remove. + * + * @return bool True on success, false on failure. + */ + public static function remove_recipient( $post_id, $user_id ) { + $user_id = (int) $user_id; + + // Allow 0 for blog user, but reject negative values. + if ( $user_id < 0 ) { + return false; + } + + // Delete the specific meta entry with this value. + return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id ); + } + + /** + * Delete all posts. + * + * Used during plugin uninstall to clean up all remote posts. + * + * @return int The number of posts deleted. + */ + public static function delete_all() { + $post_ids = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => array( 'any', 'trash', 'auto-draft' ), + 'fields' => 'ids', + 'numberposts' => -1, + ) + ); + + foreach ( $post_ids as $post_id ) { + \wp_delete_post( $post_id, true ); + } + + return count( $post_ids ); + } + + /** + * Purge old remote posts. + * + * Deletes remote posts older than the specified number of days, + * but preserves posts that have comments from local users + * as these indicate meaningful local interactions. + * + * @param int $days Number of days to keep items. Items older than this will be deleted. + * + * @return int The number of items deleted. + */ + public static function purge( $days ) { + if ( $days <= 0 ) { + return 0; + } + + $counts = \wp_count_posts( self::POST_TYPE ); + $total = 0; + foreach ( $counts as $count ) { + $total += (int) $count; + } + + if ( $total <= 200 ) { + return 0; + } + + global $wpdb; + + $deleted = 0; + $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) ); + $start_time = \time(); + $exclude = array(); + + // If total exceeds the hard cap, drop the date filter to purge oldest items first. + $overflow = $total > self::MAX_ITEMS; + $date_query = array( + array( + 'before' => $cutoff, + ), + ); + + $query_args = array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'any', + 'fields' => 'ids', + 'numberposts' => self::PURGE_BATCH_SIZE, + 'orderby' => 'date', + 'order' => 'ASC', + ); + + if ( ! $overflow ) { + $query_args['date_query'] = $date_query; + } + + do { + $query_args['exclude'] = $exclude; + $post_ids = \get_posts( $query_args ); + + if ( empty( $post_ids ) ) { + break; + } + + // Batch-fetch post IDs that have local user comments (single query per batch). + $placeholders = \implode( ',', \array_fill( 0, \count( $post_ids ), '%d' ) ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $commented_post_ids = $wpdb->get_col( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders + $wpdb->prepare( "SELECT DISTINCT comment_post_ID FROM $wpdb->comments WHERE comment_post_ID IN ($placeholders) AND user_id > 0", $post_ids ) + ); + $commented_post_ids = \array_flip( $commented_post_ids ); + + foreach ( $post_ids as $post_id ) { + /** + * Filter whether to preserve a specific ap_post from being purged. + * + * @param bool $preserve Whether to preserve this post. Default false. + * @param int $post_id The ap_post ID being considered for deletion. + * + * @return bool Whether to preserve this post from deletion. + */ + if ( \apply_filters( 'activitypub_preserve_ap_post', false, $post_id ) ) { + $exclude[] = $post_id; + continue; + } + + // Preserve posts with comments from local users. + if ( isset( $commented_post_ids[ $post_id ] ) ) { + $exclude[] = $post_id; + continue; + } + + \wp_delete_post( $post_id, true ); + ++$deleted; + } + + // Once we're back under the cap, re-apply the date filter. + if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) { + $overflow = false; + $query_args['date_query'] = $date_query; + } + } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT ); + + return $deleted; + } +} diff --git a/includes/debug.php b/includes/debug.php index abf2a83775..23459fa6be 100644 --- a/includes/debug.php +++ b/includes/debug.php @@ -9,7 +9,7 @@ use Activitypub\Collection\Inbox; use Activitypub\Collection\Outbox; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; /** * Allow localhost URLs if WP_DEBUG is true. @@ -34,7 +34,7 @@ function allow_localhost( $parsed_args ) { * @return array The arguments for the post type. */ function debug_post_type( $args, $post_type ) { - if ( ! \in_array( $post_type, array( Outbox::POST_TYPE, Inbox::POST_TYPE, Posts::POST_TYPE ), true ) ) { + if ( ! \in_array( $post_type, array( Outbox::POST_TYPE, Inbox::POST_TYPE, Remote_Posts::POST_TYPE ), true ) ) { return $args; } @@ -44,7 +44,7 @@ function debug_post_type( $args, $post_type ) { $args['menu_icon'] = 'dashicons-upload'; } elseif ( Inbox::POST_TYPE === $post_type ) { $args['menu_icon'] = 'dashicons-download'; - } elseif ( Posts::POST_TYPE === $post_type ) { + } elseif ( Remote_Posts::POST_TYPE === $post_type ) { $args['menu_icon'] = 'dashicons-media-document'; } diff --git a/includes/functions-post.php b/includes/functions-post.php index d1fe21c4b4..48ebdb7de4 100644 --- a/includes/functions-post.php +++ b/includes/functions-post.php @@ -9,7 +9,7 @@ namespace Activitypub; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; /** * Check if a post is disabled for ActivityPub. @@ -76,7 +76,7 @@ function is_ap_post( $post ) { } // Check for ap_post post type. - return Posts::POST_TYPE === $post->post_type; + return Remote_Posts::POST_TYPE === $post->post_type; } /** diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index a7ebc94a28..97b7dc9659 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -8,7 +8,7 @@ namespace Activitypub\Handler; use Activitypub\Collection\Interactions; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; use Activitypub\Tombstone; use function Activitypub\get_activity_visibility; @@ -110,7 +110,7 @@ public static function create_post( $activity, $user_ids, $activity_object = nul return false; } - $existing_post = Posts::get_by_guid( $activity['object']['id'] ); + $existing_post = Remote_Posts::get_by_guid( $activity['object']['id'] ); // If post exists, call update action. if ( $existing_post instanceof \WP_Post ) { @@ -119,7 +119,7 @@ public static function create_post( $activity, $user_ids, $activity_object = nul return false; } - return Posts::add( $activity, $user_ids ); + return Remote_Posts::add( $activity, $user_ids ); } /** diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index ccc0f788aa..5e95431aac 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -8,8 +8,8 @@ namespace Activitypub\Handler; use Activitypub\Collection\Interactions; -use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Actors; +use Activitypub\Collection\Remote_Posts; use Activitypub\Tombstone; use function Activitypub\object_to_uri; @@ -220,10 +220,10 @@ public static function delete_interactions( $id ) { * @return bool True on success, false otherwise. */ public static function delete_posts( $id ) { - $posts = Posts::get_by_remote_actor_id( $id ); + $posts = Remote_Posts::get_by_remote_actor_id( $id ); foreach ( $posts as $post ) { - Posts::delete( $post->ID ); + Remote_Posts::delete( $post->ID ); } if ( $posts ) { @@ -273,7 +273,7 @@ public static function maybe_delete_post( $activity ) { // Check if the object exists and is a tombstone. if ( Tombstone::exists( $id ) ) { - return Posts::delete_by_guid( $id ); + return Remote_Posts::delete_by_guid( $id ); } return false; diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index 0e5a6cf6dd..383168b57f 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -8,8 +8,8 @@ namespace Activitypub\Handler; use Activitypub\Collection\Interactions; -use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Actors; +use Activitypub\Collection\Remote_Posts; use function Activitypub\get_remote_metadata_by_actor; use function Activitypub\is_activity_reply; @@ -95,7 +95,7 @@ public static function update_object( $activity, $user_ids, $activity_object ) { $result = \get_comment( $comment_data['comment_ID'] ); } } elseif ( \get_option( 'activitypub_create_posts', false ) ) { - $result = Posts::update( $activity, $user_ids ); + $result = Remote_Posts::update( $activity, $user_ids ); if ( \is_wp_error( $result ) && 'activitypub_post_not_found' === $result->get_error_code() ) { $updated = false; diff --git a/includes/handler/outbox/class-create.php b/includes/handler/outbox/class-create.php index c92695a5e7..cba661735e 100644 --- a/includes/handler/outbox/class-create.php +++ b/includes/handler/outbox/class-create.php @@ -8,9 +8,9 @@ namespace Activitypub\Handler\Outbox; use Activitypub\Collection\Interactions; +use Activitypub\Collection\Posts; use function Activitypub\get_activity_visibility; -use function Activitypub\get_content_visibility; use function Activitypub\is_activity_reply; use function Activitypub\is_quote_activity; @@ -79,58 +79,12 @@ public static function handle_create( $activity, $user_id = null, $visibility = * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure. */ private static function create_post( $activity, $user_id, $visibility ) { - // Verify the user has permission to create posts. - if ( $user_id > 0 && ! \user_can( $user_id, 'publish_posts' ) ) { - return new \WP_Error( - 'activitypub_forbidden', - \__( 'You do not have permission to create posts.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - - $object = $activity['object'] ?? array(); - - $object_type = $object['type'] ?? ''; - $content = \wp_kses_post( $object['content'] ?? '' ); - $name = \sanitize_text_field( $object['name'] ?? '' ); - $summary = \wp_kses_post( $object['summary'] ?? '' ); - - // Use name as title for Articles, or generate from content for Notes. - $title = $name; - if ( empty( $title ) && ! empty( $content ) ) { - $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); - } + $post = Posts::create( $activity, $user_id, $visibility ); - // Determine visibility if not provided. - if ( null === $visibility ) { - $visibility = get_content_visibility( $activity ); + if ( \is_wp_error( $post ) ) { + return $post; } - $post_data = array( - 'post_author' => $user_id > 0 ? $user_id : 0, - 'post_title' => $title, - 'post_content' => $content, - 'post_excerpt' => $summary, - 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish', - 'post_type' => 'post', - 'meta_input' => array( - 'activitypub_content_visibility' => $visibility, - ), - ); - - $post_id = \wp_insert_post( $post_data, true ); - - if ( \is_wp_error( $post_id ) ) { - return $post_id; - } - - // Set post format to 'status' for Notes so the transformer maps it back correctly. - if ( 'Note' === $object_type ) { - \set_post_format( $post_id, 'status' ); - } - - $post = \get_post( $post_id ); - /** * Fires after a post has been created from an outgoing Create activity. * @@ -139,7 +93,7 @@ private static function create_post( $activity, $user_id, $visibility ) { * @param int $user_id The user ID. * @param string $visibility The content visibility. */ - \do_action( 'activitypub_outbox_created_post', $post_id, $activity, $user_id, $visibility ); + \do_action( 'activitypub_outbox_created_post', $post->ID, $activity, $user_id, $visibility ); return $post; } diff --git a/includes/handler/outbox/class-delete.php b/includes/handler/outbox/class-delete.php index ed8218dfa0..b4944bef3c 100644 --- a/includes/handler/outbox/class-delete.php +++ b/includes/handler/outbox/class-delete.php @@ -7,7 +7,7 @@ namespace Activitypub\Handler\Outbox; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; use function Activitypub\object_to_uri; use function Activitypub\url_to_commentid; @@ -109,7 +109,7 @@ private static function maybe_delete_post( $object_id, $user_id ) { // Fall back to Posts collection for remote posts (ap_post type). if ( ! $post instanceof \WP_Post ) { - $post = Posts::get_by_guid( $object_id ); + $post = Remote_Posts::get_by_guid( $object_id ); } if ( ! $post instanceof \WP_Post ) { diff --git a/includes/handler/outbox/class-update.php b/includes/handler/outbox/class-update.php index c1bde78ba8..7b64665167 100644 --- a/includes/handler/outbox/class-update.php +++ b/includes/handler/outbox/class-update.php @@ -8,9 +8,9 @@ namespace Activitypub\Handler\Outbox; use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; use function Activitypub\get_activity_visibility; -use function Activitypub\get_content_visibility; /** * Handle outgoing Update activities (C2S). @@ -69,7 +69,7 @@ public static function handle_update( $activity, $user_id = null, $visibility = // Fall back to Posts collection for remote posts (ap_post type). if ( ! $post instanceof \WP_Post ) { - $post = Posts::get_by_guid( $object_id ); + $post = Remote_Posts::get_by_guid( $object_id ); } if ( ! $post instanceof \WP_Post ) { @@ -94,39 +94,12 @@ public static function handle_update( $activity, $user_id = null, $visibility = ); } - $content = \wp_kses_post( $object['content'] ?? '' ); - $name = \sanitize_text_field( $object['name'] ?? '' ); - $summary = \wp_kses_post( $object['summary'] ?? '' ); + $post = Posts::update( $post, $activity, $visibility ); - // Use name as title for Articles, or generate from content for Notes. - $title = $name; - if ( empty( $title ) && ! empty( $content ) ) { - $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' ); - } - - // Determine visibility if not provided. - if ( null === $visibility ) { - $visibility = get_content_visibility( $activity ); - } - - $post_data = array( - 'ID' => $post->ID, - 'post_title' => $title, - 'post_content' => $content, - 'post_excerpt' => $summary, - 'meta_input' => array( - 'activitypub_content_visibility' => $visibility, - ), - ); - - $post_id = \wp_update_post( $post_data, true ); - - if ( \is_wp_error( $post_id ) ) { + if ( \is_wp_error( $post ) ) { return null; } - $post = \get_post( $post_id ); - /** * Fires after a post has been updated from an outgoing Update activity. * @@ -134,7 +107,7 @@ public static function handle_update( $activity, $user_id = null, $visibility = * @param array $activity The activity data. * @param int $user_id The user ID. */ - \do_action( 'activitypub_outbox_updated_post', $post_id, $activity, $user_id ); + \do_action( 'activitypub_outbox_updated_post', $post->ID, $activity, $user_id ); return $post; } diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php index 03372c3bbf..7a3c0afb24 100644 --- a/tests/phpunit/tests/includes/class-test-blocks.php +++ b/tests/phpunit/tests/includes/class-test-blocks.php @@ -902,6 +902,103 @@ public function test_render_reply_block_embed_respects_content_width() { $this->assertSame( 800, $captured_width, 'Width should use $content_width when available' ); } + /** + * Data provider for convert_from_html tests. + * + * @return array[] Each entry: [ html, expected_output ]. + */ + public function data_convert_from_html() { + return array( + 'empty string' => array( + '', + '', + ), + 'single paragraph' => array( + '

Hello world

', + '

Hello world

', + ), + 'two paragraphs' => array( + '

First

Second

', + '

First

' + . '

Second

', + ), + 'heading h1' => array( + '

Title

', + '

Title

', + ), + 'heading h3' => array( + '

Subtitle

', + '

Subtitle

', + ), + 'unordered list' => array( + '
  • One
  • Two
', + '
  • One
  • Two
', + ), + 'ordered list' => array( + '
  1. First
  2. Second
', + '
  1. First
  2. Second
', + ), + 'blockquote' => array( + '

A quote

', + '

A quote

', + ), + 'separator' => array( + '
', + '
', + ), + 'image' => array( + 'A photo', + 'A photo', + ), + 'figure with caption' => array( + '
Caption
', + '
Caption
', + ), + 'inline in paragraph' => array( + '

Visit my site and enjoy

', + '

Visit my site and enjoy

', + ), + 'skips br' => array( + '

Hello


World

', + '

Hello

' + . '

World

', + ), + 'nested list' => array( + '
  • Parent
    • Child
', + '
  • Parent
    • Child
', + ), + 'bare inline span' => array( + 'Some text', + 'Some text', + ), + 'unknown tag' => array( + '
Custom content
', + '
Custom content
', + ), + 'mixed content' => array( + '

Title

Text

  • Item

Quote

', + '

Title

' + . '

Text

' + . '
  • Item
' + . '
' + . '

Quote

', + ), + ); + } + + /** + * Test convert_from_html. + * + * @dataProvider data_convert_from_html + * @covers ::convert_from_html + * + * @param string $html The input HTML. + * @param string $expected The expected block markup. + */ + public function test_convert_from_html( $html, $expected ) { + $this->assertSame( $expected, Blocks::convert_from_html( $html ) ); + } + /** * Test Extra Fields block preserves HTML in field content. * diff --git a/tests/phpunit/tests/includes/class-test-comment.php b/tests/phpunit/tests/includes/class-test-comment.php index a314722256..2c7e06db0d 100644 --- a/tests/phpunit/tests/includes/class-test-comment.php +++ b/tests/phpunit/tests/includes/class-test-comment.php @@ -7,7 +7,7 @@ namespace Activitypub\Tests; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; use Activitypub\Comment; /** @@ -1063,7 +1063,7 @@ public function test_hide_for() { $post_types = Comment::hide_for(); $this->assertIsArray( $post_types ); - $this->assertContains( Posts::POST_TYPE, $post_types, 'ap_post should be in the list of post types to hide comments for' ); + $this->assertContains( Remote_Posts::POST_TYPE, $post_types, 'ap_post should be in the list of post types to hide comments for' ); $this->assertCount( 1, $post_types, 'Only ap_post should be in the default list' ); } @@ -1083,7 +1083,7 @@ public function test_hide_for_filter_can_add_post_types() { $post_types = Comment::hide_for(); $this->assertContains( 'custom_post_type', $post_types, 'Filter should be able to add custom post types' ); - $this->assertContains( Posts::POST_TYPE, $post_types, 'ap_post should still be in the list' ); + $this->assertContains( Remote_Posts::POST_TYPE, $post_types, 'ap_post should still be in the list' ); \remove_filter( 'activitypub_hide_comments_for', $filter ); } @@ -1095,14 +1095,14 @@ public function test_hide_for_filter_can_add_post_types() { */ public function test_hide_for_filter_can_remove_post_types() { $filter = function ( $post_types ) { - return array_diff( $post_types, array( Posts::POST_TYPE ) ); + return array_diff( $post_types, array( Remote_Posts::POST_TYPE ) ); }; \add_filter( 'activitypub_hide_comments_for', $filter ); $post_types = Comment::hide_for(); - $this->assertNotContains( Posts::POST_TYPE, $post_types, 'Filter should be able to remove ap_post from the list' ); + $this->assertNotContains( Remote_Posts::POST_TYPE, $post_types, 'Filter should be able to remove ap_post from the list' ); \remove_filter( 'activitypub_hide_comments_for', $filter ); } diff --git a/tests/phpunit/tests/includes/class-test-scheduler.php b/tests/phpunit/tests/includes/class-test-scheduler.php index 79adde29f9..b467fdc884 100644 --- a/tests/phpunit/tests/includes/class-test-scheduler.php +++ b/tests/phpunit/tests/includes/class-test-scheduler.php @@ -12,8 +12,8 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Inbox; use Activitypub\Collection\Outbox; -use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Actors; +use Activitypub\Collection\Remote_Posts; use Activitypub\Comment; use Activitypub\Dispatcher; use Activitypub\Migration; @@ -707,7 +707,7 @@ public function test_purge_ap_posts_more_than_200_posts() { self::factory()->post->create_many( 20, array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'publish', 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-7 months' ) ), ) @@ -717,7 +717,7 @@ public function test_purge_ap_posts_more_than_200_posts() { self::factory()->post->create_many( 5, array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'publish', 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 week' ) ), ) @@ -725,7 +725,7 @@ public function test_purge_ap_posts_more_than_200_posts() { // Mock the count to exceed the 200-post threshold. $wp_count_posts_callback = function ( $counts, $type ) { - if ( Posts::POST_TYPE === $type ) { + if ( Remote_Posts::POST_TYPE === $type ) { $counts->publish = 225; } return $counts; @@ -733,7 +733,7 @@ public function test_purge_ap_posts_more_than_200_posts() { \add_filter( 'wp_count_posts', $wp_count_posts_callback, 10, 2 ); Scheduler::purge_ap_posts(); - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); + \wp_cache_delete( \_count_posts_cache_key( Remote_Posts::POST_TYPE ), 'counts' ); // Remove filter before checking actual count. \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); @@ -741,7 +741,7 @@ public function test_purge_ap_posts_more_than_200_posts() { // Assert that 20 old posts were deleted, leaving 5. $actual_count = \get_posts( array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'publish', 'numberposts' => -1, 'fields' => 'ids', @@ -760,17 +760,17 @@ public function test_purge_ap_posts_200_or_fewer_posts() { self::factory()->post->create_many( 20, array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'publish', 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-13 months' ) ), ) ); Scheduler::purge_ap_posts(); - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); + \wp_cache_delete( \_count_posts_cache_key( Remote_Posts::POST_TYPE ), 'counts' ); // Assert that no posts were deleted (below threshold). - $this->assertEquals( 20, \wp_count_posts( Posts::POST_TYPE )->publish ); + $this->assertEquals( 20, \wp_count_posts( Remote_Posts::POST_TYPE )->publish ); } /** @@ -782,7 +782,7 @@ public function test_purge_ap_posts_preserves_posts_with_comments() { // Create an old post without comments (will be deleted). $post_without_comments = self::factory()->post->create( array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'publish', 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-7 months' ) ), ) @@ -791,7 +791,7 @@ public function test_purge_ap_posts_preserves_posts_with_comments() { // Create an old post with a comment (will be preserved). $post_with_comment = self::factory()->post->create( array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'publish', 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-7 months' ) ), ) @@ -809,7 +809,7 @@ public function test_purge_ap_posts_preserves_posts_with_comments() { // Mock the count to exceed the 200-post threshold. $wp_count_posts_callback = function ( $counts, $type ) { - if ( Posts::POST_TYPE === $type ) { + if ( Remote_Posts::POST_TYPE === $type ) { $counts->publish = 225; } return $counts; @@ -817,7 +817,7 @@ public function test_purge_ap_posts_preserves_posts_with_comments() { \add_filter( 'wp_count_posts', $wp_count_posts_callback, 10, 2 ); Scheduler::purge_ap_posts(); - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); + \wp_cache_delete( \_count_posts_cache_key( Remote_Posts::POST_TYPE ), 'counts' ); // Remove filter. \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); @@ -839,7 +839,7 @@ public function test_purge_ap_posts_with_different_purge_days() { self::factory()->post->create_many( 25, array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-2 months' ) ), 'post_status' => 'publish', ) @@ -850,7 +850,7 @@ public function test_purge_ap_posts_with_different_purge_days() { // Mock the count to exceed the 200-post threshold. $wp_count_posts_callback = function ( $counts, $type ) { - if ( Posts::POST_TYPE === $type ) { + if ( Remote_Posts::POST_TYPE === $type ) { $counts->publish = 225; } return $counts; @@ -859,13 +859,13 @@ public function test_purge_ap_posts_with_different_purge_days() { // Run purge_ap_posts with 180 days retention. Scheduler::purge_ap_posts(); - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); + \wp_cache_delete( \_count_posts_cache_key( Remote_Posts::POST_TYPE ), 'counts' ); // Remove filter before checking actual count. \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); // Verify posts are not deleted (2 months < 180 days). - $this->assertEquals( 25, \wp_count_posts( Posts::POST_TYPE )->publish ); + $this->assertEquals( 25, \wp_count_posts( Remote_Posts::POST_TYPE )->publish ); // Change the purge days option to 30 days. \update_option( 'activitypub_ap_post_purge_days', 30 ); @@ -875,12 +875,12 @@ public function test_purge_ap_posts_with_different_purge_days() { // Run purge_ap_posts with changed days. Scheduler::purge_ap_posts(); - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); + \wp_cache_delete( \_count_posts_cache_key( Remote_Posts::POST_TYPE ), 'counts' ); // Remove filter before checking actual count. \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); // Verify posts are deleted (2 months > 30 days). - $this->assertEquals( 0, \wp_count_posts( Posts::POST_TYPE )->publish ); + $this->assertEquals( 0, \wp_count_posts( Remote_Posts::POST_TYPE )->publish ); } } diff --git a/tests/phpunit/tests/includes/collection/class-test-posts.php b/tests/phpunit/tests/includes/collection/class-test-posts.php index 468c63e142..33da09566b 100644 --- a/tests/phpunit/tests/includes/collection/class-test-posts.php +++ b/tests/phpunit/tests/includes/collection/class-test-posts.php @@ -7,11 +7,7 @@ namespace Activitypub\Tests\Collection; -use Activitypub\Cache\Media; use Activitypub\Collection\Posts; -use Activitypub\Post_Types; - -use function Activitypub\object_to_uri; /** * Posts Collection Test Class. @@ -21,1894 +17,347 @@ class Test_Posts extends \WP_UnitTestCase { /** - * Set up test environment. - */ - public function set_up() { - parent::set_up(); - - // Register required post types. - Post_Types::register_remote_actors_post_type(); - Post_Types::register_post_post_type(); - - // Initialize cache to register hooks. - Media::init(); - - // Mock HTTP requests for Remote_Actors::fetch_by_uri. - \add_filter( 'pre_http_request', array( $this, 'mock_http_request' ), 10, 3 ); - - // Also hook into the ActivityPub-specific filter to bypass URL validation. - \add_filter( 'activitypub_pre_http_get_remote_object', array( $this, 'mock_remote_object' ), 10, 2 ); - - // Short-circuit file downloads in tests by providing pre-downloaded files. - \add_filter( 'activitypub_pre_download_url', array( $this, 'mock_file_download' ), 10, 3 ); - } - - /** - * Tear down test environment. - */ - public function tear_down() { - \remove_filter( 'pre_http_request', array( $this, 'mock_http_request' ) ); - \remove_filter( 'activitypub_pre_http_get_remote_object', array( $this, 'mock_remote_object' ) ); - \remove_filter( 'activitypub_pre_download_url', array( $this, 'mock_file_download' ) ); - - $this->remove_added_uploads(); - - parent::tear_down(); - } - - /** - * Render post content to trigger lazy media caching. - * - * Sets up global post context, renders blocks, then cleans up. - * - * @param \WP_Post $the_post The post to render. - */ - protected function render_post_content( $the_post ) { - global $post; - $post = $the_post; - \setup_postdata( $the_post ); - \do_blocks( $the_post->post_content ); - \wp_reset_postdata(); - } - - /** - * Mock remote object fetching to bypass URL validation. - * - * @param mixed $response The response to return. - * @param string $url_or_object The URL or object being fetched. - * @return mixed The mocked response or null to continue. - */ - public function mock_remote_object( $response, $url_or_object ) { - if ( 'https://example.com/users/testuser' === object_to_uri( $url_or_object ) ) { - return array( - 'id' => 'https://example.com/users/testuser', - 'type' => 'Person', - 'name' => 'Test Actor', - 'preferredUsername' => 'testuser', - 'summary' => 'A test actor', - 'url' => 'https://example.com/users/testuser', - 'inbox' => 'https://example.com/users/testuser/inbox', - 'outbox' => 'https://example.com/users/testuser/outbox', - ); - } - - return $response; - } - - /** - * Mock file downloads by providing pre-downloaded test files. - * - * This short-circuits the entire download process, avoiding DNS lookups - * and URL validation that may fail in CI environments. - * - * @param array|null $result The pre-download result. - * @param string $url The URL being downloaded. - * @param string $type The cache type. - * @return array|null Array with file and mime_type for test URLs, null otherwise. - */ - public function mock_file_download( $result, $url, $type ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required for filter signature. - // Handle all example.com image URLs. - if ( str_starts_with( $url, 'https://example.com/' ) && preg_match( '/\.(jpg|jpeg|png|gif|webp)$/i', $url ) ) { - // Copy test asset to temp file. - $tmp_file = \wp_tempnam( 'test-image.jpg' ); - copy( AP_TESTS_DIR . '/data/assets/test.jpg', $tmp_file ); - - return array( - 'file' => $tmp_file, - 'mime_type' => 'image/jpeg', - ); - } - - return $result; - } - - /** - * Mock HTTP requests for remote actor fetching and attachment downloads. + * Test creating a post from an Article activity. * - * @param mixed $response The response to return. - * @param array $parsed_args The parsed arguments. - * @param string $url The URL being requested. - * @return mixed The mocked response or original response. + * @covers ::create */ - public function mock_http_request( $response, $parsed_args, $url ) { - if ( 'https://example.com/users/testuser' === $url ) { - return array( - 'response' => array( 'code' => 200 ), - 'body' => wp_json_encode( - array( - 'id' => 'https://example.com/users/testuser', - 'type' => 'Person', - 'name' => 'Test Actor', - 'preferredUsername' => 'testuser', - 'summary' => 'A test actor', - 'url' => 'https://example.com/users/testuser', - 'inbox' => 'https://example.com/users/testuser/inbox', - 'outbox' => 'https://example.com/users/testuser/outbox', - ) - ), - ); - } - - if ( 'https://nonexistent.com/users/unknown' === $url ) { - return new \WP_Error( 'http_request_failed', 'Could not resolve host' ); - } - - // Mock attachment downloads. - if ( 'https://example.com/image.jpg' === $url && isset( $parsed_args['filename'] ) ) { - copy( AP_TESTS_DIR . '/data/assets/test.jpg', $parsed_args['filename'] ); - - return array( - 'response' => array( 'code' => 200 ), - 'headers' => array( 'content-type' => 'image/jpeg' ), - ); - } - - return $response; - } - - /** - * Test adding an object to the collection. - * - * @covers ::add - */ - public function test_add() { + public function test_create_article() { + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'object' => array( - 'id' => 'https://example.com/objects/123', - 'type' => 'Note', - 'name' => 'Test Object', - 'content' => '

This is a test object content

', - 'summary' => 'Test summary', - 'attributedTo' => 'https://example.com/users/testuser', - 'published' => '2023-01-01T12:00:00Z', + 'type' => 'Article', + 'name' => 'My Article Title', + 'content' => '

Article content here.

', + 'summary' => 'A short summary.', ), ); - $result = Posts::add( $activity, 1 ); + $post = Posts::create( $activity, $user_id ); - $this->assertInstanceOf( '\WP_Post', $result ); - $this->assertEquals( 'Test Object', $result->post_title ); - $this->assertEquals( Posts::POST_TYPE, $result->post_type ); - $this->assertEquals( 'publish', $result->post_status ); - $this->assertEquals( 'https://example.com/objects/123', $result->guid ); + $this->assertInstanceOf( '\WP_Post', $post ); + $this->assertEquals( 'My Article Title', $post->post_title ); + $this->assertEquals( 'A short summary.', $post->post_excerpt ); + $this->assertEquals( 'post', $post->post_type ); + $this->assertEquals( 'publish', $post->post_status ); + $this->assertEquals( $user_id, (int) $post->post_author ); } /** - * Test updating an existing object. + * Test creating a post from a Note activity sets status format. * - * @covers ::update + * @covers ::create */ - public function test_update() { - // First, create an object. + public function test_create_note_sets_status_format() { + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'object' => array( - 'id' => 'https://example.com/objects/456', - 'type' => 'Note', - 'name' => 'Original Title', - 'content' => '

Original content

', - 'attributedTo' => 'https://example.com/users/testuser', - ), - ); - - $original_post = Posts::add( $activity, 1 ); - $this->assertInstanceOf( '\WP_Post', $original_post ); - - // Now update it. - $update_activity = array( - 'object' => array( - 'id' => 'https://example.com/objects/456', 'type' => 'Note', - 'name' => 'Updated Title', - 'content' => '

Updated content

', + 'content' => '

A short note.

', ), ); - $updated_post = Posts::update( $update_activity, 1 ); + $post = Posts::create( $activity, $user_id ); - $this->assertInstanceOf( '\WP_Post', $updated_post ); - $this->assertEquals( 'Updated Title', $updated_post->post_title ); - $this->assertStringContainsString( 'Updated content', $updated_post->post_content ); - $this->assertEquals( $original_post->ID, $updated_post->ID ); + $this->assertInstanceOf( '\WP_Post', $post ); + $this->assertEquals( 'status', \get_post_format( $post->ID ) ); } /** - * Test updating a non-existent object. + * Test creating a post generates title from content when name is missing. * - * @covers ::update + * @covers ::create */ - public function test_update_nonexistent() { + public function test_create_generates_title_from_content() { + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'object' => array( - 'id' => 'https://example.com/objects/nonexistent', 'type' => 'Note', - 'name' => 'Updated Title', - 'content' => '

Updated content

', + 'content' => '

This is a note without a name field so title should be generated.

', ), ); - $result = Posts::update( $activity, 1 ); - - $this->assertInstanceOf( '\WP_Error', $result ); - } + $post = Posts::create( $activity, $user_id ); - /** - * Test getting an object by GUID. - * - * @covers ::get_by_guid - */ - public function test_get_by_guid() { - // Create an object. - $activity = array( - 'object' => array( - 'id' => 'https://example.com/objects/789', - 'type' => 'Note', - 'name' => 'Test Object', - 'content' => '

Test content

', - 'attributedTo' => 'https://example.com/users/testuser', - ), - ); - - $post = Posts::add( $activity, 1 ); $this->assertInstanceOf( '\WP_Post', $post ); - - // Test retrieval. - $retrieved_post = Posts::get_by_guid( 'https://example.com/objects/789' ); - - $this->assertInstanceOf( '\WP_Post', $retrieved_post ); - $this->assertEquals( $post->ID, $retrieved_post->ID ); - $this->assertEquals( 'Test Object', $retrieved_post->post_title ); - } - - /** - * Test getting a non-existent object by GUID. - * - * @covers ::get_by_guid - */ - public function test_get_by_guid_nonexistent() { - $result = Posts::get_by_guid( 'https://example.com/objects/nonexistent' ); - - $this->assertInstanceOf( '\WP_Error', $result ); - } - - /** - * Test activity to post conversion. - * - * @covers ::activity_to_post - */ - public function test_activity_to_post() { - $activity = array( - 'id' => 'https://example.com/objects/test', - 'type' => 'Note', - 'name' => 'Test Title', - 'content' => '

Test content with HTML

', - 'summary' => 'Test summary', - 'published' => '2023-01-01T12:00:00Z', - ); - - // Use reflection to access the private method. - $reflection = new \ReflectionClass( Posts::class ); - $method = $reflection->getMethod( 'activity_to_post' ); - if ( \PHP_VERSION_ID < 80100 ) { - $method->setAccessible( true ); - } - - try { - $result = $method->invoke( null, $activity ); - } catch ( \Exception $exception ) { - $result = $exception; - } - - $this->assertIsArray( $result ); - $this->assertEquals( 'Test Title', $result['post_title'] ); - $this->assertEquals( 'Test summary', $result['post_excerpt'] ); - $this->assertEquals( Posts::POST_TYPE, $result['post_type'] ); - $this->assertEquals( 'publish', $result['post_status'] ); - $this->assertEquals( 'https://example.com/objects/test', $result['guid'] ); - $this->assertStringContainsString( 'Test content', $result['post_content'] ); + $this->assertNotEmpty( $post->post_title ); } /** - * Test activity to post conversion with invalid data. + * Test creating a post with private visibility. * - * @covers ::activity_to_post + * @covers ::create */ - public function test_activity_to_post_invalid() { - // Use reflection to access the private method. - $reflection = new \ReflectionClass( Posts::class ); - $method = $reflection->getMethod( 'activity_to_post' ); - if ( \PHP_VERSION_ID < 80100 ) { - $method->setAccessible( true ); - } - - try { - $result = $method->invoke( null, 'invalid_data' ); - } catch ( \Exception $exception ) { - $result = $exception; - } - - $this->assertInstanceOf( '\WP_Error', $result ); - } - - /** - * Test activity to post conversion with minimal data. - * - * @covers ::activity_to_post - */ - public function test_activity_to_post_minimal() { - $activity = array( - 'type' => 'Note', - 'content' => '

Minimal content for excerpt generation

', - ); - - // Use reflection to access the private method. - $reflection = new \ReflectionClass( Posts::class ); - $method = $reflection->getMethod( 'activity_to_post' ); - if ( \PHP_VERSION_ID < 80100 ) { - $method->setAccessible( true ); - } - - try { - $result = $method->invoke( null, $activity ); - } catch ( \Exception $exception ) { - $result = $exception; - } - - $this->assertIsArray( $result ); - $this->assertEquals( '', $result['post_title'] ); - $this->assertStringContainsString( 'Minimal content', $result['post_content'] ); - // Note: generate_post_summary() expects a WP_Post object, so passing $activity['content'] - // returns empty. WordPress will auto-generate the excerpt from content after post creation. - $this->assertEquals( '', $result['post_excerpt'] ); - $this->assertEquals( Posts::POST_TYPE, $result['post_type'] ); - $this->assertEquals( 'publish', $result['post_status'] ); - } - - /** - * Test that published timestamp is preserved when creating posts. - * - * @covers ::activity_to_post - * @covers ::add - */ - public function test_preserves_published_timestamp() { + public function test_create_private_post() { + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'object' => array( - 'id' => 'https://example.com/objects/timestamp-test', - 'type' => 'Note', - 'name' => 'Timestamp Test', - 'content' => '

Test content

', - 'attributedTo' => 'https://example.com/users/testuser', - 'published' => '2023-06-15T14:30:00Z', - ), - ); - - $result = Posts::add( $activity, 1 ); - - $this->assertInstanceOf( '\WP_Post', $result ); - $this->assertEquals( '2023-06-15 14:30:00', $result->post_date_gmt ); - $this->assertEquals( \get_date_from_gmt( '2023-06-15 14:30:00' ), $result->post_date ); - } - - /** - * Test that activity_to_post handles missing content gracefully. - * - * @covers ::activity_to_post - */ - public function test_activity_to_post_missing_content() { - $activity = array( - 'type' => 'Note', - 'name' => 'Title Only', - 'summary' => 'Summary text', - ); - - // Use reflection to access the private method. - $reflection = new \ReflectionClass( Posts::class ); - $method = $reflection->getMethod( 'activity_to_post' ); - if ( \PHP_VERSION_ID < 80100 ) { - $method->setAccessible( true ); - } - - try { - $result = $method->invoke( null, $activity ); - } catch ( \Exception $exception ) { - $result = $exception; - } - - $this->assertIsArray( $result ); - $this->assertEquals( 'Title Only', $result['post_title'] ); - $this->assertEquals( '', $result['post_content'] ); - $this->assertEquals( 'Summary text', $result['post_excerpt'] ); - } - - /** - * Test adding an object with multiple recipients. - * - * @covers ::add - * @covers ::get_recipients - */ - public function test_add_with_multiple_recipients() { - $activity = array( - 'object' => array( - 'id' => 'https://example.com/objects/multi-user', - 'type' => 'Note', - 'name' => 'Multi-User Post', - 'content' => '

This post is for multiple users

', - 'attributedTo' => 'https://example.com/users/testuser', - ), - ); - - $result = Posts::add( $activity, array( 1, 2, 3 ) ); - - $this->assertInstanceOf( '\WP_Post', $result ); - $this->assertEquals( 'Multi-User Post', $result->post_title ); - - // Verify all recipients were added. - $recipients = Posts::get_recipients( $result->ID ); - $this->assertCount( 3, $recipients ); - $this->assertContains( 1, $recipients ); - $this->assertContains( 2, $recipients ); - $this->assertContains( 3, $recipients ); - } - - /** - * Test adding an object with attachments. - * - * @covers ::add - */ - public function test_add_with_attachments() { - $activity = array( - 'object' => array( - 'id' => 'https://example.com/objects/with-attachment', - 'type' => 'Note', - 'name' => 'Post with Image', - // Real ActivityPub content includes img tags. - 'content' => '

Test content

Test Image

', - 'attributedTo' => 'https://example.com/users/testuser', - 'attachment' => array( - array( - 'url' => 'https://example.com/image.jpg', - 'mediaType' => 'image/jpeg', - 'name' => 'Test Image', - 'type' => 'Image', - ), - ), - ), - ); - - $result = Posts::add( $activity, 1 ); - - $this->assertInstanceOf( '\WP_Post', $result ); - $this->assertEquals( 'Post with Image', $result->post_title ); - - // Verify content has the media block with original URL. - $this->assertStringContainsString( '', $post->post_content ); } /** - * Test that add with existing post calls update instead of creating duplicate. + * Test updating a post. * - * @covers ::add * @covers ::update - * @covers ::get_recipients */ - public function test_add_existing_post_adds_recipients() { + public function test_update() { + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $activity = array( 'object' => array( - 'id' => 'https://example.com/objects/existing-post', - 'type' => 'Note', - 'name' => 'Existing Post', - 'content' => '

Test existing post

', - 'attributedTo' => 'https://example.com/users/testuser', + 'type' => 'Article', + 'name' => 'Original Title', + 'content' => '

Original content.

', + 'summary' => 'Original summary.', ), ); - // First add. - $post1 = Posts::add( $activity, 1 ); - $this->assertInstanceOf( '\WP_Post', $post1 ); - - // Second add with same activity ID but different recipient. - $post2 = Posts::add( $activity, 2 ); - $this->assertInstanceOf( '\WP_Post', $post2 ); - - // Should be the same post. - $this->assertEquals( $post1->ID, $post2->ID ); - - // Should have both recipients. - $recipients = Posts::get_recipients( $post1->ID ); - $this->assertCount( 2, $recipients ); - $this->assertContains( 1, $recipients ); - $this->assertContains( 2, $recipients ); - - // Verify only one post exists with this GUID. - $posts = \get_posts( - array( - 'post_type' => Posts::POST_TYPE, - 'guid' => 'https://example.com/objects/existing-post', - 'posts_per_page' => -1, - ) - ); - $this->assertCount( 1, $posts ); - // Verify no attachments initially. - $attachments = \get_attached_media( '', $post1->ID ); - $this->assertEmpty( $attachments ); + $post = Posts::create( $activity, $user_id ); + $this->assertInstanceOf( '\WP_Post', $post ); - // Update with attachments. $update_activity = array( 'object' => array( - 'id' => 'https://example.com/objects/existing-post', - 'type' => 'Note', - 'name' => 'Updated Post', - 'content' => '

Updated content

', - 'attachment' => array( - array( - 'url' => 'https://example.com/image.jpg', - 'mediaType' => 'image/jpeg', - 'name' => 'New Image', - 'type' => 'Image', - ), - ), + 'type' => 'Article', + 'name' => 'Updated Title', + 'content' => '

Updated content.

', + 'summary' => 'Updated summary.', ), ); - $updated_post = Posts::update( $update_activity, 1 ); - $this->assertInstanceOf( '\WP_Post', $updated_post ); - - // Verify attachment was added to content as media block. - $this->assertStringContainsString( '', $updated->post_content ); } /** - * Test extracting hashtags from empty array. + * Test deleting (trashing) a post. * - * @covers ::extract_hashtags + * @covers ::delete */ - public function test_extract_hashtags_from_empty_array() { - $result = Posts::extract_hashtags( array() ); + public function test_delete() { + $post_id = self::factory()->post->create(); - $this->assertIsArray( $result ); - $this->assertEmpty( $result ); - } - - /** - * Test extracting hashtags from null value. - * - * @covers ::extract_hashtags - */ - public function test_extract_hashtags_from_null() { - $result = Posts::extract_hashtags( null ); - - $this->assertIsArray( $result ); - $this->assertEmpty( $result ); - } - - /** - * Test extracting hashtags when tags have no name field. - * - * @covers ::extract_hashtags - */ - public function test_extract_hashtags_missing_name_field() { - $tags = array( - array( - 'type' => 'Hashtag', - ), - array( - 'type' => 'Hashtag', - 'name' => '#valid', - ), - ); - - $result = Posts::extract_hashtags( $tags ); + $result = Posts::delete( $post_id ); - $this->assertIsArray( $result ); - $this->assertCount( 1, $result ); - $this->assertContains( 'valid', $result ); - } - - /** - * Test extracting hashtags when tags have no type field. - * - * @covers ::extract_hashtags - */ - public function test_extract_hashtags_missing_type_field() { - $tags = array( - array( - 'name' => '#invalid', - ), - array( - 'type' => 'Hashtag', - 'name' => '#valid', - ), - ); - - $result = Posts::extract_hashtags( $tags ); + $this->assertInstanceOf( '\WP_Post', $result ); - $this->assertIsArray( $result ); - $this->assertCount( 1, $result ); - $this->assertContains( 'valid', $result ); - $this->assertNotContains( 'invalid', $result ); + $post = \get_post( $post_id ); + $this->assertEquals( 'trash', $post->post_status ); } /** - * Test that hashtag removal works in activity_to_post. + * Test deleting a non-existent post. * - * @covers ::activity_to_post + * @covers ::delete */ - public function test_activity_to_post_removes_hashtags() { - $activity = array( - 'id' => 'https://example.com/objects/hashtag-test', - 'type' => 'Note', - 'name' => 'Hashtag Test', - 'content' => '

This is a test #test #wordpress

', - 'published' => '2023-01-01T12:00:00Z', - 'tag' => array( - array( - 'type' => 'Hashtag', - 'name' => '#test', - ), - array( - 'type' => 'Hashtag', - 'name' => '#wordpress', - ), - ), - ); - - // Use reflection to access the private method. - $reflection = new \ReflectionClass( Posts::class ); - $method = $reflection->getMethod( 'activity_to_post' ); - if ( \PHP_VERSION_ID < 80100 ) { - $method->setAccessible( true ); - } + public function test_delete_nonexistent() { + $result = Posts::delete( 999999 ); - $result = $method->invoke( null, $activity ); - - $this->assertIsArray( $result ); - // Content should have hashtags removed. - $this->assertStringNotContainsString( '#test', $result['post_content'] ); - $this->assertStringNotContainsString( '#WordPress', $result['post_content'] ); - $this->assertStringContainsString( 'This is a test', $result['post_content'] ); + $this->assertNull( $result ); } /** - * Data provider for remove_hashtags tests. + * Data provider for prepare_content tests. * - * @return array Test data. + * @return array Test cases. */ - public function remove_hashtags_provider() { + public function data_prepare_content() { return array( - 'simple_hashtag_removal' => array( - '

This is a test #wordpress #activitypub

', - array( - array( - 'type' => 'Hashtag', - 'name' => '#wordpress', - ), - array( - 'type' => 'Hashtag', - 'name' => '#activitypub', - ), - ), - '

This is a test

', - ), - 'hashtags_without_hash_prefix' => array( - '

Testing content #php #javascript

', - array( - array( - 'type' => 'Hashtag', - 'name' => 'php', - ), - array( - 'type' => 'Hashtag', - 'name' => 'javascript', - ), - ), - '

Testing content

', - ), - 'hashtags_in_anchor_tags' => array( - '

Check out this post #wordpress #php

', - array( - array( - 'type' => 'Hashtag', - 'name' => '#wordpress', - ), - array( - 'type' => 'Hashtag', - 'name' => '#php', - ), - ), - '

Check out this post

', - ), - 'mixed_hashtags' => array( - '

Post about coding #php #javascript

', - array( - array( - 'type' => 'Hashtag', - 'name' => 'php', - ), - array( - 'type' => 'Hashtag', - 'name' => 'javascript', - ), - ), - '

Post about coding

', - ), - 'inline_hashtags_not_removed' => array( - '

Testing #wordpress in the middle and more text

', - array( - array( - 'type' => 'Hashtag', - 'name' => 'wordpress', - ), - ), - '

Testing #wordpress in the middle and more text

', - ), - 'partial_match_should_not_remove' => array( - '

Testing #wordpressdevelopment in content #wordpress

', - array( - array( - 'type' => 'Hashtag', - 'name' => 'wordpress', - ), - ), - '

Testing #wordpressdevelopment in content

', - ), - 'empty_hashtags_array' => array( - '

Testing #wordpress #php

', - array(), - '

Testing #wordpress #php

', - ), - 'empty_content' => array( + 'empty string' => array( '', - array( - array( - 'type' => 'Hashtag', - 'name' => 'wordpress', - ), - ), '', ), - 'no_matching_hashtags' => array( - '

Testing #wordpress #php

', - array( - array( - 'type' => 'Hashtag', - 'name' => 'javascript', - ), - array( - 'type' => 'Hashtag', - 'name' => 'python', - ), - ), - '

Testing #wordpress #php

', + 'plain text gets wpautop' => array( + 'Hello world', + // wpautop wraps in

, then converted to block. + '

Hello world

', ), - 'case_insensitive_removal' => array( - '

Testing content #WordPress #PHP

', - array( - array( - 'type' => 'Hashtag', - 'name' => 'wordpress', - ), - array( - 'type' => 'Hashtag', - 'name' => 'php', - ), - ), - '

Testing content

', + 'existing paragraph' => array( + '

Already wrapped

', + '

Already wrapped

', ), - 'trailing_hashtags_only' => array( - '

Testing #wordpress in middle #php #activitypub

', - array( - array( - 'type' => 'Hashtag', - 'name' => 'wordpress', - ), - array( - 'type' => 'Hashtag', - 'name' => 'php', - ), - array( - 'type' => 'Hashtag', - 'name' => 'activitypub', - ), - ), - '

Testing #wordpress in middle

', + 'multiple paragraphs' => array( + '

First

Second

', + '

First

Second

', ), - 'special_characters_in_hashtags' => array( - '

Testing content #c++ #.net

', - array( - array( - 'type' => 'Hashtag', - 'name' => 'c++', - ), - array( - 'type' => 'Hashtag', - 'name' => '.net', - ), - ), - '

Testing content

', + 'heading preserved' => array( + '

A heading

', + '

A heading

', ), - 'multiple_spaces_cleanup' => array( - '

Testing content #tag1 #tag2 #tag3

', - array( - array( - 'type' => 'Hashtag', - 'name' => 'tag1', - ), - array( - 'type' => 'Hashtag', - 'name' => 'tag2', - ), - array( - 'type' => 'Hashtag', - 'name' => 'tag3', - ), - ), - '

Testing content

', + 'blockquote' => array( + '

Quote

', + '

Quote

', ), ); } /** - * Test remove_hashtags with various inputs. + * Test prepare_content pipeline. * - * @dataProvider remove_hashtags_provider - * @covers ::remove_hashtags + * @dataProvider data_prepare_content + * @covers ::prepare_content * - * @param string $content Input content. - * @param array $hashtags Hashtags to remove. - * @param string $expected Expected output. + * @param string $input The input content. + * @param string $expected The expected output. */ - public function test_remove_hashtags( $content, $hashtags, $expected ) { - $result = Posts::remove_hashtags( $content, $hashtags ); - $this->assertEquals( $expected, $result ); - } - - /** - * Test remove_hashtags with non-array hashtags parameter. - * - * @covers ::remove_hashtags - */ - public function test_remove_hashtags_with_invalid_hashtags() { - $content = '

Testing #WordPress #php

'; - - // Should return original content when hashtags is not an array. - $this->assertEquals( $content, Posts::remove_hashtags( $content, 'not-an-array' ) ); - $this->assertEquals( $content, Posts::remove_hashtags( $content, null ) ); - $this->assertEquals( $content, Posts::remove_hashtags( $content, 123 ) ); - } - - /** - * Test remove_hashtags preserves content structure. - * - * @covers ::remove_hashtags - */ - public function test_remove_hashtags_preserves_structure() { - $content = '

First paragraph content

Second paragraph with bold text #php #test

'; - $tags = array( - array( - 'type' => 'Hashtag', - 'name' => 'test', - ), - array( - 'type' => 'Hashtag', - 'name' => 'php', - ), - ); - $result = Posts::remove_hashtags( $content, $tags ); - - // Should preserve HTML structure and remove trailing hashtags only. - $this->assertStringContainsString( '

First paragraph content

', $result ); - $this->assertStringContainsString( 'bold text', $result ); - $this->assertStringNotContainsString( '#test', $result ); - $this->assertStringNotContainsString( '#php', $result ); - } - - /** - * Test delete_all method deletes all posts. - * - * @covers ::delete_all - */ - public function test_delete_all() { - // Create some posts. - self::factory()->post->create_many( - 5, - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - ) - ); - - // Verify posts were created. - $count_before = \wp_count_posts( Posts::POST_TYPE )->publish; - $this->assertEquals( 5, $count_before ); - - // Delete all posts. - $deleted = Posts::delete_all(); - - // Clear cache to get accurate count. - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); - - // Verify all posts were deleted. - $count_after = \wp_count_posts( Posts::POST_TYPE )->publish; - $this->assertEquals( 0, $count_after ); - - // Verify return value. - $this->assertEquals( 5, $deleted ); - } - - /** - * Test delete_all method with mixed post statuses. - * - * @covers ::delete_all - */ - public function test_delete_all_mixed_statuses() { - // Create posts with different statuses. - self::factory()->post->create_many( - 3, - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - ) - ); - self::factory()->post->create_many( - 2, - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'draft', - ) - ); - self::factory()->post->create( - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'trash', - ) - ); - - // Delete all posts. - $deleted = Posts::delete_all(); - - // Verify all posts were deleted regardless of status. - $remaining = \get_posts( - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'any', - 'numberposts' => -1, - 'fields' => 'ids', - ) - ); - $this->assertEmpty( $remaining ); - - // Verify return value includes all posts. - $this->assertEquals( 6, $deleted ); - } - - /** - * Test purge method with more than 200 posts. - * - * @covers ::purge - */ - public function test_purge_more_than_200_posts() { - // Create 20 old posts (will be deleted). - self::factory()->post->create_many( - 20, - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-7 months' ) ), - ) - ); - - // Create 5 new posts (will be kept). - self::factory()->post->create_many( - 5, - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 month' ) ), - ) - ); - - // Mock the count to exceed the 200-post threshold. - $wp_count_posts_callback = function ( $counts, $type ) { - if ( Posts::POST_TYPE === $type ) { - $counts->publish = 225; - } - return $counts; - }; - \add_filter( 'wp_count_posts', $wp_count_posts_callback, 10, 2 ); - - $deleted = Posts::purge( 180 ); - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); - - \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); - - // Assert that 20 old posts were deleted. - $this->assertEquals( 20, $deleted ); - - // Verify 5 new posts remain. - $remaining = \get_posts( - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'numberposts' => -1, - 'fields' => 'ids', - ) - ); - $this->assertCount( 5, $remaining ); - } - - /** - * Test purge method with 200 or fewer posts. - * - * @covers ::purge - */ - public function test_purge_200_or_fewer_posts() { - // Create 20 old posts. - self::factory()->post->create_many( - 20, - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 year' ) ), - ) - ); - - $deleted = Posts::purge( 180 ); - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); - - // Assert no posts were deleted (below threshold). - $this->assertEquals( 0, $deleted ); - $this->assertEquals( 20, \wp_count_posts( Posts::POST_TYPE )->publish ); - } - - /** - * Test purge method preserves posts with local user comments. - * - * @covers ::purge - */ - public function test_purge_preserves_posts_with_comments() { - // Create old post without comments (should be deleted). - $post_without_comments = self::factory()->post->create( - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 year' ) ), - ) - ); - - // Create old post with a local user comment (should be preserved). - $post_with_comment = self::factory()->post->create( - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 year' ) ), - ) - ); - - // Add a comment from a local user to the second post. - self::factory()->comment->create( - array( - 'comment_post_ID' => $post_with_comment, - 'comment_content' => 'Test comment', - 'comment_approved' => 1, - 'user_id' => 1, // Local user comment. - ) - ); - - // Mock the count to exceed the 200-post threshold. - $wp_count_posts_callback = function ( $counts, $type ) { - if ( Posts::POST_TYPE === $type ) { - $counts->publish = 225; - } - return $counts; - }; - \add_filter( 'wp_count_posts', $wp_count_posts_callback, 10, 2 ); - - $deleted = Posts::purge( 180 ); - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); - - \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); - - // Assert only 1 post was deleted. - $this->assertEquals( 1, $deleted ); - - // Post without comments should be deleted. - $this->assertNull( \get_post( $post_without_comments ) ); - - // Post with local user comment should still exist. - $this->assertNotNull( \get_post( $post_with_comment ) ); - } - - /** - * Test purge preserves posts with multiple local user comments. - * - * @covers ::purge - */ - public function test_purge_preserves_posts_with_multiple_comments() { - // Create old post with multiple local user comments (should be preserved). - $post_with_comments = self::factory()->post->create( - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 year' ) ), - ) - ); - - // Add multiple comments from local users. - self::factory()->comment->create( - array( - 'comment_post_ID' => $post_with_comments, - 'comment_content' => 'First comment', - 'comment_approved' => 1, - 'user_id' => 1, // Local user comment. - ) - ); - self::factory()->comment->create( - array( - 'comment_post_ID' => $post_with_comments, - 'comment_content' => 'Second comment', - 'comment_approved' => 1, - 'user_id' => 1, // Local user comment. - ) - ); - - // Create old post without any interactions (should be deleted). - $post_without_interactions = self::factory()->post->create( - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 year' ) ), - ) - ); - - // Mock the count to exceed threshold. - $wp_count_posts_callback = function ( $counts, $type ) { - if ( Posts::POST_TYPE === $type ) { - $counts->publish = 225; - } - return $counts; - }; - \add_filter( 'wp_count_posts', $wp_count_posts_callback, 10, 2 ); - - $deleted = Posts::purge( 180 ); - - \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); - - // Only post without interactions should be deleted. - $this->assertEquals( 1, $deleted ); - $this->assertNotNull( \get_post( $post_with_comments ) ); - $this->assertNull( \get_post( $post_without_interactions ) ); - } - - /** - * Test purge method with different retention days. - * - * @covers ::purge - */ - public function test_purge_with_different_days() { - // Create posts older than 60 days but newer than 30 days. - self::factory()->post->create_many( - 10, - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-45 days' ) ), - ) - ); - - // Mock the count to exceed threshold. - $wp_count_posts_callback = function ( $counts, $type ) { - if ( Posts::POST_TYPE === $type ) { - $counts->publish = 225; - } - return $counts; - }; - \add_filter( 'wp_count_posts', $wp_count_posts_callback, 10, 2 ); - - // Purge with 60 days retention - should not delete. - $deleted = Posts::purge( 60 ); - $this->assertEquals( 0, $deleted ); - - // Purge with 30 days retention - should delete all. - $deleted = Posts::purge( 30 ); - \wp_cache_delete( \_count_posts_cache_key( Posts::POST_TYPE ), 'counts' ); - - \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); - - $this->assertEquals( 10, $deleted ); - } - - /** - * Test activity_to_post with video attachment. - * - * @covers ::add - */ - public function test_activity_to_post_with_video_attachment() { - $activity = array( - 'object' => array( - 'id' => 'https://example.com/objects/video-post', - 'type' => 'Note', - 'name' => 'Video Post', - 'content' => '

Check out this video

', - 'attributedTo' => 'https://example.com/users/testuser', - 'attachment' => array( - array( - 'url' => 'https://example.com/video.mp4', - 'mediaType' => 'video/mp4', - 'name' => 'A cool video', - 'type' => 'Document', - ), - ), - ), - ); - - $result = Posts::add( $activity, 1 ); - - $this->assertInstanceOf( '\WP_Post', $result ); - $this->assertStringContainsString( '', $result->post_content ); - } - - /** - * Test activity_to_post with audio attachment. - * - * @covers ::add - */ - public function test_activity_to_post_with_audio_attachment() { - $activity = array( - 'object' => array( - 'id' => 'https://example.com/objects/audio-post', - 'type' => 'Note', - 'name' => 'Audio Post', - 'content' => '

Listen to this

', - 'attributedTo' => 'https://example.com/users/testuser', - 'attachment' => array( - array( - 'url' => 'https://example.com/podcast.mp3', - 'mediaType' => 'audio/mpeg', - 'name' => 'Episode 1', - 'type' => 'Document', - ), - ), - ), - ); - - $result = Posts::add( $activity, 1 ); - - $this->assertInstanceOf( '\WP_Post', $result ); - $this->assertStringContainsString( '', $result->post_content ); - } - - /** - * Test purge returns count of deleted items. - * - * @covers ::purge - */ - public function test_purge_returns_deleted_count() { - // Create 15 old posts. - self::factory()->post->create_many( - 15, - array( - 'post_type' => Posts::POST_TYPE, - 'post_status' => 'publish', - 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 year' ) ), - ) - ); - - // Mock the count to exceed threshold. - $wp_count_posts_callback = function ( $counts, $type ) { - if ( Posts::POST_TYPE === $type ) { - $counts->publish = 225; - } - return $counts; - }; - \add_filter( 'wp_count_posts', $wp_count_posts_callback, 10, 2 ); - - $deleted = Posts::purge( 180 ); - - \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); - - // Should return exact count of deleted posts. - $this->assertEquals( 15, $deleted ); + public function test_prepare_content( $input, $expected ) { + $this->assertSame( $expected, Posts::prepare_content( $input ) ); } } diff --git a/tests/phpunit/tests/includes/collection/class-test-remote-posts.php b/tests/phpunit/tests/includes/collection/class-test-remote-posts.php new file mode 100644 index 0000000000..d01106ebf9 --- /dev/null +++ b/tests/phpunit/tests/includes/collection/class-test-remote-posts.php @@ -0,0 +1,1914 @@ +remove_added_uploads(); + + parent::tear_down(); + } + + /** + * Render post content to trigger lazy media caching. + * + * Sets up global post context, renders blocks, then cleans up. + * + * @param \WP_Post $the_post The post to render. + */ + protected function render_post_content( $the_post ) { + global $post; + $post = $the_post; + \setup_postdata( $the_post ); + \do_blocks( $the_post->post_content ); + \wp_reset_postdata(); + } + + /** + * Mock remote object fetching to bypass URL validation. + * + * @param mixed $response The response to return. + * @param string $url_or_object The URL or object being fetched. + * @return mixed The mocked response or null to continue. + */ + public function mock_remote_object( $response, $url_or_object ) { + if ( 'https://example.com/users/testuser' === object_to_uri( $url_or_object ) ) { + return array( + 'id' => 'https://example.com/users/testuser', + 'type' => 'Person', + 'name' => 'Test Actor', + 'preferredUsername' => 'testuser', + 'summary' => 'A test actor', + 'url' => 'https://example.com/users/testuser', + 'inbox' => 'https://example.com/users/testuser/inbox', + 'outbox' => 'https://example.com/users/testuser/outbox', + ); + } + + return $response; + } + + /** + * Mock file downloads by providing pre-downloaded test files. + * + * This short-circuits the entire download process, avoiding DNS lookups + * and URL validation that may fail in CI environments. + * + * @param array|null $result The pre-download result. + * @param string $url The URL being downloaded. + * @param string $type The cache type. + * @return array|null Array with file and mime_type for test URLs, null otherwise. + */ + public function mock_file_download( $result, $url, $type ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required for filter signature. + // Handle all example.com image URLs. + if ( str_starts_with( $url, 'https://example.com/' ) && preg_match( '/\.(jpg|jpeg|png|gif|webp)$/i', $url ) ) { + // Copy test asset to temp file. + $tmp_file = \wp_tempnam( 'test-image.jpg' ); + copy( AP_TESTS_DIR . '/data/assets/test.jpg', $tmp_file ); + + return array( + 'file' => $tmp_file, + 'mime_type' => 'image/jpeg', + ); + } + + return $result; + } + + /** + * Mock HTTP requests for remote actor fetching and attachment downloads. + * + * @param mixed $response The response to return. + * @param array $parsed_args The parsed arguments. + * @param string $url The URL being requested. + * @return mixed The mocked response or original response. + */ + public function mock_http_request( $response, $parsed_args, $url ) { + if ( 'https://example.com/users/testuser' === $url ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( + array( + 'id' => 'https://example.com/users/testuser', + 'type' => 'Person', + 'name' => 'Test Actor', + 'preferredUsername' => 'testuser', + 'summary' => 'A test actor', + 'url' => 'https://example.com/users/testuser', + 'inbox' => 'https://example.com/users/testuser/inbox', + 'outbox' => 'https://example.com/users/testuser/outbox', + ) + ), + ); + } + + if ( 'https://nonexistent.com/users/unknown' === $url ) { + return new \WP_Error( 'http_request_failed', 'Could not resolve host' ); + } + + // Mock attachment downloads. + if ( 'https://example.com/image.jpg' === $url && isset( $parsed_args['filename'] ) ) { + copy( AP_TESTS_DIR . '/data/assets/test.jpg', $parsed_args['filename'] ); + + return array( + 'response' => array( 'code' => 200 ), + 'headers' => array( 'content-type' => 'image/jpeg' ), + ); + } + + return $response; + } + + /** + * Test adding an object to the collection. + * + * @covers ::add + */ + public function test_add() { + $activity = array( + 'object' => array( + 'id' => 'https://example.com/objects/123', + 'type' => 'Note', + 'name' => 'Test Object', + 'content' => '

This is a test object content

', + 'summary' => 'Test summary', + 'attributedTo' => 'https://example.com/users/testuser', + 'published' => '2023-01-01T12:00:00Z', + ), + ); + + $result = Remote_Posts::add( $activity, 1 ); + + $this->assertInstanceOf( '\WP_Post', $result ); + $this->assertEquals( 'Test Object', $result->post_title ); + $this->assertEquals( Remote_Posts::POST_TYPE, $result->post_type ); + $this->assertEquals( 'publish', $result->post_status ); + $this->assertEquals( 'https://example.com/objects/123', $result->guid ); + } + + /** + * Test updating an existing object. + * + * @covers ::update + */ + public function test_update() { + // First, create an object. + $activity = array( + 'object' => array( + 'id' => 'https://example.com/objects/456', + 'type' => 'Note', + 'name' => 'Original Title', + 'content' => '

Original content

', + 'attributedTo' => 'https://example.com/users/testuser', + ), + ); + + $original_post = Remote_Posts::add( $activity, 1 ); + $this->assertInstanceOf( '\WP_Post', $original_post ); + + // Now update it. + $update_activity = array( + 'object' => array( + 'id' => 'https://example.com/objects/456', + 'type' => 'Note', + 'name' => 'Updated Title', + 'content' => '

Updated content

', + ), + ); + + $updated_post = Remote_Posts::update( $update_activity, 1 ); + + $this->assertInstanceOf( '\WP_Post', $updated_post ); + $this->assertEquals( 'Updated Title', $updated_post->post_title ); + $this->assertStringContainsString( 'Updated content', $updated_post->post_content ); + $this->assertEquals( $original_post->ID, $updated_post->ID ); + } + + /** + * Test updating a non-existent object. + * + * @covers ::update + */ + public function test_update_nonexistent() { + $activity = array( + 'object' => array( + 'id' => 'https://example.com/objects/nonexistent', + 'type' => 'Note', + 'name' => 'Updated Title', + 'content' => '

Updated content

', + ), + ); + + $result = Remote_Posts::update( $activity, 1 ); + + $this->assertInstanceOf( '\WP_Error', $result ); + } + + /** + * Test getting an object by GUID. + * + * @covers ::get_by_guid + */ + public function test_get_by_guid() { + // Create an object. + $activity = array( + 'object' => array( + 'id' => 'https://example.com/objects/789', + 'type' => 'Note', + 'name' => 'Test Object', + 'content' => '

Test content

', + 'attributedTo' => 'https://example.com/users/testuser', + ), + ); + + $post = Remote_Posts::add( $activity, 1 ); + $this->assertInstanceOf( '\WP_Post', $post ); + + // Test retrieval. + $retrieved_post = Remote_Posts::get_by_guid( 'https://example.com/objects/789' ); + + $this->assertInstanceOf( '\WP_Post', $retrieved_post ); + $this->assertEquals( $post->ID, $retrieved_post->ID ); + $this->assertEquals( 'Test Object', $retrieved_post->post_title ); + } + + /** + * Test getting a non-existent object by GUID. + * + * @covers ::get_by_guid + */ + public function test_get_by_guid_nonexistent() { + $result = Remote_Posts::get_by_guid( 'https://example.com/objects/nonexistent' ); + + $this->assertInstanceOf( '\WP_Error', $result ); + } + + /** + * Test activity to post conversion. + * + * @covers ::activity_to_post + */ + public function test_activity_to_post() { + $activity = array( + 'id' => 'https://example.com/objects/test', + 'type' => 'Note', + 'name' => 'Test Title', + 'content' => '

Test content with HTML

', + 'summary' => 'Test summary', + 'published' => '2023-01-01T12:00:00Z', + ); + + // Use reflection to access the private method. + $reflection = new \ReflectionClass( Remote_Posts::class ); + $method = $reflection->getMethod( 'activity_to_post' ); + if ( \PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + try { + $result = $method->invoke( null, $activity ); + } catch ( \Exception $exception ) { + $result = $exception; + } + + $this->assertIsArray( $result ); + $this->assertEquals( 'Test Title', $result['post_title'] ); + $this->assertEquals( 'Test summary', $result['post_excerpt'] ); + $this->assertEquals( Remote_Posts::POST_TYPE, $result['post_type'] ); + $this->assertEquals( 'publish', $result['post_status'] ); + $this->assertEquals( 'https://example.com/objects/test', $result['guid'] ); + $this->assertStringContainsString( 'Test content', $result['post_content'] ); + } + + /** + * Test activity to post conversion with invalid data. + * + * @covers ::activity_to_post + */ + public function test_activity_to_post_invalid() { + // Use reflection to access the private method. + $reflection = new \ReflectionClass( Remote_Posts::class ); + $method = $reflection->getMethod( 'activity_to_post' ); + if ( \PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + try { + $result = $method->invoke( null, 'invalid_data' ); + } catch ( \Exception $exception ) { + $result = $exception; + } + + $this->assertInstanceOf( '\WP_Error', $result ); + } + + /** + * Test activity to post conversion with minimal data. + * + * @covers ::activity_to_post + */ + public function test_activity_to_post_minimal() { + $activity = array( + 'type' => 'Note', + 'content' => '

Minimal content for excerpt generation

', + ); + + // Use reflection to access the private method. + $reflection = new \ReflectionClass( Remote_Posts::class ); + $method = $reflection->getMethod( 'activity_to_post' ); + if ( \PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + try { + $result = $method->invoke( null, $activity ); + } catch ( \Exception $exception ) { + $result = $exception; + } + + $this->assertIsArray( $result ); + $this->assertEquals( '', $result['post_title'] ); + $this->assertStringContainsString( 'Minimal content', $result['post_content'] ); + // Note: generate_post_summary() expects a WP_Post object, so passing $activity['content'] + // returns empty. WordPress will auto-generate the excerpt from content after post creation. + $this->assertEquals( '', $result['post_excerpt'] ); + $this->assertEquals( Remote_Posts::POST_TYPE, $result['post_type'] ); + $this->assertEquals( 'publish', $result['post_status'] ); + } + + /** + * Test that published timestamp is preserved when creating posts. + * + * @covers ::activity_to_post + * @covers ::add + */ + public function test_preserves_published_timestamp() { + $activity = array( + 'object' => array( + 'id' => 'https://example.com/objects/timestamp-test', + 'type' => 'Note', + 'name' => 'Timestamp Test', + 'content' => '

Test content

', + 'attributedTo' => 'https://example.com/users/testuser', + 'published' => '2023-06-15T14:30:00Z', + ), + ); + + $result = Remote_Posts::add( $activity, 1 ); + + $this->assertInstanceOf( '\WP_Post', $result ); + $this->assertEquals( '2023-06-15 14:30:00', $result->post_date_gmt ); + $this->assertEquals( \get_date_from_gmt( '2023-06-15 14:30:00' ), $result->post_date ); + } + + /** + * Test that activity_to_post handles missing content gracefully. + * + * @covers ::activity_to_post + */ + public function test_activity_to_post_missing_content() { + $activity = array( + 'type' => 'Note', + 'name' => 'Title Only', + 'summary' => 'Summary text', + ); + + // Use reflection to access the private method. + $reflection = new \ReflectionClass( Remote_Posts::class ); + $method = $reflection->getMethod( 'activity_to_post' ); + if ( \PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + try { + $result = $method->invoke( null, $activity ); + } catch ( \Exception $exception ) { + $result = $exception; + } + + $this->assertIsArray( $result ); + $this->assertEquals( 'Title Only', $result['post_title'] ); + $this->assertEquals( '', $result['post_content'] ); + $this->assertEquals( 'Summary text', $result['post_excerpt'] ); + } + + /** + * Test adding an object with multiple recipients. + * + * @covers ::add + * @covers ::get_recipients + */ + public function test_add_with_multiple_recipients() { + $activity = array( + 'object' => array( + 'id' => 'https://example.com/objects/multi-user', + 'type' => 'Note', + 'name' => 'Multi-User Post', + 'content' => '

This post is for multiple users

', + 'attributedTo' => 'https://example.com/users/testuser', + ), + ); + + $result = Remote_Posts::add( $activity, array( 1, 2, 3 ) ); + + $this->assertInstanceOf( '\WP_Post', $result ); + $this->assertEquals( 'Multi-User Post', $result->post_title ); + + // Verify all recipients were added. + $recipients = Remote_Posts::get_recipients( $result->ID ); + $this->assertCount( 3, $recipients ); + $this->assertContains( 1, $recipients ); + $this->assertContains( 2, $recipients ); + $this->assertContains( 3, $recipients ); + } + + /** + * Test adding an object with attachments. + * + * @covers ::add + */ + public function test_add_with_attachments() { + $activity = array( + 'object' => array( + 'id' => 'https://example.com/objects/with-attachment', + 'type' => 'Note', + 'name' => 'Post with Image', + // Real ActivityPub content includes img tags. + 'content' => '

Test content

Test Image

', + 'attributedTo' => 'https://example.com/users/testuser', + 'attachment' => array( + array( + 'url' => 'https://example.com/image.jpg', + 'mediaType' => 'image/jpeg', + 'name' => 'Test Image', + 'type' => 'Image', + ), + ), + ), + ); + + $result = Remote_Posts::add( $activity, 1 ); + + $this->assertInstanceOf( '\WP_Post', $result ); + $this->assertEquals( 'Post with Image', $result->post_title ); + + // Verify content has the media block with original URL. + $this->assertStringContainsString( '', $result->post_content ); + } + + /** + * Test activity_to_post with audio attachment. + * + * @covers ::add + */ + public function test_activity_to_post_with_audio_attachment() { + $activity = array( + 'object' => array( + 'id' => 'https://example.com/objects/audio-post', + 'type' => 'Note', + 'name' => 'Audio Post', + 'content' => '

Listen to this

', + 'attributedTo' => 'https://example.com/users/testuser', + 'attachment' => array( + array( + 'url' => 'https://example.com/podcast.mp3', + 'mediaType' => 'audio/mpeg', + 'name' => 'Episode 1', + 'type' => 'Document', + ), + ), + ), + ); + + $result = Remote_Posts::add( $activity, 1 ); + + $this->assertInstanceOf( '\WP_Post', $result ); + $this->assertStringContainsString( '', $result->post_content ); + } + + /** + * Test purge returns count of deleted items. + * + * @covers ::purge + */ + public function test_purge_returns_deleted_count() { + // Create 15 old posts. + self::factory()->post->create_many( + 15, + array( + 'post_type' => Remote_Posts::POST_TYPE, + 'post_status' => 'publish', + 'post_date' => \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 year' ) ), + ) + ); + + // Mock the count to exceed threshold. + $wp_count_posts_callback = function ( $counts, $type ) { + if ( Remote_Posts::POST_TYPE === $type ) { + $counts->publish = 225; + } + return $counts; + }; + \add_filter( 'wp_count_posts', $wp_count_posts_callback, 10, 2 ); + + $deleted = Remote_Posts::purge( 180 ); + + \remove_filter( 'wp_count_posts', $wp_count_posts_callback ); + + // Should return exact count of deleted posts. + $this->assertEquals( 15, $deleted ); + } +} diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index d6c94b5369..23d0efcf84 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -9,7 +9,7 @@ use Activitypub\Activity\Activity; use Activitypub\Activity\Base_Object; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; use Activitypub\Handler\Create; use Activitypub\Post_Types; use Activitypub\Tombstone; @@ -343,7 +343,7 @@ public function test_handle_create_object_with_sanitization() { Create::handle_create( $activity, $this->user_id ); // Verify the object was created with sanitized content. - $created_object = Posts::get_by_guid( 'https://example.com/objects/note_sanitize' ); + $created_object = Remote_Posts::get_by_guid( 'https://example.com/objects/note_sanitize' ); $this->assertNotNull( $created_object ); @@ -378,7 +378,7 @@ public function test_handle_create_private_activity() { // Count objects before. $objects_before = get_posts( array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'any', 'posts_per_page' => -1, ) @@ -389,7 +389,7 @@ public function test_handle_create_private_activity() { // Count objects after. $objects_after = get_posts( array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'any', 'posts_per_page' => -1, ) @@ -419,7 +419,7 @@ public function test_handle_create_malformed_object() { // Count objects before. $objects_before = get_posts( array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'any', 'posts_per_page' => -1, ) @@ -430,7 +430,7 @@ public function test_handle_create_malformed_object() { // Count objects after. $objects_after = get_posts( array( - 'post_type' => Posts::POST_TYPE, + 'post_type' => Remote_Posts::POST_TYPE, 'post_status' => 'any', 'posts_per_page' => -1, ) @@ -486,7 +486,7 @@ public function test_create_post_disabled_by_option() { $this->assertFalse( $result ); // Verify no post was created. - $created_object = Posts::get_by_guid( 'https://example.com/objects/note_disabled' ); + $created_object = Remote_Posts::get_by_guid( 'https://example.com/objects/note_disabled' ); $this->assertTrue( \is_wp_error( $created_object ) ); \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_callback ); @@ -538,7 +538,7 @@ public function test_create_post_enabled_by_option() { $this->assertInstanceOf( 'WP_Post', $result ); // Verify post was created. - $created_object = Posts::get_by_guid( 'https://example.com/objects/note_enabled' ); + $created_object = Remote_Posts::get_by_guid( 'https://example.com/objects/note_enabled' ); $this->assertNotNull( $created_object ); $this->assertStringContainsString( 'This should be created', $created_object->post_content ); diff --git a/tests/phpunit/tests/includes/handler/class-test-delete.php b/tests/phpunit/tests/includes/handler/class-test-delete.php index 10d70ed463..a0a782fbca 100644 --- a/tests/phpunit/tests/includes/handler/class-test-delete.php +++ b/tests/phpunit/tests/includes/handler/class-test-delete.php @@ -200,13 +200,13 @@ public function test_delete_actor_posts() { for ( $i = 0; $i < 3; $i++ ) { $post_id = self::factory()->post->create( array( - 'post_type' => \Activitypub\Collection\Posts::POST_TYPE, + 'post_type' => \Activitypub\Collection\Remote_Posts::POST_TYPE, 'post_author' => $actor->ID, 'post_title' => "Test Post $i", 'post_status' => 'publish', ) ); - // Add the remote actor ID meta that Posts::get_by_remote_actor() looks for. + // Add the remote actor ID meta that Remote_Posts::get_by_remote_actor() looks for. \add_post_meta( $post_id, '_activitypub_remote_actor_id', $actor->ID ); $post_ids[] = $post_id; } @@ -334,7 +334,7 @@ public function test_delete_object_tombstone_deletes_post() { // Create a post in the Posts collection. $post_id = \wp_insert_post( array( - 'post_type' => \Activitypub\Collection\Posts::POST_TYPE, + 'post_type' => \Activitypub\Collection\Remote_Posts::POST_TYPE, 'post_title' => 'Test Note', 'post_content' => 'Test content', 'post_status' => 'publish', From 213004c28a01b3e035b3da14c4f46819c5c21d65 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 18 Feb 2026 10:32:44 +0100 Subject: [PATCH 078/105] Fix test assertions for block-wrapped content Content now goes through prepare_content() which wraps HTML in block markup. Use assertStringContainsString instead of assertEquals. --- .../phpunit/tests/includes/handler/outbox/class-test-create.php | 2 +- .../phpunit/tests/includes/handler/outbox/class-test-update.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-create.php b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php index 7067678c55..df0fb241e7 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php @@ -178,7 +178,7 @@ public function test_outgoing_post_content_and_title() { $this->assertInstanceOf( 'WP_Post', $result ); $this->assertEquals( 'Specific Title', $result->post_title ); - $this->assertEquals( '

Specific content here.

', $result->post_content ); + $this->assertStringContainsString( 'Specific content here.', $result->post_content ); $this->assertEquals( 'A brief summary.', $result->post_excerpt ); \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-update.php b/tests/phpunit/tests/includes/handler/outbox/class-test-update.php index 15dcc69e55..8703699399 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-update.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-update.php @@ -77,7 +77,7 @@ public function test_outgoing_updates_post() { $post = \get_post( $post_id ); $this->assertEquals( 'Updated Title', $post->post_title ); - $this->assertEquals( 'Updated content', $post->post_content ); + $this->assertStringContainsString( 'Updated content', $post->post_content ); $this->assertEquals( 'Updated summary', $post->post_excerpt ); } From 22bb3bf8f5ebc10258e4c0872fa65c342e572ab2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 18 Feb 2026 10:35:20 +0100 Subject: [PATCH 079/105] Update C2S implementation plan to reflect current status Mark phases 1-2 as implemented, add architecture notes for collection split, content pipeline, and Connected Applications UI. --- docs/activitypub-api-implementation-plan.md | 831 ++++++++++++++++++++ 1 file changed, 831 insertions(+) create mode 100644 docs/activitypub-api-implementation-plan.md diff --git a/docs/activitypub-api-implementation-plan.md b/docs/activitypub-api-implementation-plan.md new file mode 100644 index 0000000000..001ef042ed --- /dev/null +++ b/docs/activitypub-api-implementation-plan.md @@ -0,0 +1,831 @@ +# ActivityPub API Implementation Plan + +This document outlines the implementation plan for adding SWICG ActivityPub API support to the WordPress ActivityPub plugin, enabling Client-to-Server (C2S) functionality. + +## Executive Summary + +The [SWICG ActivityPub API](https://github.com/swicg/activitypub-api) specification aims to standardize client-to-server ActivityPub interactions, allowing third-party clients to work across different ActivityPub servers. + +### Implementation Status + +| Feature | Status | Notes | +|---------|--------|-------| +| S2S Federation | ✅ Maintained | Unchanged | +| HTTP Signatures | ✅ Maintained | RFC-9421 + Draft-Cavage | +| OAuth 2.0 + PKCE | ✅ Implemented | Authorization code flow, token refresh, revocation, introspection | +| Dynamic Client Registration | ✅ Implemented | RFC 7591 + CIMD auto-discovery | +| POST to Outbox | ✅ Implemented | Create, Update, Delete, Follow, Undo, Like, Announce | +| GET Inbox | ✅ Implemented | OAuth-authenticated read access | +| CORS Headers | ✅ Implemented | Cross-origin support for C2S clients | +| Proxy Endpoint | ✅ Implemented | Fetch remote objects on behalf of clients | +| Connected Applications UI | ✅ Implemented | User profile section for managing OAuth tokens and registering clients | +| Content Pipeline | ✅ Implemented | HTML-to-blocks conversion, hashtag extraction, link processing | +| Application Passwords | ✅ Implemented | Alternative auth for C2S | +| SSE Push | ⬜ Future | Real-time updates | +| Media Upload | ⬜ Future | Standard upload endpoint | +| Search & Discovery | ⬜ Future | Content and actor search | +| User Controls | ⬜ Future | Mute/block management | + +--- + +## Phase 1: OAuth 2.0 Foundation ✅ + +**Status**: Implemented + +### 1.1 OAuth Server Implementation + +#### Files +``` +includes/oauth/ +├── class-server.php # OAuth 2.0 server logic, Bearer token validation +├── class-token.php # Token model (transient + user meta storage) +├── class-client.php # Client registration model +└── class-scope.php # Scope definitions and validation (READ, WRITE, FOLLOW) +``` + +#### Storage + +Tokens are stored using WordPress transients (access tokens) and user meta (refresh tokens), avoiding custom post types for performance. + +Clients are stored as `activitypub_oauth_client` custom post types with meta for `client_id`, `redirect_uris`, `grant_types`, and `scope`. + +#### REST Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/activitypub/1.0/oauth/authorize` | GET | Authorization page (redirect to WP login) | +| `/activitypub/1.0/oauth/token` | POST | Token exchange + refresh | +| `/activitypub/1.0/oauth/revoke` | POST | Token revocation (RFC 7009) | +| `/activitypub/1.0/oauth/introspect` | POST | Token introspection (RFC 7662) | +| `/activitypub/1.0/oauth/clients` | POST | Dynamic client registration (RFC 7591) | +| `/.well-known/oauth-authorization-server` | GET | Authorization Server Metadata | + +#### OAuth Flow (PKCE) + +``` +┌─────────────┐ ┌─────────────┐ +│ Client │ │ Server │ +└──────┬──────┘ └──────┬──────┘ + │ │ + │ 1. GET /oauth/authorize │ + │ ?response_type=code │ + │ &client_id=... │ + │ &redirect_uri=... │ + │ &scope=read write │ + │ &state=... │ + │ &code_challenge=... │ + │ &code_challenge_method=S256 │ + │ ─────────────────────────────────────────> │ + │ │ + │ 2. Redirect to WordPress login │ + │ <───────────────────────────────────────── │ + │ │ + │ 3. User authenticates & authorizes │ + │ ─────────────────────────────────────────> │ + │ │ + │ 4. Redirect to client with code │ + │ <───────────────────────────────────────── │ + │ │ + │ 5. POST /oauth/token │ + │ grant_type=authorization_code │ + │ &code=... │ + │ &code_verifier=... │ + │ ─────────────────────────────────────────> │ + │ │ + │ 6. Return access_token + refresh_token │ + │ <───────────────────────────────────────── │ + │ │ +``` + +#### Scopes (Implemented) + +| Scope | Description | +|-------|-------------| +| `read` | Read actor profile, collections, inbox | +| `write` | Create activities via POST to outbox | +| `follow` | Manage following relationships | + +### 1.2 Actor Endpoints Property + +Extend `get_endpoints()` in User/Blog models: + +```php +public function get_endpoints() { + $endpoints = array( + 'sharedInbox' => get_rest_url_by_path( 'inbox' ), + ); + + if ( $this->supports_c2s() ) { + $endpoints['oauthAuthorizationEndpoint'] = get_rest_url_by_path( 'oauth/authorize' ); + $endpoints['oauthTokenEndpoint'] = get_rest_url_by_path( 'oauth/token' ); + $endpoints['uploadMedia'] = get_rest_url_by_path( 'users/' . $this->get__id() . '/media' ); + } + + return $endpoints; +} +``` + +### 1.3 Authentication Filter + +Add Bearer token validation to REST API: + +```php +// Hook into WordPress REST authentication +add_filter( 'rest_authentication_errors', array( $this, 'authenticate_oauth' ), 20 ); + +public function authenticate_oauth( $result ) { + // Check for Bearer token + $auth_header = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if ( strpos( $auth_header, 'Bearer ' ) !== 0 ) { + return $result; // Let other auth methods handle + } + + $token = substr( $auth_header, 7 ); + $validated = OAuth\Token::validate( $token ); + + if ( is_wp_error( $validated ) ) { + return $validated; + } + + wp_set_current_user( $validated->user_id ); + return true; +} +``` + +--- + +## Phase 2: POST to Outbox (Core C2S) ✅ + +**Status**: Implemented + +### 2.1 Outbox POST Endpoint + +Extend `class-outbox-controller.php`: + +```php +// Add CREATABLE route +array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_create_item_args(), +) +``` + +#### Permission Check + +```php +public function create_item_permissions_check( $request ) { + // Must be authenticated via OAuth with 'write' scope + $token = OAuth\Server::get_current_token(); + + if ( ! $token || ! $token->has_scope( 'write' ) ) { + return new WP_Error( + 'activitypub_unauthorized', + __( 'OAuth token with write scope required', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Token user must match actor + $user_id = $this->get_user_id_from_request( $request ); + if ( $token->user_id !== $user_id ) { + return new WP_Error( + 'activitypub_forbidden', + __( 'Token does not match actor', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; +} +``` + +### 2.2 Activity Processing + +#### Supported Activity Types (Implemented) + +| Activity | Action | WordPress Mapping | +|----------|--------|-------------------| +| `Create` + `Note` | Create post (status format) | `Collection\Posts::create()` | +| `Create` + `Article` | Create post | `Collection\Posts::create()` | +| `Update` | Update post | `Collection\Posts::update()` | +| `Delete` | Trash post / delete comment | `Collection\Posts::delete()` | +| `Follow` | Follow actor | Delegates to `follow()` for delivery | +| `Undo` + `Follow` | Unfollow actor | Delegates to `unfollow()` | +| `Like` | Like object | Outbox entry stored | +| `Announce` | Boost/Reblog | Outbox entry stored | + +Content created via Create/Update goes through `Posts::prepare_content()` which applies `wpautop()`, processes links and hashtags, and converts HTML to Gutenberg blocks. Hashtags are automatically saved as WordPress tags via `Hashtag::insert_post()`. + +#### Architecture: Outbox Handlers + +Outbox handlers live in `includes/handler/outbox/` (separate from S2S inbox handlers in `includes/handler/`). They are thin wrappers that delegate to `Collection\Posts` for CRUD and fire action hooks for federation dispatch. + +#### Create Activity Handler + +```php +public function handle_create( $activity, $user ) { + $object = $activity->get_object(); + $type = $object->get_type(); + + // Wrap bare objects in Create activity + if ( ! $activity->get_type() ) { + $activity = Activity::wrap_in_create( $object, $user ); + } + + switch ( $type ) { + case 'Note': + return $this->create_note( $object, $user ); + case 'Article': + return $this->create_article( $object, $user ); + case 'Image': + case 'Video': + case 'Audio': + return $this->create_media( $object, $user ); + default: + return new WP_Error( 'unsupported_type', "Type $type not supported" ); + } +} + +private function create_note( $object, $user ) { + $post_data = array( + 'post_author' => $user->get__id(), + 'post_content' => $object->get_content(), + 'post_status' => 'publish', + 'post_type' => 'post', + ); + + // Handle addressing for visibility + $to = $object->get_to(); + if ( in_array( 'https://www.w3.org/ns/activitystreams#Public', $to ) ) { + $post_data['post_status'] = 'publish'; + } else { + $post_data['post_status'] = 'private'; + } + + $post_id = wp_insert_post( $post_data, true ); + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + // Return 201 Created with Location header + return array( + 'id' => Transformer::get_activity_id_for_post( $post_id ), + 'post_id' => $post_id, + 'location' => get_permalink( $post_id ), + ); +} +``` + +### 2.3 Response Format + +Per W3C spec, return `201 Created` with `Location` header: + +```php +public function create_item( $request ) { + $activity = $this->parse_activity( $request ); + $result = $this->process_activity( $activity ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $response = new WP_REST_Response( $result, 201 ); + $response->header( 'Location', $result['id'] ); + + return $response; +} +``` + +--- + +## Phase 3: Server-Sent Events (SSE) ⬜ + +**Status**: Future — not yet implemented + +### 3.1 SSE Endpoint + +New file: `includes/rest/class-sse-controller.php` + +```php +class SSE_Controller extends WP_REST_Controller { + + public function register_routes() { + register_rest_route( + ACTIVITYPUB_REST_NAMESPACE, + '/users/(?P[\d]+)/stream', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'stream' ), + 'permission_callback' => array( $this, 'stream_permissions_check' ), + ) + ); + } + + public function stream( $request ) { + $user_id = $request->get_param( 'user_id' ); + $last_event_id = $request->get_header( 'Last-Event-ID' ); + + // Set SSE headers + header( 'Content-Type: text/event-stream' ); + header( 'Cache-Control: no-cache' ); + header( 'Connection: keep-alive' ); + header( 'X-Accel-Buffering: no' ); // Disable nginx buffering + + // Flush headers + if ( ob_get_level() ) { + ob_end_flush(); + } + flush(); + + // Stream loop + $stream = new SSE_Stream( $user_id, $last_event_id ); + $stream->run(); + + exit; + } +} +``` + +### 3.2 Event Types + +```php +class SSE_Event { + const ADD = 'Add'; + const REMOVE = 'Remove'; + const UPDATE = 'Update'; + const DELETE = 'Delete'; +} +``` + +### 3.3 Event Generation Hooks + +Hook into WordPress actions to generate SSE events: + +```php +// New follower +add_action( 'activitypub_followers_post_follow', function( $user_id, $follower ) { + SSE_Stream::broadcast( $user_id, 'followers', SSE_Event::ADD, $follower ); +}, 10, 2 ); + +// New inbox activity +add_action( 'activitypub_inbox_received', function( $user_id, $activity ) { + SSE_Stream::broadcast( $user_id, 'inbox', SSE_Event::ADD, $activity ); +}, 10, 2 ); + +// Post published (outbox) +add_action( 'activitypub_outbox_created', function( $user_id, $activity ) { + SSE_Stream::broadcast( $user_id, 'outbox', SSE_Event::ADD, $activity ); +}, 10, 2 ); +``` + +### 3.4 Actor eventStream Property + +Add to actor JSON-LD: + +```php +public function get_event_stream() { + if ( ! $this->supports_sse() ) { + return null; + } + + return get_rest_url_by_path( 'users/' . $this->get__id() . '/stream' ); +} +``` + +--- + +## Phase 4: Feature Discovery ⬜ + +**Status**: Partially implemented — OAuth endpoints advertised in actor profiles, Authorization Server Metadata at `/.well-known/oauth-authorization-server` + +### 4.1 NodeInfo Extensions + +Extend existing NodeInfo to include C2S features: + +```php +public function get_nodeinfo_2_1() { + $nodeinfo = parent::get_nodeinfo_2_1(); + + $nodeinfo['metadata']['activitypubApi'] = array( + 'version' => '1.0', + 'features' => array( + 'oauth2' => true, + 'postToOutbox' => true, + 'mediaUpload' => true, + 'serverSentEvents' => true, + 'collections' => array( + 'inbox', + 'outbox', + 'followers', + 'following', + 'liked', + 'featured', + ), + 'activityTypes' => array( + 'Create', + 'Update', + 'Delete', + 'Follow', + 'Like', + 'Announce', + 'Undo', + 'Add', + 'Remove', + ), + ), + ); + + return $nodeinfo; +} +``` + +### 4.2 Actor Capabilities + +Use FEP-844e pattern for capability discovery: + +```php +public function get_capabilities() { + return array( + 'acceptsChatMessages' => false, + 'supportsClientToServer' => true, + 'supportsServerSentEvents' => true, + 'supportedActivities' => array( + 'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo' + ), + ); +} +``` + +--- + +## Phase 5: Collection Enhancements ⬜ + +**Status**: Future — not yet implemented. Note: `Collection\Posts` (local CRUD) and `Collection\Remote_Posts` (federated posts) have been split into separate classes. + +### 5.1 Collection Filtering + +Add query parameters to collection endpoints: + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `type` | Filter by object type | `?type=Note` | +| `object` | Filter by object ID | `?object=https://...` | +| `actor` | Filter by actor | `?actor=https://...` | +| `after` | Pagination cursor | `?after=2024-01-01` | +| `limit` | Page size | `?limit=20` | + +```php +public function get_collection_items( $request ) { + $args = array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => $request->get_param( 'limit' ) ?? 20, + ); + + // Type filter + if ( $type = $request->get_param( 'type' ) ) { + $args['meta_query'][] = array( + 'key' => 'activitypub_object_type', + 'value' => $type, + ); + } + + // Object filter + if ( $object = $request->get_param( 'object' ) ) { + $args['meta_query'][] = array( + 'key' => 'activitypub_object_id', + 'value' => $object, + ); + } + + return new WP_Query( $args ); +} +``` + +### 5.2 Collection Membership Check + +New endpoint: `GET /collections/{id}/contains?object={uri}` + +```php +register_rest_route( + ACTIVITYPUB_REST_NAMESPACE, + '/users/(?P[\d]+)/(?Pfollowers|following|liked)/contains', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'check_membership' ), + 'args' => array( + 'object' => array( + 'required' => true, + 'type' => 'string', + 'format' => 'uri', + ), + ), + ) +); + +public function check_membership( $request ) { + $collection = $request->get_param( 'collection' ); + $object = $request->get_param( 'object' ); + + $is_member = $this->collection_contains( $collection, $object ); + + return array( + 'isMember' => $is_member, + ); +} +``` + +### 5.3 Additional Collections + +| Collection | Description | Status | +|------------|-------------|--------| +| `liked` | Objects the actor has liked | New | +| `bookmarks` | Saved/bookmarked objects | New | +| `blocked` | Blocked actors | New | +| `muted` | Muted actors | New | +| `pendingFollowers` | Awaiting approval | New | +| `pendingFollowing` | Sent, awaiting response | New | + +--- + +## Phase 6: Media Upload ⬜ + +**Status**: Future — not yet implemented + +### 6.1 Upload Endpoint + +```php +register_rest_route( + ACTIVITYPUB_REST_NAMESPACE, + '/users/(?P[\d]+)/media', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'upload_media' ), + 'permission_callback' => array( $this, 'upload_permissions_check' ), + ) +); + +public function upload_media( $request ) { + $files = $request->get_file_params(); + $file = $files['file'] ?? null; + + if ( ! $file ) { + return new WP_Error( 'no_file', 'No file uploaded', array( 'status' => 400 ) ); + } + + // Use WordPress media handling + require_once ABSPATH . 'wp-admin/includes/image.php'; + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + + $attachment_id = media_handle_upload( 'file', 0 ); + + if ( is_wp_error( $attachment_id ) ) { + return $attachment_id; + } + + $url = wp_get_attachment_url( $attachment_id ); + $mime = get_post_mime_type( $attachment_id ); + + // Return ActivityPub-compatible object + return array( + 'id' => $url, + 'type' => $this->mime_to_as_type( $mime ), + 'mediaType' => $mime, + 'url' => $url, + ); +} +``` + +--- + +## Phase 7: User Controls ⬜ + +**Status**: Future — not yet implemented + +### 7.1 Mute/Block Endpoints + +```php +// POST /users/{id}/muted +register_rest_route( + ACTIVITYPUB_REST_NAMESPACE, + '/users/(?P[\d]+)/muted', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'mute_actor' ), + ) +); + +// DELETE /users/{id}/muted/{actor} +register_rest_route( + ACTIVITYPUB_REST_NAMESPACE, + '/users/(?P[\d]+)/muted/(?P.+)', + array( + 'methods' => 'DELETE', + 'callback' => array( $this, 'unmute_actor' ), + ) +); +``` + +### 7.2 Follow Request Management + +```php +// GET /users/{id}/pendingFollowers +// POST /users/{id}/pendingFollowers/{id}/accept +// POST /users/{id}/pendingFollowers/{id}/reject +``` + +### 7.3 Profile Editing + +```php +// PATCH /users/{id} +public function update_profile( $request ) { + $user_id = $request->get_param( 'user_id' ); + $updates = $request->get_json_params(); + + $allowed_fields = array( + 'name' => 'display_name', + 'summary' => 'description', + 'icon' => 'avatar', // Requires special handling + 'image' => 'header', // Requires special handling + ); + + foreach ( $updates as $field => $value ) { + if ( isset( $allowed_fields[ $field ] ) ) { + $this->update_user_field( $user_id, $allowed_fields[ $field ], $value ); + } + } + + return $this->get_actor( $user_id ); +} +``` + +--- + +## Phase 8: Search & Discovery ⬜ + +**Status**: Future — not yet implemented + +### 8.1 Search Endpoint + +```php +register_rest_route( + ACTIVITYPUB_REST_NAMESPACE, + '/search', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'search' ), + 'args' => array( + 'q' => array( 'required' => true, 'type' => 'string' ), + 'type' => array( 'enum' => array( 'accounts', 'statuses', 'hashtags' ) ), + 'limit' => array( 'default' => 20, 'maximum' => 40 ), + ), + ) +); +``` + +### 8.2 Actor Directory + +```php +register_rest_route( + ACTIVITYPUB_REST_NAMESPACE, + '/directory', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_directory' ), + ) +); +``` + +--- + +## Phase 9: Error Handling & Rate Limiting + +### 9.1 Standard Error Format + +Per SWICG user story #30: + +```php +class API_Error extends WP_Error { + public function to_response() { + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Error', + 'error' => $this->get_error_code(), + 'message' => $this->get_error_message(), + 'details' => $this->get_error_data(), + ); + } +} +``` + +### 9.2 Rate Limiting + +```php +// Add rate limit headers to responses +add_filter( 'rest_post_dispatch', function( $response, $server, $request ) { + $limits = Rate_Limiter::get_limits( $request ); + + $response->header( 'X-RateLimit-Limit', $limits['limit'] ); + $response->header( 'X-RateLimit-Remaining', $limits['remaining'] ); + $response->header( 'X-RateLimit-Reset', $limits['reset'] ); + + return $response; +}, 10, 3 ); +``` + +--- + +## Implementation Priority + +### Implemented (MVP) +1. ✅ **Phase 1**: OAuth 2.0 with PKCE, dynamic client registration, token introspection/revocation +2. ✅ **Phase 2**: POST to Outbox — Create, Update, Delete, Follow, Undo, Like, Announce +3. ✅ **Inbox GET**: Authenticated read access to user inbox +4. ✅ **Connected Applications UI**: User profile section for OAuth management +5. ✅ **Content Pipeline**: HTML-to-blocks conversion, hashtag/link processing + +### Next Up +6. ⬜ **Phase 6**: Media upload +7. ⬜ **Phase 5**: Collection filtering and membership checks +8. ⬜ **Phase 4**: Extended feature discovery (NodeInfo, capabilities) + +### Future +9. ⬜ **Phase 3**: SSE push delivery +10. ⬜ **Phase 7**: Mute/block/profile editing +11. ⬜ **Phase 8**: Search & discovery +12. ⬜ **Phase 9**: Rate limiting + +--- + +## File Structure (Implemented) + +``` +includes/ +├── oauth/ +│ ├── class-server.php # OAuth server logic, Bearer token validation +│ ├── class-token.php # Token model (transients + user meta) +│ ├── class-client.php # Client registration model (CPT) +│ └── class-scope.php # Scope definitions (READ, WRITE, FOLLOW) +├── rest/ +│ ├── class-outbox-controller.php # Extended with POST (create_item) +│ ├── class-actors-inbox-controller.php # Extended with GET for authenticated users +│ └── class-proxy-controller.php # Proxy endpoint for fetching remote objects +├── collection/ +│ ├── class-posts.php # Local posts CRUD (create/update/delete/prepare_content) +│ └── class-remote-posts.php # Federated remote posts (renamed from class-posts.php) +├── handler/ +│ └── outbox/ # Outbox activity handlers +│ ├── class-create.php # Delegates to Collection\Posts::create() +│ ├── class-update.php # Delegates to Collection\Posts::update() +│ ├── class-delete.php # Post/comment deletion +│ ├── class-follow.php # Follow/unfollow management +│ ├── class-like.php # Like activities +│ └── class-announce.php # Boost/reblog activities +├── wp-admin/ +│ ├── class-admin.php # AJAX handlers for Connected Applications +│ └── class-user-settings-fields.php # Connected Applications UI section +└── class-blocks.php # Extended with convert_from_html() +assets/js/ +└── activitypub-connected-apps.js # Client-side for token revocation and client registration +``` + +--- + +## Testing Strategy + +### Unit Tests +- OAuth token generation and validation +- Activity parsing and processing +- Collection membership checks + +### Integration Tests +- Full OAuth flow with PKCE +- POST to outbox creates WordPress post +- SSE events fire on collection changes + +### E2E Tests +- Use SWICG example OAuth client +- Test with existing ActivityPub clients (if any support C2S) + +--- + +## References + +- [W3C ActivityPub Spec](https://www.w3.org/TR/activitypub/) - Sections 6-9 for C2S +- [SWICG ActivityPub API](https://github.com/swicg/activitypub-api) - User stories and extensions +- [SWICG SSE Spec](https://swicg.github.io/activitypub-api/sse) - Server-Sent Events +- [OAuth 2.0 PKCE](https://oauth.net/2/pkce/) - Authorization Code Flow with PKCE +- [FEP-844e](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md) - Capability discovery + +--- + +## Open Questions + +1. **Scope granularity**: Should scopes be more fine-grained (e.g., `write:statuses`, `write:follows`)? Currently using `read`, `write`, `follow`. +2. ~~**Dynamic client registration**: Should we support RFC 7591?~~ → ✅ Implemented, enabled by default. +3. **Rate limits**: What are appropriate limits for POST to outbox? +4. **SSE scaling**: How to handle SSE with multiple PHP workers (Redis pub/sub)? +5. **Compatibility**: Should we maintain Mastodon API compatibility alongside ActivityPub API? From 232bddcda4a2b8ca7c72b80ca4897d6e0e80a60e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 18 Feb 2026 10:39:27 +0100 Subject: [PATCH 080/105] Replace implementation plan with C2S how-to guide Remove the internal dev roadmap and add a short user-facing guide for connecting ActivityPub clients to a WordPress site. --- docs/activitypub-api-implementation-plan.md | 831 -------------------- docs/how-to/client-to-server.md | 40 + 2 files changed, 40 insertions(+), 831 deletions(-) delete mode 100644 docs/activitypub-api-implementation-plan.md create mode 100644 docs/how-to/client-to-server.md diff --git a/docs/activitypub-api-implementation-plan.md b/docs/activitypub-api-implementation-plan.md deleted file mode 100644 index 001ef042ed..0000000000 --- a/docs/activitypub-api-implementation-plan.md +++ /dev/null @@ -1,831 +0,0 @@ -# ActivityPub API Implementation Plan - -This document outlines the implementation plan for adding SWICG ActivityPub API support to the WordPress ActivityPub plugin, enabling Client-to-Server (C2S) functionality. - -## Executive Summary - -The [SWICG ActivityPub API](https://github.com/swicg/activitypub-api) specification aims to standardize client-to-server ActivityPub interactions, allowing third-party clients to work across different ActivityPub servers. - -### Implementation Status - -| Feature | Status | Notes | -|---------|--------|-------| -| S2S Federation | ✅ Maintained | Unchanged | -| HTTP Signatures | ✅ Maintained | RFC-9421 + Draft-Cavage | -| OAuth 2.0 + PKCE | ✅ Implemented | Authorization code flow, token refresh, revocation, introspection | -| Dynamic Client Registration | ✅ Implemented | RFC 7591 + CIMD auto-discovery | -| POST to Outbox | ✅ Implemented | Create, Update, Delete, Follow, Undo, Like, Announce | -| GET Inbox | ✅ Implemented | OAuth-authenticated read access | -| CORS Headers | ✅ Implemented | Cross-origin support for C2S clients | -| Proxy Endpoint | ✅ Implemented | Fetch remote objects on behalf of clients | -| Connected Applications UI | ✅ Implemented | User profile section for managing OAuth tokens and registering clients | -| Content Pipeline | ✅ Implemented | HTML-to-blocks conversion, hashtag extraction, link processing | -| Application Passwords | ✅ Implemented | Alternative auth for C2S | -| SSE Push | ⬜ Future | Real-time updates | -| Media Upload | ⬜ Future | Standard upload endpoint | -| Search & Discovery | ⬜ Future | Content and actor search | -| User Controls | ⬜ Future | Mute/block management | - ---- - -## Phase 1: OAuth 2.0 Foundation ✅ - -**Status**: Implemented - -### 1.1 OAuth Server Implementation - -#### Files -``` -includes/oauth/ -├── class-server.php # OAuth 2.0 server logic, Bearer token validation -├── class-token.php # Token model (transient + user meta storage) -├── class-client.php # Client registration model -└── class-scope.php # Scope definitions and validation (READ, WRITE, FOLLOW) -``` - -#### Storage - -Tokens are stored using WordPress transients (access tokens) and user meta (refresh tokens), avoiding custom post types for performance. - -Clients are stored as `activitypub_oauth_client` custom post types with meta for `client_id`, `redirect_uris`, `grant_types`, and `scope`. - -#### REST Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/activitypub/1.0/oauth/authorize` | GET | Authorization page (redirect to WP login) | -| `/activitypub/1.0/oauth/token` | POST | Token exchange + refresh | -| `/activitypub/1.0/oauth/revoke` | POST | Token revocation (RFC 7009) | -| `/activitypub/1.0/oauth/introspect` | POST | Token introspection (RFC 7662) | -| `/activitypub/1.0/oauth/clients` | POST | Dynamic client registration (RFC 7591) | -| `/.well-known/oauth-authorization-server` | GET | Authorization Server Metadata | - -#### OAuth Flow (PKCE) - -``` -┌─────────────┐ ┌─────────────┐ -│ Client │ │ Server │ -└──────┬──────┘ └──────┬──────┘ - │ │ - │ 1. GET /oauth/authorize │ - │ ?response_type=code │ - │ &client_id=... │ - │ &redirect_uri=... │ - │ &scope=read write │ - │ &state=... │ - │ &code_challenge=... │ - │ &code_challenge_method=S256 │ - │ ─────────────────────────────────────────> │ - │ │ - │ 2. Redirect to WordPress login │ - │ <───────────────────────────────────────── │ - │ │ - │ 3. User authenticates & authorizes │ - │ ─────────────────────────────────────────> │ - │ │ - │ 4. Redirect to client with code │ - │ <───────────────────────────────────────── │ - │ │ - │ 5. POST /oauth/token │ - │ grant_type=authorization_code │ - │ &code=... │ - │ &code_verifier=... │ - │ ─────────────────────────────────────────> │ - │ │ - │ 6. Return access_token + refresh_token │ - │ <───────────────────────────────────────── │ - │ │ -``` - -#### Scopes (Implemented) - -| Scope | Description | -|-------|-------------| -| `read` | Read actor profile, collections, inbox | -| `write` | Create activities via POST to outbox | -| `follow` | Manage following relationships | - -### 1.2 Actor Endpoints Property - -Extend `get_endpoints()` in User/Blog models: - -```php -public function get_endpoints() { - $endpoints = array( - 'sharedInbox' => get_rest_url_by_path( 'inbox' ), - ); - - if ( $this->supports_c2s() ) { - $endpoints['oauthAuthorizationEndpoint'] = get_rest_url_by_path( 'oauth/authorize' ); - $endpoints['oauthTokenEndpoint'] = get_rest_url_by_path( 'oauth/token' ); - $endpoints['uploadMedia'] = get_rest_url_by_path( 'users/' . $this->get__id() . '/media' ); - } - - return $endpoints; -} -``` - -### 1.3 Authentication Filter - -Add Bearer token validation to REST API: - -```php -// Hook into WordPress REST authentication -add_filter( 'rest_authentication_errors', array( $this, 'authenticate_oauth' ), 20 ); - -public function authenticate_oauth( $result ) { - // Check for Bearer token - $auth_header = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; - if ( strpos( $auth_header, 'Bearer ' ) !== 0 ) { - return $result; // Let other auth methods handle - } - - $token = substr( $auth_header, 7 ); - $validated = OAuth\Token::validate( $token ); - - if ( is_wp_error( $validated ) ) { - return $validated; - } - - wp_set_current_user( $validated->user_id ); - return true; -} -``` - ---- - -## Phase 2: POST to Outbox (Core C2S) ✅ - -**Status**: Implemented - -### 2.1 Outbox POST Endpoint - -Extend `class-outbox-controller.php`: - -```php -// Add CREATABLE route -array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'create_item' ), - 'permission_callback' => array( $this, 'create_item_permissions_check' ), - 'args' => $this->get_create_item_args(), -) -``` - -#### Permission Check - -```php -public function create_item_permissions_check( $request ) { - // Must be authenticated via OAuth with 'write' scope - $token = OAuth\Server::get_current_token(); - - if ( ! $token || ! $token->has_scope( 'write' ) ) { - return new WP_Error( - 'activitypub_unauthorized', - __( 'OAuth token with write scope required', 'activitypub' ), - array( 'status' => 401 ) - ); - } - - // Token user must match actor - $user_id = $this->get_user_id_from_request( $request ); - if ( $token->user_id !== $user_id ) { - return new WP_Error( - 'activitypub_forbidden', - __( 'Token does not match actor', 'activitypub' ), - array( 'status' => 403 ) - ); - } - - return true; -} -``` - -### 2.2 Activity Processing - -#### Supported Activity Types (Implemented) - -| Activity | Action | WordPress Mapping | -|----------|--------|-------------------| -| `Create` + `Note` | Create post (status format) | `Collection\Posts::create()` | -| `Create` + `Article` | Create post | `Collection\Posts::create()` | -| `Update` | Update post | `Collection\Posts::update()` | -| `Delete` | Trash post / delete comment | `Collection\Posts::delete()` | -| `Follow` | Follow actor | Delegates to `follow()` for delivery | -| `Undo` + `Follow` | Unfollow actor | Delegates to `unfollow()` | -| `Like` | Like object | Outbox entry stored | -| `Announce` | Boost/Reblog | Outbox entry stored | - -Content created via Create/Update goes through `Posts::prepare_content()` which applies `wpautop()`, processes links and hashtags, and converts HTML to Gutenberg blocks. Hashtags are automatically saved as WordPress tags via `Hashtag::insert_post()`. - -#### Architecture: Outbox Handlers - -Outbox handlers live in `includes/handler/outbox/` (separate from S2S inbox handlers in `includes/handler/`). They are thin wrappers that delegate to `Collection\Posts` for CRUD and fire action hooks for federation dispatch. - -#### Create Activity Handler - -```php -public function handle_create( $activity, $user ) { - $object = $activity->get_object(); - $type = $object->get_type(); - - // Wrap bare objects in Create activity - if ( ! $activity->get_type() ) { - $activity = Activity::wrap_in_create( $object, $user ); - } - - switch ( $type ) { - case 'Note': - return $this->create_note( $object, $user ); - case 'Article': - return $this->create_article( $object, $user ); - case 'Image': - case 'Video': - case 'Audio': - return $this->create_media( $object, $user ); - default: - return new WP_Error( 'unsupported_type', "Type $type not supported" ); - } -} - -private function create_note( $object, $user ) { - $post_data = array( - 'post_author' => $user->get__id(), - 'post_content' => $object->get_content(), - 'post_status' => 'publish', - 'post_type' => 'post', - ); - - // Handle addressing for visibility - $to = $object->get_to(); - if ( in_array( 'https://www.w3.org/ns/activitystreams#Public', $to ) ) { - $post_data['post_status'] = 'publish'; - } else { - $post_data['post_status'] = 'private'; - } - - $post_id = wp_insert_post( $post_data, true ); - - if ( is_wp_error( $post_id ) ) { - return $post_id; - } - - // Return 201 Created with Location header - return array( - 'id' => Transformer::get_activity_id_for_post( $post_id ), - 'post_id' => $post_id, - 'location' => get_permalink( $post_id ), - ); -} -``` - -### 2.3 Response Format - -Per W3C spec, return `201 Created` with `Location` header: - -```php -public function create_item( $request ) { - $activity = $this->parse_activity( $request ); - $result = $this->process_activity( $activity ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - $response = new WP_REST_Response( $result, 201 ); - $response->header( 'Location', $result['id'] ); - - return $response; -} -``` - ---- - -## Phase 3: Server-Sent Events (SSE) ⬜ - -**Status**: Future — not yet implemented - -### 3.1 SSE Endpoint - -New file: `includes/rest/class-sse-controller.php` - -```php -class SSE_Controller extends WP_REST_Controller { - - public function register_routes() { - register_rest_route( - ACTIVITYPUB_REST_NAMESPACE, - '/users/(?P[\d]+)/stream', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'stream' ), - 'permission_callback' => array( $this, 'stream_permissions_check' ), - ) - ); - } - - public function stream( $request ) { - $user_id = $request->get_param( 'user_id' ); - $last_event_id = $request->get_header( 'Last-Event-ID' ); - - // Set SSE headers - header( 'Content-Type: text/event-stream' ); - header( 'Cache-Control: no-cache' ); - header( 'Connection: keep-alive' ); - header( 'X-Accel-Buffering: no' ); // Disable nginx buffering - - // Flush headers - if ( ob_get_level() ) { - ob_end_flush(); - } - flush(); - - // Stream loop - $stream = new SSE_Stream( $user_id, $last_event_id ); - $stream->run(); - - exit; - } -} -``` - -### 3.2 Event Types - -```php -class SSE_Event { - const ADD = 'Add'; - const REMOVE = 'Remove'; - const UPDATE = 'Update'; - const DELETE = 'Delete'; -} -``` - -### 3.3 Event Generation Hooks - -Hook into WordPress actions to generate SSE events: - -```php -// New follower -add_action( 'activitypub_followers_post_follow', function( $user_id, $follower ) { - SSE_Stream::broadcast( $user_id, 'followers', SSE_Event::ADD, $follower ); -}, 10, 2 ); - -// New inbox activity -add_action( 'activitypub_inbox_received', function( $user_id, $activity ) { - SSE_Stream::broadcast( $user_id, 'inbox', SSE_Event::ADD, $activity ); -}, 10, 2 ); - -// Post published (outbox) -add_action( 'activitypub_outbox_created', function( $user_id, $activity ) { - SSE_Stream::broadcast( $user_id, 'outbox', SSE_Event::ADD, $activity ); -}, 10, 2 ); -``` - -### 3.4 Actor eventStream Property - -Add to actor JSON-LD: - -```php -public function get_event_stream() { - if ( ! $this->supports_sse() ) { - return null; - } - - return get_rest_url_by_path( 'users/' . $this->get__id() . '/stream' ); -} -``` - ---- - -## Phase 4: Feature Discovery ⬜ - -**Status**: Partially implemented — OAuth endpoints advertised in actor profiles, Authorization Server Metadata at `/.well-known/oauth-authorization-server` - -### 4.1 NodeInfo Extensions - -Extend existing NodeInfo to include C2S features: - -```php -public function get_nodeinfo_2_1() { - $nodeinfo = parent::get_nodeinfo_2_1(); - - $nodeinfo['metadata']['activitypubApi'] = array( - 'version' => '1.0', - 'features' => array( - 'oauth2' => true, - 'postToOutbox' => true, - 'mediaUpload' => true, - 'serverSentEvents' => true, - 'collections' => array( - 'inbox', - 'outbox', - 'followers', - 'following', - 'liked', - 'featured', - ), - 'activityTypes' => array( - 'Create', - 'Update', - 'Delete', - 'Follow', - 'Like', - 'Announce', - 'Undo', - 'Add', - 'Remove', - ), - ), - ); - - return $nodeinfo; -} -``` - -### 4.2 Actor Capabilities - -Use FEP-844e pattern for capability discovery: - -```php -public function get_capabilities() { - return array( - 'acceptsChatMessages' => false, - 'supportsClientToServer' => true, - 'supportsServerSentEvents' => true, - 'supportedActivities' => array( - 'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo' - ), - ); -} -``` - ---- - -## Phase 5: Collection Enhancements ⬜ - -**Status**: Future — not yet implemented. Note: `Collection\Posts` (local CRUD) and `Collection\Remote_Posts` (federated posts) have been split into separate classes. - -### 5.1 Collection Filtering - -Add query parameters to collection endpoints: - -| Parameter | Description | Example | -|-----------|-------------|---------| -| `type` | Filter by object type | `?type=Note` | -| `object` | Filter by object ID | `?object=https://...` | -| `actor` | Filter by actor | `?actor=https://...` | -| `after` | Pagination cursor | `?after=2024-01-01` | -| `limit` | Page size | `?limit=20` | - -```php -public function get_collection_items( $request ) { - $args = array( - 'post_type' => Outbox::POST_TYPE, - 'posts_per_page' => $request->get_param( 'limit' ) ?? 20, - ); - - // Type filter - if ( $type = $request->get_param( 'type' ) ) { - $args['meta_query'][] = array( - 'key' => 'activitypub_object_type', - 'value' => $type, - ); - } - - // Object filter - if ( $object = $request->get_param( 'object' ) ) { - $args['meta_query'][] = array( - 'key' => 'activitypub_object_id', - 'value' => $object, - ); - } - - return new WP_Query( $args ); -} -``` - -### 5.2 Collection Membership Check - -New endpoint: `GET /collections/{id}/contains?object={uri}` - -```php -register_rest_route( - ACTIVITYPUB_REST_NAMESPACE, - '/users/(?P[\d]+)/(?Pfollowers|following|liked)/contains', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'check_membership' ), - 'args' => array( - 'object' => array( - 'required' => true, - 'type' => 'string', - 'format' => 'uri', - ), - ), - ) -); - -public function check_membership( $request ) { - $collection = $request->get_param( 'collection' ); - $object = $request->get_param( 'object' ); - - $is_member = $this->collection_contains( $collection, $object ); - - return array( - 'isMember' => $is_member, - ); -} -``` - -### 5.3 Additional Collections - -| Collection | Description | Status | -|------------|-------------|--------| -| `liked` | Objects the actor has liked | New | -| `bookmarks` | Saved/bookmarked objects | New | -| `blocked` | Blocked actors | New | -| `muted` | Muted actors | New | -| `pendingFollowers` | Awaiting approval | New | -| `pendingFollowing` | Sent, awaiting response | New | - ---- - -## Phase 6: Media Upload ⬜ - -**Status**: Future — not yet implemented - -### 6.1 Upload Endpoint - -```php -register_rest_route( - ACTIVITYPUB_REST_NAMESPACE, - '/users/(?P[\d]+)/media', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'upload_media' ), - 'permission_callback' => array( $this, 'upload_permissions_check' ), - ) -); - -public function upload_media( $request ) { - $files = $request->get_file_params(); - $file = $files['file'] ?? null; - - if ( ! $file ) { - return new WP_Error( 'no_file', 'No file uploaded', array( 'status' => 400 ) ); - } - - // Use WordPress media handling - require_once ABSPATH . 'wp-admin/includes/image.php'; - require_once ABSPATH . 'wp-admin/includes/file.php'; - require_once ABSPATH . 'wp-admin/includes/media.php'; - - $attachment_id = media_handle_upload( 'file', 0 ); - - if ( is_wp_error( $attachment_id ) ) { - return $attachment_id; - } - - $url = wp_get_attachment_url( $attachment_id ); - $mime = get_post_mime_type( $attachment_id ); - - // Return ActivityPub-compatible object - return array( - 'id' => $url, - 'type' => $this->mime_to_as_type( $mime ), - 'mediaType' => $mime, - 'url' => $url, - ); -} -``` - ---- - -## Phase 7: User Controls ⬜ - -**Status**: Future — not yet implemented - -### 7.1 Mute/Block Endpoints - -```php -// POST /users/{id}/muted -register_rest_route( - ACTIVITYPUB_REST_NAMESPACE, - '/users/(?P[\d]+)/muted', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'mute_actor' ), - ) -); - -// DELETE /users/{id}/muted/{actor} -register_rest_route( - ACTIVITYPUB_REST_NAMESPACE, - '/users/(?P[\d]+)/muted/(?P.+)', - array( - 'methods' => 'DELETE', - 'callback' => array( $this, 'unmute_actor' ), - ) -); -``` - -### 7.2 Follow Request Management - -```php -// GET /users/{id}/pendingFollowers -// POST /users/{id}/pendingFollowers/{id}/accept -// POST /users/{id}/pendingFollowers/{id}/reject -``` - -### 7.3 Profile Editing - -```php -// PATCH /users/{id} -public function update_profile( $request ) { - $user_id = $request->get_param( 'user_id' ); - $updates = $request->get_json_params(); - - $allowed_fields = array( - 'name' => 'display_name', - 'summary' => 'description', - 'icon' => 'avatar', // Requires special handling - 'image' => 'header', // Requires special handling - ); - - foreach ( $updates as $field => $value ) { - if ( isset( $allowed_fields[ $field ] ) ) { - $this->update_user_field( $user_id, $allowed_fields[ $field ], $value ); - } - } - - return $this->get_actor( $user_id ); -} -``` - ---- - -## Phase 8: Search & Discovery ⬜ - -**Status**: Future — not yet implemented - -### 8.1 Search Endpoint - -```php -register_rest_route( - ACTIVITYPUB_REST_NAMESPACE, - '/search', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'search' ), - 'args' => array( - 'q' => array( 'required' => true, 'type' => 'string' ), - 'type' => array( 'enum' => array( 'accounts', 'statuses', 'hashtags' ) ), - 'limit' => array( 'default' => 20, 'maximum' => 40 ), - ), - ) -); -``` - -### 8.2 Actor Directory - -```php -register_rest_route( - ACTIVITYPUB_REST_NAMESPACE, - '/directory', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'get_directory' ), - ) -); -``` - ---- - -## Phase 9: Error Handling & Rate Limiting - -### 9.1 Standard Error Format - -Per SWICG user story #30: - -```php -class API_Error extends WP_Error { - public function to_response() { - return array( - '@context' => 'https://www.w3.org/ns/activitystreams', - 'type' => 'Error', - 'error' => $this->get_error_code(), - 'message' => $this->get_error_message(), - 'details' => $this->get_error_data(), - ); - } -} -``` - -### 9.2 Rate Limiting - -```php -// Add rate limit headers to responses -add_filter( 'rest_post_dispatch', function( $response, $server, $request ) { - $limits = Rate_Limiter::get_limits( $request ); - - $response->header( 'X-RateLimit-Limit', $limits['limit'] ); - $response->header( 'X-RateLimit-Remaining', $limits['remaining'] ); - $response->header( 'X-RateLimit-Reset', $limits['reset'] ); - - return $response; -}, 10, 3 ); -``` - ---- - -## Implementation Priority - -### Implemented (MVP) -1. ✅ **Phase 1**: OAuth 2.0 with PKCE, dynamic client registration, token introspection/revocation -2. ✅ **Phase 2**: POST to Outbox — Create, Update, Delete, Follow, Undo, Like, Announce -3. ✅ **Inbox GET**: Authenticated read access to user inbox -4. ✅ **Connected Applications UI**: User profile section for OAuth management -5. ✅ **Content Pipeline**: HTML-to-blocks conversion, hashtag/link processing - -### Next Up -6. ⬜ **Phase 6**: Media upload -7. ⬜ **Phase 5**: Collection filtering and membership checks -8. ⬜ **Phase 4**: Extended feature discovery (NodeInfo, capabilities) - -### Future -9. ⬜ **Phase 3**: SSE push delivery -10. ⬜ **Phase 7**: Mute/block/profile editing -11. ⬜ **Phase 8**: Search & discovery -12. ⬜ **Phase 9**: Rate limiting - ---- - -## File Structure (Implemented) - -``` -includes/ -├── oauth/ -│ ├── class-server.php # OAuth server logic, Bearer token validation -│ ├── class-token.php # Token model (transients + user meta) -│ ├── class-client.php # Client registration model (CPT) -│ └── class-scope.php # Scope definitions (READ, WRITE, FOLLOW) -├── rest/ -│ ├── class-outbox-controller.php # Extended with POST (create_item) -│ ├── class-actors-inbox-controller.php # Extended with GET for authenticated users -│ └── class-proxy-controller.php # Proxy endpoint for fetching remote objects -├── collection/ -│ ├── class-posts.php # Local posts CRUD (create/update/delete/prepare_content) -│ └── class-remote-posts.php # Federated remote posts (renamed from class-posts.php) -├── handler/ -│ └── outbox/ # Outbox activity handlers -│ ├── class-create.php # Delegates to Collection\Posts::create() -│ ├── class-update.php # Delegates to Collection\Posts::update() -│ ├── class-delete.php # Post/comment deletion -│ ├── class-follow.php # Follow/unfollow management -│ ├── class-like.php # Like activities -│ └── class-announce.php # Boost/reblog activities -├── wp-admin/ -│ ├── class-admin.php # AJAX handlers for Connected Applications -│ └── class-user-settings-fields.php # Connected Applications UI section -└── class-blocks.php # Extended with convert_from_html() -assets/js/ -└── activitypub-connected-apps.js # Client-side for token revocation and client registration -``` - ---- - -## Testing Strategy - -### Unit Tests -- OAuth token generation and validation -- Activity parsing and processing -- Collection membership checks - -### Integration Tests -- Full OAuth flow with PKCE -- POST to outbox creates WordPress post -- SSE events fire on collection changes - -### E2E Tests -- Use SWICG example OAuth client -- Test with existing ActivityPub clients (if any support C2S) - ---- - -## References - -- [W3C ActivityPub Spec](https://www.w3.org/TR/activitypub/) - Sections 6-9 for C2S -- [SWICG ActivityPub API](https://github.com/swicg/activitypub-api) - User stories and extensions -- [SWICG SSE Spec](https://swicg.github.io/activitypub-api/sse) - Server-Sent Events -- [OAuth 2.0 PKCE](https://oauth.net/2/pkce/) - Authorization Code Flow with PKCE -- [FEP-844e](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md) - Capability discovery - ---- - -## Open Questions - -1. **Scope granularity**: Should scopes be more fine-grained (e.g., `write:statuses`, `write:follows`)? Currently using `read`, `write`, `follow`. -2. ~~**Dynamic client registration**: Should we support RFC 7591?~~ → ✅ Implemented, enabled by default. -3. **Rate limits**: What are appropriate limits for POST to outbox? -4. **SSE scaling**: How to handle SSE with multiple PHP workers (Redis pub/sub)? -5. **Compatibility**: Should we maintain Mastodon API compatibility alongside ActivityPub API? diff --git a/docs/how-to/client-to-server.md b/docs/how-to/client-to-server.md new file mode 100644 index 0000000000..af4f828061 --- /dev/null +++ b/docs/how-to/client-to-server.md @@ -0,0 +1,40 @@ +# Connecting an ActivityPub client to your site + +The plugin supports the ActivityPub Client-to-Server (C2S) protocol, allowing third-party apps to create, edit, and delete posts on your behalf. + +## Enable C2S + +Go to **Settings > ActivityPub > Advanced** and enable **Client-to-Server**. + +## Connect a client + +1. Open an ActivityPub C2S client (e.g. [box](https://github.com/go-ap/box)). +2. Point it at your site's actor URL (e.g. `https://example.com/author/yourname`). +3. The client will discover the OAuth endpoints from your actor profile and start the authorization flow. +4. Log in to WordPress when prompted and approve the request. +5. The client receives an access token and can now post to your outbox. + +## Register a client manually + +If your client does not support dynamic registration, you can register it from your WordPress profile: + +1. Go to **Users > Profile > Connected Applications**. +2. Enter the application name and redirect URI, then click **Register Application**. +3. Copy the client ID shown in the notice and configure it in your client. + +## Manage connected apps + +The **Connected Applications** section on your profile page lists all active OAuth tokens. You can revoke individual tokens or all of them at once. + +## Supported activities + +Clients can POST these activities to your outbox: + +- **Create** (Note or Article) — creates a WordPress post +- **Update** — updates an existing post +- **Delete** — trashes a post +- **Follow** / **Undo Follow** — manage who you follow +- **Like** — like a remote object +- **Announce** — boost/reblog a remote object + +Notes are created with the "status" post format. Articles use the `name` field as the post title and `summary` as the excerpt. Hashtags in content are automatically saved as WordPress tags. From c0ef3275319bba9195f841f130d9e8939336ccaf Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 18 Feb 2026 22:26:20 +0100 Subject: [PATCH 081/105] Enable CORS for all ActivityPub endpoints Broaden CORS headers to cover all ActivityPub REST namespace routes (actors, followers, following, etc.), not just inbox/outbox. Also add Accept to allowed headers for content negotiation. --- includes/oauth/class-server.php | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 81e95a4c44..6290d2fe6e 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -268,7 +268,7 @@ public static function add_cors_headers( $response, $server, $request ) { $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? \esc_url_raw( \wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; $response->header( 'Access-Control-Allow-Origin', $origin ? $origin : '*' ); $response->header( 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS' ); - $response->header( 'Access-Control-Allow-Headers', 'Content-Type, Authorization' ); + $response->header( 'Access-Control-Allow-Headers', 'Accept, Content-Type, Authorization' ); if ( $origin ) { $response->header( 'Vary', 'Origin' ); @@ -286,26 +286,11 @@ public static function add_cors_headers( $response, $server, $request ) { private static function route_needs_cors( $route ) { $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE; - // OAuth endpoints (except the interactive authorize endpoint which redirects). - if ( 0 === strpos( $route, $namespace . '/oauth/' ) ) { + // All ActivityPub endpoints need CORS except the interactive OAuth authorize endpoint. + if ( 0 === strpos( $route, $namespace ) ) { return $namespace . '/oauth/authorize' !== $route; } - // Proxy endpoint for fetching remote objects. - if ( $namespace . '/proxy' === $route ) { - return true; - } - - // C2S outbox and inbox endpoints. - if ( \str_ends_with( $route, '/outbox' ) || \str_ends_with( $route, '/inbox' ) ) { - return true; - } - - // WebFinger endpoint. - if ( $namespace . '/webfinger' === $route ) { - return true; - } - return false; } From 9024b6f7465db16779fa9905ae713b0dfb630700 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 18 Feb 2026 22:37:38 +0100 Subject: [PATCH 082/105] Update E2E tests to expect CORS headers on all AP endpoints Tests previously asserted actors and followers endpoints had no CORS headers. Now that CORS covers the full AP namespace, update assertions accordingly. Also verify Accept is in allowed headers. --- tests/e2e/specs/includes/rest/oauth-controller.test.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/e2e/specs/includes/rest/oauth-controller.test.js b/tests/e2e/specs/includes/rest/oauth-controller.test.js index 064251ab09..e0f0d0d314 100644 --- a/tests/e2e/specs/includes/rest/oauth-controller.test.js +++ b/tests/e2e/specs/includes/rest/oauth-controller.test.js @@ -12,6 +12,7 @@ test.describe( 'OAuth Controller CORS Headers', () => { expect( response.status() ).toBe( 200 ); expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); expect( response.headers()[ 'access-control-allow-methods' ] ).toContain( 'GET' ); + expect( response.headers()[ 'access-control-allow-headers' ] ).toContain( 'Accept' ); expect( response.headers()[ 'access-control-allow-headers' ] ).toContain( 'Authorization' ); } ); @@ -46,17 +47,17 @@ test.describe( 'OAuth Controller CORS Headers', () => { expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); } ); - test( 'should NOT include CORS headers on actors endpoint', async ( { request } ) => { + test( 'should include CORS headers on actors endpoint', async ( { request } ) => { const response = await request.get( `${ restBase }/activitypub/1.0/actors/1` ); expect( response.status() ).toBe( 200 ); - expect( response.headers()[ 'access-control-allow-origin' ] ).toBeUndefined(); + expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); } ); - test( 'should NOT include CORS headers on followers endpoint', async ( { request } ) => { + test( 'should include CORS headers on followers endpoint', async ( { request } ) => { const response = await request.get( `${ restBase }/activitypub/1.0/actors/1/followers` ); expect( response.status() ).toBe( 200 ); - expect( response.headers()[ 'access-control-allow-origin' ] ).toBeUndefined(); + expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); } ); } ); From 24c990ad8b81d969d77eb15a11ed3eeb4040d216 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 19 Feb 2026 18:01:06 +0100 Subject: [PATCH 083/105] Fix outbox handlers rejecting C2S activities without addressing Use `is_activity_public()` instead of `get_activity_visibility()` in outbox Create and Update handlers. Add `ensure_addressing()` to the outbox controller so the server adds default public addressing when a C2S client omits recipients, per the ActivityPub spec. Update tests to include proper `to` addressing. --- includes/handler/outbox/class-create.php | 6 ++-- includes/handler/outbox/class-update.php | 6 ++-- includes/rest/class-outbox-controller.php | 29 +++++++++++++++++++ .../handler/outbox/class-test-update.php | 5 ++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/includes/handler/outbox/class-create.php b/includes/handler/outbox/class-create.php index cba661735e..c0d42d73ac 100644 --- a/includes/handler/outbox/class-create.php +++ b/includes/handler/outbox/class-create.php @@ -10,7 +10,7 @@ use Activitypub\Collection\Interactions; use Activitypub\Collection\Posts; -use function Activitypub\get_activity_visibility; +use function Activitypub\is_activity_public; use function Activitypub\is_activity_reply; use function Activitypub\is_quote_activity; @@ -37,8 +37,8 @@ public static function init() { * @return int|\WP_Error|null The outbox ID on success, WP_Error on failure, null if not handled. */ public static function handle_create( $activity, $user_id = null, $visibility = null ) { - // Check for private and/or direct messages. - if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) { + // Skip private/direct activities. + if ( ! is_activity_public( $activity ) ) { return false; } diff --git a/includes/handler/outbox/class-update.php b/includes/handler/outbox/class-update.php index 7b64665167..d4f14ad4d5 100644 --- a/includes/handler/outbox/class-update.php +++ b/includes/handler/outbox/class-update.php @@ -10,7 +10,7 @@ use Activitypub\Collection\Posts; use Activitypub\Collection\Remote_Posts; -use function Activitypub\get_activity_visibility; +use function Activitypub\is_activity_public; /** * Handle outgoing Update activities (C2S). @@ -36,8 +36,8 @@ public static function init() { * @return \WP_Post|null The updated post on success, null if not handled. */ public static function handle_update( $activity, $user_id = null, $visibility = null ) { - // Check for private and/or direct messages. - if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) { + // Skip private/direct activities. + if ( ! is_activity_public( $activity ) ) { return false; } diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 900c53bb37..531c6f3496 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -13,6 +13,7 @@ use Activitypub\Collection\Outbox; use function Activitypub\add_to_outbox; +use function Activitypub\extract_recipients_from_activity; use function Activitypub\get_masked_wp_version; use function Activitypub\get_object_id; use function Activitypub\get_rest_url_by_path; @@ -379,6 +380,9 @@ public function create_item( $request ) { $data = $this->wrap_in_create( $data, $user ); } + // C2S: default to public addressing if client omits recipients. + $data = $this->ensure_addressing( $data, $user ); + // Determine visibility from addressing. $visibility = $this->determine_visibility( $data ); @@ -536,6 +540,31 @@ private function validate_ownership( $data, $user ) { return true; } + /** + * Add default public addressing when the client omits recipients. + * + * Per the ActivityPub spec, the server adds addressing when the client + * does not provide it. Defaults to public with followers in cc. + * + * @since unreleased + * + * @param array $data The activity data. + * @param \Activitypub\Activity\Actor $user The authenticated user. + * @return array The activity data with addressing ensured. + */ + private function ensure_addressing( $data, $user ) { + $recipients = extract_recipients_from_activity( $data ); + + if ( ! empty( $recipients ) ) { + return $data; + } + + $data['to'] = array( 'https://www.w3.org/ns/activitystreams#Public' ); + $data['cc'] = array( $user->get_followers() ); + + return $data; + } + /** * Determine content visibility from activity addressing. * diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-update.php b/tests/phpunit/tests/includes/handler/outbox/class-test-update.php index 8703699399..2643ff7497 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-update.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-update.php @@ -64,6 +64,7 @@ public function test_outgoing_updates_post() { $data = array( 'type' => 'Update', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), 'object' => array( 'type' => 'Note', 'id' => $permalink, @@ -98,6 +99,7 @@ public function test_outgoing_generates_title_from_content() { $data = array( 'type' => 'Update', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), 'object' => array( 'type' => 'Note', 'id' => $permalink, @@ -130,6 +132,7 @@ public function test_outgoing_ignores_unsupported_types() { $data = array( 'type' => 'Update', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), 'object' => array( 'type' => 'Event', 'id' => $permalink, @@ -162,6 +165,7 @@ public function test_outgoing_skips_unowned_post() { $data = array( 'type' => 'Update', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), 'object' => array( 'type' => 'Note', 'id' => $permalink, @@ -234,6 +238,7 @@ public function test_outgoing_fires_action() { $data = array( 'type' => 'Update', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), 'object' => array( 'type' => 'Note', 'id' => $permalink, From 3c3859161b84c8bdb8e0669f93643f24ce507a85 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 19 Feb 2026 22:51:09 +0100 Subject: [PATCH 084/105] Change proxy endpoint from GET to POST per proxyUrl spec The proxyUrl endpoint should use POST with the `id` parameter in the request body, not GET with a query parameter. See https://www.w3.org/wiki/ActivityPub/Primer/proxyUrl_endpoint --- includes/rest/class-proxy-controller.php | 8 +++++--- .../rest/class-test-proxy-controller.php | 18 +++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php index 4d9b6c4193..9a6d34de9d 100644 --- a/includes/rest/class-proxy-controller.php +++ b/includes/rest/class-proxy-controller.php @@ -47,8 +47,8 @@ public function register_routes() { '/' . $this->rest_base, array( array( - 'methods' => \WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_item' ), + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'verify_authentication' ), 'args' => array( 'id' => array( @@ -88,10 +88,12 @@ public function validate_url( $url ) { /** * Fetch a remote ActivityPub object via the proxy. * + * @see https://www.w3.org/wiki/ActivityPub/Primer/proxyUrl_endpoint + * * @param \WP_REST_Request $request Full details about the request. * @return \WP_REST_Response|\WP_Error Response object on success, WP_Error on failure. */ - public function get_item( $request ) { + public function create_item( $request ) { $url = $request->get_param( 'id' ); // Try to fetch as an actor first using Remote_Actors which handles caching. diff --git a/tests/phpunit/tests/includes/rest/class-test-proxy-controller.php b/tests/phpunit/tests/includes/rest/class-test-proxy-controller.php index f3397faaaf..7248fbd5c8 100644 --- a/tests/phpunit/tests/includes/rest/class-test-proxy-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-proxy-controller.php @@ -80,8 +80,8 @@ public function test_http_url_rejected() { // Mock OAuth authentication. $this->mock_oauth_auth(); - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); - $request->set_query_params( array( 'id' => 'http://example.com/users/test' ) ); + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_body_params( array( 'id' => 'http://example.com/users/test' ) ); $response = $this->server->dispatch( $request ); @@ -102,8 +102,8 @@ public function test_private_network_rejected() { // Mock OAuth authentication. $this->mock_oauth_auth(); - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); - $request->set_query_params( array( 'id' => 'https://192.168.1.1/users/test' ) ); + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_body_params( array( 'id' => 'https://192.168.1.1/users/test' ) ); $response = $this->server->dispatch( $request ); @@ -119,8 +119,8 @@ public function test_private_network_rejected() { * @covers ::verify_authentication */ public function test_requires_authentication() { - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); - $request->set_query_params( array( 'id' => 'https://example.com/users/test' ) ); + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) ); $response = $this->server->dispatch( $request ); @@ -131,7 +131,7 @@ public function test_requires_authentication() { /** * Test successful proxy fetch of an actor. * - * @covers ::get_item + * @covers ::create_item */ public function test_successful_actor_fetch() { // Mock OAuth authentication. @@ -158,8 +158,8 @@ function () use ( $actor_data ) { } ); - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); - $request->set_query_params( array( 'id' => 'https://example.com/users/test' ) ); + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' ); + $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) ); $response = $this->server->dispatch( $request ); From 966722da9f4d1d8e55df5edc679be979108a2084 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 20 Feb 2026 10:00:16 +0100 Subject: [PATCH 085/105] Show followers/following collections for authenticated owner requests When `activitypub_hide_social_graph` is enabled, the followers/following endpoints now still return orderedItems for authenticated owners (via OAuth or Application Passwords). The hide setting only applies to public/anonymous access. --- includes/rest/class-followers-controller.php | 3 +-- includes/rest/class-following-controller.php | 2 +- includes/rest/trait-verification.php | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 7ae337716e..dcd8a3a91a 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -8,7 +8,6 @@ namespace Activitypub\Rest; use Activitypub\Activity\Base_Object; -use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; use Activitypub\Collection\Remote_Actors; @@ -158,7 +157,7 @@ public function get_items( $request ) { $response = array( '@context' => Base_Object::JSON_LD_CONTEXT ) + $response; } - if ( Actors::show_social_graph( $user_id ) ) { + if ( $this->show_social_graph( $request ) ) { $response['orderedItems'] = \array_filter( \array_map( static function ( $item ) use ( $context ) { diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php index 1e6300066a..e86f2839e9 100644 --- a/includes/rest/class-following-controller.php +++ b/includes/rest/class-following-controller.php @@ -115,7 +115,7 @@ public function get_items( $request ) { $response = array( '@context' => Base_Object::JSON_LD_CONTEXT ) + $response; } - if ( Actors::show_social_graph( $user_id ) ) { + if ( $this->show_social_graph( $request ) ) { $response['orderedItems'] = \array_filter( \array_map( static function ( $item ) use ( $context ) { diff --git a/includes/rest/trait-verification.php b/includes/rest/trait-verification.php index bed806bc73..9b3cd3820f 100644 --- a/includes/rest/trait-verification.php +++ b/includes/rest/trait-verification.php @@ -184,4 +184,21 @@ public function verify_owner( $request ) { array( 'status' => 403 ) ); } + + /** + * Check if the social graph should be shown for this request. + * + * Returns true if the social graph setting allows public display, + * or if the request is authenticated by the resource owner. + * + * @since unreleased + * + * @param \WP_REST_Request $request The request object. + * @return bool True if the social graph should be shown. + */ + protected function show_social_graph( $request ) { + $user_id = $request->get_param( 'user_id' ); + + return Actors::show_social_graph( $user_id ) || true === $this->verify_owner( $request ); + } } From ec800f41b738e1d521a31b78490fff1ff5bbbec2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 20 Feb 2026 10:15:58 +0100 Subject: [PATCH 086/105] Inline shared inbox args and fix permission callback after merge Remove get_create_item_args() method and inline the args directly in register_routes() for consistency with all other controllers. Also fix permission_callback to use the Verification trait method instead of the removed Server::verify_signature static method. --- includes/rest/class-inbox-controller.php | 78 +----------------------- 1 file changed, 1 insertion(+), 77 deletions(-) diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index fca1220bb0..d0353f3ae0 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -57,7 +57,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'permission_callback' => array( $this, 'verify_signature' ), 'args' => array( 'id' => array( 'description' => 'The unique identifier for the activity.', @@ -133,82 +133,6 @@ public function register_routes() { ); } - /** - * Get the arguments for create_item. - * - * @return array The arguments. - */ - private function get_create_item_args() { - return array( - 'id' => array( - 'description' => 'The unique identifier for the activity.', - 'type' => 'string', - 'format' => 'uri', - 'required' => true, - ), - 'actor' => array( - 'description' => 'The actor performing the activity.', - 'type' => 'string', - 'required' => true, - 'sanitize_callback' => '\Activitypub\object_to_uri', - ), - 'type' => array( - 'description' => 'The type of the activity.', - 'type' => 'string', - 'required' => true, - ), - 'object' => array( - 'description' => 'The object of the activity.', - 'required' => true, - 'validate_callback' => static function ( $param, $request, $key ) { - /** - * Filter the ActivityPub object validation. - * - * @param bool $validate The validation result. - * @param array $param The object data. - * @param \WP_REST_Request $request The request object. - * @param string $key The key. - */ - return \apply_filters( 'activitypub_validate_object', true, $param, $request, $key ); - }, - ), - 'to' => array( - 'description' => 'The primary recipients of the activity.', - 'type' => array( 'string', 'array' ), - 'required' => false, - 'sanitize_callback' => static function ( $param ) { - if ( \is_string( $param ) ) { - $param = array( $param ); - } - - return $param; - }, - ), - 'cc' => array( - 'description' => 'The secondary recipients of the activity.', - 'type' => array( 'string', 'array' ), - 'sanitize_callback' => static function ( $param ) { - if ( \is_string( $param ) ) { - $param = array( $param ); - } - - return $param; - }, - ), - 'bcc' => array( - 'description' => 'The private recipients of the activity.', - 'type' => array( 'string', 'array' ), - 'sanitize_callback' => static function ( $param ) { - if ( \is_string( $param ) ) { - $param = array( $param ); - } - - return $param; - }, - ), - ); - } - /** * The shared inbox. * From f93ad3d526467186c1e94e845112781fc3778271 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 20 Feb 2026 12:03:06 +0100 Subject: [PATCH 087/105] Add PHPUnit tests for OAuth REST controllers Test the three OAuth REST controllers (Authorization, Token, Clients) at the dispatch level covering route registration, parameter validation, authentication, and success flows. --- .../class-test-authorization-controller.php | 258 +++++++++++++ .../oauth/class-test-clients-controller.php | 152 ++++++++ .../oauth/class-test-token-controller.php | 349 ++++++++++++++++++ 3 files changed, 759 insertions(+) create mode 100644 tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php create mode 100644 tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php create mode 100644 tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php diff --git a/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php b/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php new file mode 100644 index 0000000000..5038f9c6c9 --- /dev/null +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php @@ -0,0 +1,258 @@ +user_id = $this->factory->user->create( + array( + 'role' => 'editor', + ) + ); + + $client_result = Client::register( + array( + 'name' => 'Test Auth Client', + 'redirect_uris' => array( $this->redirect_uri ), + ) + ); + $this->client_id = $client_result['client_id']; + } + + /** + * Tear down the test. + */ + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + + if ( $this->client_id ) { + Client::delete( $this->client_id ); + } + + parent::tear_down(); + } + + /** + * Test that authorization routes are registered. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = \rest_get_server()->get_routes(); + $route = '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorize'; + + $this->assertArrayHasKey( $route, $routes ); + + // Should have GET and POST endpoints. + $methods = array(); + foreach ( $routes[ $route ] as $endpoint ) { + $methods = array_merge( $methods, array_keys( $endpoint['methods'] ) ); + } + $this->assertContains( 'GET', $methods ); + $this->assertContains( 'POST', $methods ); + } + + /** + * Test that GET authorize redirects to wp-login.php for valid client. + * + * @covers ::authorize + */ + public function test_authorize_redirects_to_login() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorize' ); + $request->set_param( 'response_type', 'code' ); + $request->set_param( 'client_id', $this->client_id ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + $request->set_param( 'scope', 'read' ); + $request->set_param( 'state', 'test_state_123' ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 302, $response->get_status() ); + + $headers = $response->get_headers(); + $location = $headers['Location']; + + $this->assertStringContainsString( 'wp-login.php', $location ); + $this->assertStringContainsString( 'action=activitypub_authorize', $location ); + $this->assertStringContainsString( 'client_id=' . $this->client_id, $location ); + $this->assertStringContainsString( 'state=test_state_123', $location ); + } + + /** + * Test that GET authorize returns error for unknown client. + * + * @covers ::authorize + */ + public function test_authorize_invalid_client() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorize' ); + $request->set_param( 'response_type', 'code' ); + $request->set_param( 'client_id', 'nonexistent-client-id' ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status() ); + } + + /** + * Test that GET authorize returns error for mismatched redirect URI. + * + * @covers ::authorize + */ + public function test_authorize_invalid_redirect_uri() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorize' ); + $request->set_param( 'response_type', 'code' ); + $request->set_param( 'client_id', $this->client_id ); + $request->set_param( 'redirect_uri', 'https://evil.example.com/steal' ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test that POST authorize requires login. + * + * @covers ::authorize_submit_permissions_check + */ + public function test_authorize_submit_requires_login() { + // Ensure no user is logged in. + \wp_set_current_user( 0 ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorize' ); + $request->set_param( 'response_type', 'code' ); + $request->set_param( 'client_id', $this->client_id ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + $request->set_param( 'approve', true ); + $request->set_param( '_wpnonce', 'invalid' ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * Test that POST authorize requires valid nonce. + * + * @covers ::authorize_submit_permissions_check + */ + public function test_authorize_submit_requires_nonce() { + \wp_set_current_user( $this->user_id ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorize' ); + $request->set_param( 'response_type', 'code' ); + $request->set_param( 'client_id', $this->client_id ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + $request->set_param( 'approve', true ); + $request->set_param( '_wpnonce', 'bad_nonce' ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 403, $response->get_status() ); + } + + /** + * Test that POST authorize with deny redirects with access_denied. + * + * @covers ::authorize_submit + */ + public function test_authorize_submit_denied() { + \wp_set_current_user( $this->user_id ); + $nonce = \wp_create_nonce( 'activitypub_oauth_authorize' ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorize' ); + $request->set_param( 'response_type', 'code' ); + $request->set_param( 'client_id', $this->client_id ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + $request->set_param( 'approve', false ); + $request->set_param( '_wpnonce', $nonce ); + $request->set_param( 'state', 'deny_state' ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 302, $response->get_status() ); + + $headers = $response->get_headers(); + $location = $headers['Location']; + + $this->assertStringContainsString( 'error=access_denied', $location ); + $this->assertStringContainsString( 'state=deny_state', $location ); + } + + /** + * Test that POST authorize with approval redirects with code and state. + * + * @covers ::authorize_submit + */ + public function test_authorize_submit_success() { + \wp_set_current_user( $this->user_id ); + $nonce = \wp_create_nonce( 'activitypub_oauth_authorize' ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorize' ); + $request->set_param( 'response_type', 'code' ); + $request->set_param( 'client_id', $this->client_id ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + $request->set_param( 'scope', 'read write' ); + $request->set_param( 'approve', true ); + $request->set_param( '_wpnonce', $nonce ); + $request->set_param( 'state', 'success_state' ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 302, $response->get_status() ); + + $headers = $response->get_headers(); + $location = $headers['Location']; + + $this->assertStringContainsString( 'code=', $location ); + $this->assertStringContainsString( 'state=success_state', $location ); + $this->assertStringNotContainsString( 'error', $location ); + } +} diff --git a/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php b/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php new file mode 100644 index 0000000000..a18c88265e --- /dev/null +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php @@ -0,0 +1,152 @@ +get_routes(); + $base = '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth'; + + $this->assertArrayHasKey( $base . '/clients', $routes ); + $this->assertArrayHasKey( $base . '/authorization-server-metadata', $routes ); + } + + /** + * Test successful client registration. + * + * @covers ::register_client + */ + public function test_register_client_success() { + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/clients' ); + $request->set_param( 'client_name', 'My Test App' ); + $request->set_param( 'redirect_uris', array( 'https://myapp.example.com/callback' ) ); + + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 201, $response->get_status() ); + $this->assertArrayHasKey( 'client_id', $data ); + $this->assertEquals( 'My Test App', $data['client_name'] ); + $this->assertEquals( array( 'https://myapp.example.com/callback' ), $data['redirect_uris'] ); + $this->assertEquals( 'none', $data['token_endpoint_auth_method'] ); + } + + /** + * Test client registration without client_name. + * + * @covers ::register_client + */ + public function test_register_client_missing_name() { + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/clients' ); + $request->set_param( 'redirect_uris', array( 'https://myapp.example.com/callback' ) ); + + $response = \rest_get_server()->dispatch( $request ); + + // client_name is required — should fail validation. + $this->assertGreaterThanOrEqual( 400, $response->get_status() ); + } + + /** + * Test client registration without redirect_uris. + * + * @covers ::register_client + */ + public function test_register_client_missing_redirect_uris() { + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/clients' ); + $request->set_param( 'client_name', 'My Test App' ); + + $response = \rest_get_server()->dispatch( $request ); + + // redirect_uris is required — should fail validation. + $this->assertGreaterThanOrEqual( 400, $response->get_status() ); + } + + /** + * Test client registration when disabled via filter. + * + * @covers ::register_client + */ + public function test_register_client_disabled() { + \add_filter( 'activitypub_allow_dynamic_client_registration', '__return_false' ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/clients' ); + $request->set_param( 'client_name', 'Blocked App' ); + $request->set_param( 'redirect_uris', array( 'https://blocked.example.com/callback' ) ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 403, $response->get_status() ); + + \remove_filter( 'activitypub_allow_dynamic_client_registration', '__return_false' ); + } + + /** + * Test getting authorization server metadata. + * + * @covers ::get_metadata + */ + public function test_get_metadata() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorization-server-metadata' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + + $this->assertArrayHasKey( 'issuer', $data ); + $this->assertArrayHasKey( 'authorization_endpoint', $data ); + $this->assertArrayHasKey( 'token_endpoint', $data ); + $this->assertArrayHasKey( 'revocation_endpoint', $data ); + $this->assertArrayHasKey( 'introspection_endpoint', $data ); + $this->assertArrayHasKey( 'registration_endpoint', $data ); + $this->assertArrayHasKey( 'scopes_supported', $data ); + $this->assertArrayHasKey( 'response_types_supported', $data ); + $this->assertArrayHasKey( 'grant_types_supported', $data ); + $this->assertArrayHasKey( 'code_challenge_methods_supported', $data ); + + $this->assertEquals( \home_url(), $data['issuer'] ); + $this->assertContains( 'code', $data['response_types_supported'] ); + $this->assertContains( 'authorization_code', $data['grant_types_supported'] ); + $this->assertContains( 'refresh_token', $data['grant_types_supported'] ); + } +} diff --git a/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php b/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php new file mode 100644 index 0000000000..23ed5f22bb --- /dev/null +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php @@ -0,0 +1,349 @@ +user_id = $this->factory->user->create( + array( + 'role' => 'editor', + ) + ); + + $client_result = Client::register( + array( + 'name' => 'Test Token Client', + 'redirect_uris' => array( $this->redirect_uri ), + ) + ); + $this->client_id = $client_result['client_id']; + } + + /** + * Tear down the test. + */ + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + + if ( $this->client_id ) { + Client::delete( $this->client_id ); + } + + parent::tear_down(); + } + + /** + * Helper: create an authorization code for the test user/client. + * + * @param array $scopes Scopes for the code. + * @param string $code_challenge Optional PKCE code challenge. + * @param string $code_challenge_method Optional PKCE method. + * @return string The authorization code. + */ + protected function create_auth_code( $scopes = null, $code_challenge = '', $code_challenge_method = 'S256' ) { + if ( null === $scopes ) { + $scopes = array( Scope::READ, Scope::WRITE ); + } + + return Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + $scopes, + $code_challenge, + $code_challenge_method + ); + } + + /** + * Test that token routes are registered. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = \rest_get_server()->get_routes(); + $base = '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth'; + + $this->assertArrayHasKey( $base . '/token', $routes ); + $this->assertArrayHasKey( $base . '/revoke', $routes ); + $this->assertArrayHasKey( $base . '/introspect', $routes ); + } + + /** + * Test token request with unknown client. + * + * @covers ::token + */ + public function test_token_invalid_client() { + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', 'nonexistent-client-id' ); + $request->set_param( 'code', 'some_code' ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'invalid_client', $data['error'] ); + } + + /** + * Test token request with unsupported grant type. + * + * @covers ::token + */ + public function test_token_unsupported_grant_type() { + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/token' ); + $request->set_param( 'grant_type', 'password' ); + $request->set_param( 'client_id', $this->client_id ); + + $response = \rest_get_server()->dispatch( $request ); + + // The enum validation on grant_type will reject 'password' before reaching the controller. + $this->assertGreaterThanOrEqual( 400, $response->get_status() ); + } + + /** + * Test authorization_code grant without code parameter. + * + * @covers ::token + */ + public function test_token_authorization_code_missing_code() { + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', $this->client_id ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'invalid_request', $data['error'] ); + } + + /** + * Test successful authorization code exchange for token. + * + * @covers ::token + */ + public function test_token_authorization_code_success() { + $code = $this->create_auth_code(); + $this->assertIsString( $code ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', $this->client_id ); + $request->set_param( 'code', $code ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'access_token', $data ); + $this->assertArrayHasKey( 'token_type', $data ); + $this->assertArrayHasKey( 'expires_in', $data ); + $this->assertArrayHasKey( 'refresh_token', $data ); + $this->assertEquals( 'Bearer', $data['token_type'] ); + } + + /** + * Test refresh token grant success. + * + * @covers ::token + */ + public function test_token_refresh_success() { + // First obtain tokens via authorization code. + $code = $this->create_auth_code(); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', $this->client_id ); + $request->set_param( 'code', $code ); + $request->set_param( 'redirect_uri', $this->redirect_uri ); + + $response = \rest_get_server()->dispatch( $request ); + $token_data = $response->get_data(); + $refresh_token = $token_data['refresh_token']; + + // Now use the refresh token. + $refresh_request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/token' ); + $refresh_request->set_param( 'grant_type', 'refresh_token' ); + $refresh_request->set_param( 'client_id', $this->client_id ); + $refresh_request->set_param( 'refresh_token', $refresh_token ); + + $refresh_response = \rest_get_server()->dispatch( $refresh_request ); + $refresh_data = $refresh_response->get_data(); + + $this->assertEquals( 200, $refresh_response->get_status() ); + $this->assertArrayHasKey( 'access_token', $refresh_data ); + $this->assertArrayHasKey( 'refresh_token', $refresh_data ); + $this->assertNotEquals( $token_data['access_token'], $refresh_data['access_token'] ); + } + + /** + * Test refresh token grant without refresh_token parameter. + * + * @covers ::token + */ + public function test_token_refresh_missing_token() { + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/token' ); + $request->set_param( 'grant_type', 'refresh_token' ); + $request->set_param( 'client_id', $this->client_id ); + + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'invalid_request', $data['error'] ); + } + + /** + * Test revoke endpoint requires authentication. + * + * @covers ::revoke_permissions_check + */ + public function test_revoke_requires_auth() { + \wp_set_current_user( 0 ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/revoke' ); + $request->set_param( 'token', 'some_token' ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * Test successful token revocation. + * + * @covers ::revoke + */ + public function test_revoke_success() { + \wp_set_current_user( $this->user_id ); + + // Create a token to revoke. + $token_data = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/revoke' ); + $request->set_param( 'token', $token_data['access_token'] ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + // Token should no longer be valid. + $validation = Token::validate( $token_data['access_token'] ); + $this->assertInstanceOf( \WP_Error::class, $validation ); + } + + /** + * Test introspect endpoint requires authentication. + * + * @covers ::introspect_permissions_check + */ + public function test_introspect_requires_auth() { + \wp_set_current_user( 0 ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/introspect' ); + $request->set_param( 'token', 'some_token' ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * Test introspecting an active token. + * + * @covers ::introspect + */ + public function test_introspect_active_token() { + \wp_set_current_user( $this->user_id ); + + $token_data = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/introspect' ); + $request->set_param( 'token', $token_data['access_token'] ); + + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $data['active'] ); + $this->assertEquals( $this->client_id, $data['client_id'] ); + $this->assertEquals( 'Bearer', $data['token_type'] ); + } + + /** + * Test introspecting a revoked token. + * + * @covers ::introspect + */ + public function test_introspect_revoked_token() { + \wp_set_current_user( $this->user_id ); + + $token_data = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + Token::revoke( $token_data['access_token'] ); + + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/introspect' ); + $request->set_param( 'token', $token_data['access_token'] ); + + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertFalse( $data['active'] ); + } +} From d68dc3a7a731cb63c993c468bce8be795e980a50 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 20 Feb 2026 12:03:48 +0100 Subject: [PATCH 088/105] Split OAuth_Controller into three separate controllers Replace the monolithic OAuth_Controller with Authorization_Controller, Token_Controller, and Clients_Controller under Rest\OAuth namespace for better separation of concerns. --- activitypub.php | 6 +- includes/rest/class-oauth-controller.php | 784 ------------------ .../oauth/class-authorization-controller.php | 343 ++++++++ .../rest/oauth/class-clients-controller.php | 163 ++++ .../rest/oauth/class-token-controller.php | 375 +++++++++ 5 files changed, 884 insertions(+), 787 deletions(-) delete mode 100644 includes/rest/class-oauth-controller.php create mode 100644 includes/rest/oauth/class-authorization-controller.php create mode 100644 includes/rest/oauth/class-clients-controller.php create mode 100644 includes/rest/oauth/class-token-controller.php diff --git a/activitypub.php b/activitypub.php index b290195448..a3a31b87a3 100644 --- a/activitypub.php +++ b/activitypub.php @@ -61,6 +61,9 @@ function rest_init() { ( new Rest\Inbox_Controller() )->register_routes(); ( new Rest\Interaction_Controller() )->register_routes(); ( new Rest\Moderators_Controller() )->register_routes(); + ( new Rest\OAuth\Authorization_Controller() )->register_routes(); + ( new Rest\OAuth\Clients_Controller() )->register_routes(); + ( new Rest\OAuth\Token_Controller() )->register_routes(); ( new Rest\Outbox_Controller() )->register_routes(); ( new Rest\Post_Controller() )->register_routes(); ( new Rest\Replies_Controller() )->register_routes(); @@ -70,9 +73,6 @@ function rest_init() { if ( is_blog_public() ) { ( new Rest\Nodeinfo_Controller() )->register_routes(); } - - // Load OAuth REST endpoints. - ( new Rest\OAuth_Controller() )->register_routes(); ( new Rest\Proxy_Controller() )->register_routes(); } \add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' ); diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php deleted file mode 100644 index b963721f5b..0000000000 --- a/includes/rest/class-oauth-controller.php +++ /dev/null @@ -1,784 +0,0 @@ -namespace, - '/' . $this->rest_base . '/authorize', - array( - array( - 'methods' => \WP_REST_Server::READABLE, - 'callback' => array( $this, 'authorize' ), - 'permission_callback' => '__return_true', - 'args' => $this->get_authorize_args(), - ), - array( - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'authorize_submit' ), - 'permission_callback' => array( $this, 'authorize_submit_permissions_check' ), - 'args' => array_merge( - $this->get_authorize_args(), - array( - 'approve' => array( - 'description' => 'Whether the user approved the authorization.', - 'type' => 'boolean', - 'required' => true, - ), - '_wpnonce' => array( - 'description' => 'WordPress nonce for CSRF protection.', - 'type' => 'string', - 'required' => true, - ), - ) - ), - ), - ) - ); - - // Token endpoint. - \register_rest_route( - $this->namespace, - '/' . $this->rest_base . '/token', - array( - array( - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'token' ), - 'permission_callback' => '__return_true', - 'args' => $this->get_token_args(), - ), - ) - ); - - // Revocation endpoint (RFC 7009 — requires authentication). - \register_rest_route( - $this->namespace, - '/' . $this->rest_base . '/revoke', - array( - array( - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'revoke' ), - 'permission_callback' => array( $this, 'revoke_permissions_check' ), - 'args' => array( - 'token' => array( - 'description' => 'The token to revoke.', - 'type' => 'string', - 'required' => true, - ), - 'token_type_hint' => array( - 'description' => 'Hint about the token type.', - 'type' => 'string', - 'enum' => array( 'access_token', 'refresh_token' ), - ), - ), - ), - ) - ); - - // Token introspection endpoint (RFC 7662). - \register_rest_route( - $this->namespace, - '/' . $this->rest_base . '/introspect', - array( - array( - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'introspect' ), - 'permission_callback' => array( $this, 'introspect_permissions_check' ), - 'args' => array( - 'token' => array( - 'description' => 'The token to introspect.', - 'type' => 'string', - 'required' => true, - ), - 'token_type_hint' => array( - 'description' => 'Hint about the token type.', - 'type' => 'string', - 'enum' => array( 'access_token', 'refresh_token' ), - ), - ), - ), - ) - ); - - // Dynamic client registration (RFC 7591). - \register_rest_route( - $this->namespace, - '/' . $this->rest_base . '/clients', - array( - array( - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'register_client' ), - 'permission_callback' => '__return_true', - 'args' => array( - 'client_name' => array( - 'description' => 'Human-readable name of the client.', - 'type' => 'string', - 'required' => true, - ), - 'redirect_uris' => array( - 'description' => 'Array of redirect URIs.', - 'type' => 'array', - 'items' => array( - 'type' => 'string', - 'format' => 'uri', - ), - 'required' => true, - ), - 'client_uri' => array( - 'description' => 'URL of the client homepage.', - 'type' => 'string', - 'format' => 'uri', - ), - 'scope' => array( - 'description' => 'Space-separated list of requested scopes.', - 'type' => 'string', - ), - ), - ), - ) - ); - - // Authorization Server Metadata (RFC 8414). - \register_rest_route( - $this->namespace, - '/' . $this->rest_base . '/authorization-server-metadata', - array( - array( - 'methods' => \WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_metadata' ), - 'permission_callback' => '__return_true', - ), - ) - ); - } - - /** - * Handle authorization request (GET /oauth/authorize). - * - * Validates request parameters and redirects to wp-admin consent page. - * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response|\WP_Error - */ - public function authorize( \WP_REST_Request $request ) { - $client_id = $request->get_param( 'client_id' ); - $redirect_uri = $request->get_param( 'redirect_uri' ); - $response_type = $request->get_param( 'response_type' ); - $scope = $request->get_param( 'scope' ); - $state = $request->get_param( 'state' ); - - // Validate client. - $client = Client::get( $client_id ); - if ( \is_wp_error( $client ) ) { - return $client; - } - - // Validate redirect URI. - if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { - return new \WP_Error( - 'activitypub_invalid_redirect_uri', - \__( 'Invalid redirect URI for this client.', 'activitypub' ), - array( 'status' => 400 ) - ); - } - - // Only support 'code' response type. - if ( 'code' !== $response_type ) { - return $this->redirect_with_error( - $redirect_uri, - 'unsupported_response_type', - 'Only authorization code flow is supported.', - $state - ); - } - - // Check for PKCE (recommended but optional for compatibility). - $code_challenge = $request->get_param( 'code_challenge' ); - - /* - * Redirect to wp-login.php with action=activitypub_authorize. - * This uses WordPress's login_form_{action} hook for proper cookie auth. - */ - $login_url = \wp_login_url(); - $login_url = \add_query_arg( - array( - 'action' => 'activitypub_authorize', - 'client_id' => $client_id, - 'redirect_uri' => $redirect_uri, - 'response_type' => $response_type, - 'scope' => $scope, - 'state' => $state, - 'code_challenge' => $code_challenge, - 'code_challenge_method' => $request->get_param( 'code_challenge_method' ) ?: 'S256', - ), - $login_url - ); - - return new \WP_REST_Response( - null, - 302, - array( 'Location' => $login_url ) - ); - } - - /** - * Handle authorization approval (POST /oauth/authorize). - * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response|\WP_Error - */ - public function authorize_submit( \WP_REST_Request $request ) { - $client_id = $request->get_param( 'client_id' ); - $redirect_uri = $request->get_param( 'redirect_uri' ); - $scope = $request->get_param( 'scope' ); - $state = $request->get_param( 'state' ); - $code_challenge = $request->get_param( 'code_challenge' ); - $code_challenge_method = $request->get_param( 'code_challenge_method' ) ?: 'S256'; - $approve = $request->get_param( 'approve' ); - - // Re-validate client and redirect URI (form fields could be tampered with). - $client = Client::get( $client_id ); - if ( \is_wp_error( $client ) ) { - return $client; - } - - if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { - return new \WP_Error( - 'activitypub_invalid_redirect_uri', - \__( 'Invalid redirect URI for this client.', 'activitypub' ), - array( 'status' => 400 ) - ); - } - - // User denied authorization. - if ( ! $approve ) { - return $this->redirect_with_error( - $redirect_uri, - 'access_denied', - 'The user denied the authorization request.', - $state - ); - } - - // Create authorization code. - $scopes = Scope::validate( Scope::parse( $scope ) ); - $code = Authorization_Code::create( - \get_current_user_id(), - $client_id, - $redirect_uri, - $scopes, - $code_challenge, - $code_challenge_method - ); - - if ( \is_wp_error( $code ) ) { - return $this->redirect_with_error( - $redirect_uri, - 'server_error', - $code->get_error_message(), - $state - ); - } - - // Redirect back to client with code. - $redirect_url = \add_query_arg( - array( - 'code' => $code, - 'state' => $state, - ), - $redirect_uri - ); - - return new \WP_REST_Response( - null, - 302, - array( 'Location' => $redirect_url ) - ); - } - - /** - * Permission check for authorization submission. - * - * @param \WP_REST_Request $request The request object. - * @return bool|\WP_Error True if allowed, error otherwise. - */ - public function authorize_submit_permissions_check( \WP_REST_Request $request ) { - if ( ! \is_user_logged_in() ) { - return new \WP_Error( - 'activitypub_not_logged_in', - \__( 'You must be logged in to authorize applications.', 'activitypub' ), - array( 'status' => 401 ) - ); - } - - // Verify nonce. - $nonce = $request->get_param( '_wpnonce' ); - if ( ! \wp_verify_nonce( $nonce, 'activitypub_oauth_authorize' ) ) { - return new \WP_Error( - 'activitypub_invalid_nonce', - \__( 'Invalid security token. Please try again.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - - return true; - } - - /** - * Permission check for token revocation. - * - * Per RFC 7009, the revocation endpoint must be protected. - * Requires either a logged-in user or a valid Bearer token. - * - * @return bool|\WP_Error True if allowed, error otherwise. - */ - public function revoke_permissions_check() { - if ( \is_user_logged_in() ) { - return true; - } - - $token = OAuth_Server::get_bearer_token(); - - if ( $token ) { - $validated = Token::validate( $token ); - - if ( ! \is_wp_error( $validated ) ) { - \wp_set_current_user( $validated->get_user_id() ); - return true; - } - } - - return new \WP_Error( - 'activitypub_unauthorized', - \__( 'Authentication required.', 'activitypub' ), - array( 'status' => 401 ) - ); - } - - /** - * Permission check for token introspection. - * - * Per RFC 7662, the introspection endpoint must be protected. - * - * @return bool|\WP_Error True if allowed, error otherwise. - */ - public function introspect_permissions_check() { - if ( \is_user_logged_in() ) { - return true; - } - - // Support Bearer token auth for public OAuth clients. - $token = OAuth_Server::get_bearer_token(); - - if ( $token ) { - $validated = Token::validate( $token ); - - if ( ! \is_wp_error( $validated ) ) { - \wp_set_current_user( $validated->get_user_id() ); - return true; - } - } - - return new \WP_Error( - 'activitypub_unauthorized', - \__( 'Authentication required.', 'activitypub' ), - array( 'status' => 401 ) - ); - } - - /** - * Handle token request (POST /oauth/token). - * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response|\WP_Error - */ - public function token( \WP_REST_Request $request ) { - // Rate-limit token requests to prevent brute-force attacks (max 20 per minute per IP). - $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders - $transient_key = 'ap_oauth_tok_' . \md5( $ip ); - $count = (int) \get_transient( $transient_key ); - - if ( $count >= 20 ) { - return $this->token_error( 'invalid_request', 'Too many token requests. Please try again later.' ); - } - - \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS ); - - $grant_type = $request->get_param( 'grant_type' ); - $client_id = $request->get_param( 'client_id' ); - - // Validate client. - $client = Client::get( $client_id ); - if ( \is_wp_error( $client ) ) { - return $this->token_error( 'invalid_client', 'Unknown client.' ); - } - - // Validate client credentials if confidential. - if ( ! $client->is_public() ) { - $client_secret = $request->get_param( 'client_secret' ); - if ( ! Client::validate( $client_id, $client_secret ) ) { - return $this->token_error( 'invalid_client', 'Invalid client credentials.' ); - } - } - - switch ( $grant_type ) { - case 'authorization_code': - return $this->handle_authorization_code_grant( $request, $client_id ); - - case 'refresh_token': - return $this->handle_refresh_token_grant( $request, $client_id ); - - default: - return $this->token_error( 'unsupported_grant_type', 'Grant type not supported.' ); - } - } - - /** - * Handle authorization code grant. - * - * @param \WP_REST_Request $request The request object. - * @param string $client_id The client ID. - * @return \WP_REST_Response|\WP_Error - */ - private function handle_authorization_code_grant( \WP_REST_Request $request, $client_id ) { - $code = $request->get_param( 'code' ); - $redirect_uri = $request->get_param( 'redirect_uri' ); - $code_verifier = $request->get_param( 'code_verifier' ); - - if ( empty( $code ) ) { - return $this->token_error( 'invalid_request', 'Authorization code is required.' ); - } - - $result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier ); - - if ( \is_wp_error( $result ) ) { - return $this->token_error( 'invalid_grant', $result->get_error_message() ); - } - - return $this->token_response( $result ); - } - - /** - * Handle refresh token grant. - * - * @param \WP_REST_Request $request The request object. - * @param string $client_id The client ID. - * @return \WP_REST_Response|\WP_Error - */ - private function handle_refresh_token_grant( \WP_REST_Request $request, $client_id ) { - $refresh_token = $request->get_param( 'refresh_token' ); - - if ( empty( $refresh_token ) ) { - return $this->token_error( 'invalid_request', 'Refresh token is required.' ); - } - - $result = Token::refresh( $refresh_token, $client_id ); - - if ( \is_wp_error( $result ) ) { - return $this->token_error( 'invalid_grant', $result->get_error_message() ); - } - - return $this->token_response( $result ); - } - - /** - * Handle token revocation (POST /oauth/revoke). - * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response - */ - public function revoke( \WP_REST_Request $request ) { - $token = $request->get_param( 'token' ); - - // Per RFC 7009, always return 200 even if token doesn't exist. - Token::revoke( $token ); - - return new \WP_REST_Response( null, 200 ); - } - - /** - * Handle token introspection (POST /oauth/introspect). - * - * Implements RFC 7662 Token Introspection. - * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response - */ - public function introspect( \WP_REST_Request $request ) { - $token = $request->get_param( 'token' ); - - // Introspect the token. - $response = Token::introspect( $token ); - - // Scope introspection to same client: non-admin users can only - // introspect tokens belonging to the same client as their own. - if ( $response['active'] && ! \current_user_can( 'manage_options' ) ) { - $current_token = OAuth_Server::get_current_token(); - if ( $current_token && $current_token->get_client_id() !== $response['client_id'] ) { - $response = array( 'active' => false ); - } - } - - return new \WP_REST_Response( $response, 200 ); - } - - /** - * Handle dynamic client registration (POST /oauth/clients). - * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response|\WP_Error - */ - public function register_client( \WP_REST_Request $request ) { - /** - * Filters whether RFC 7591 dynamic client registration is allowed. - * - * Enabled by default so C2S clients can register on the fly. - * Return false to restrict registration to pre-configured clients only. - * - * @param bool $allowed Whether dynamic registration is allowed. Default true. - */ - if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', true ) ) { - return new \WP_Error( - 'activitypub_registration_disabled', - \__( 'Dynamic client registration is not allowed.', 'activitypub' ), - array( 'status' => 403 ) - ); - } - - $client_name = $request->get_param( 'client_name' ); - $redirect_uris = $request->get_param( 'redirect_uris' ); - $client_uri = $request->get_param( 'client_uri' ); - $scope = $request->get_param( 'scope' ); - - $result = Client::register( - array( - 'name' => $client_name, - 'redirect_uris' => $redirect_uris, - 'description' => $client_uri ?? '', - 'is_public' => true, // Dynamic clients are always public. - 'scopes' => $scope ? Scope::parse( $scope ) : Scope::ALL, - ) - ); - - if ( \is_wp_error( $result ) ) { - return $result; - } - - // RFC 7591 response format. - $response = array( - 'client_id' => $result['client_id'], - 'client_name' => $client_name, - 'redirect_uris' => $redirect_uris, - 'token_endpoint_auth_method' => 'none', - ); - - if ( isset( $result['client_secret'] ) ) { - $response['client_secret'] = $result['client_secret']; - } - - return new \WP_REST_Response( $response, 201 ); - } - - /** - * Get OAuth server metadata. - * - * @return \WP_REST_Response - */ - public function get_metadata() { - return new \WP_REST_Response( - OAuth_Server::get_metadata(), - 200, - array( 'Content-Type' => 'application/json' ) - ); - } - - /** - * Get arguments for authorize endpoint. - * - * @return array Validation schema. - */ - private function get_authorize_args() { - return array( - 'response_type' => array( - 'description' => 'OAuth response type (must be "code").', - 'type' => 'string', - 'required' => true, - 'enum' => array( 'code' ), - ), - 'client_id' => array( - 'description' => 'The OAuth client identifier.', - 'type' => 'string', - 'required' => true, - ), - 'redirect_uri' => array( - 'description' => 'The URI to redirect to after authorization.', - 'type' => 'string', - 'format' => 'uri', - 'required' => true, - ), - 'scope' => array( - 'description' => 'Space-separated list of requested scopes.', - 'type' => 'string', - ), - 'state' => array( - 'description' => 'Opaque value for CSRF protection.', - 'type' => 'string', - ), - 'code_challenge' => array( - 'description' => 'PKCE code challenge (recommended).', - 'type' => 'string', - ), - 'code_challenge_method' => array( - 'description' => 'PKCE code challenge method.', - 'type' => 'string', - 'enum' => array( 'S256', 'plain' ), - 'default' => 'S256', - ), - ); - } - - /** - * Get arguments for token endpoint. - * - * @return array Validation schema. - */ - private function get_token_args() { - return array( - 'grant_type' => array( - 'description' => 'The grant type.', - 'type' => 'string', - 'required' => true, - 'enum' => array( 'authorization_code', 'refresh_token' ), - ), - 'client_id' => array( - 'description' => 'The OAuth client identifier.', - 'type' => 'string', - 'required' => true, - ), - 'client_secret' => array( - 'description' => 'The OAuth client secret (for confidential clients).', - 'type' => 'string', - ), - 'code' => array( - 'description' => 'The authorization code (for authorization_code grant).', - 'type' => 'string', - ), - 'redirect_uri' => array( - 'description' => 'The redirect URI (must match original for authorization_code grant).', - 'type' => 'string', - 'format' => 'uri', - ), - 'code_verifier' => array( - 'description' => 'PKCE code verifier.', - 'type' => 'string', - ), - 'refresh_token' => array( - 'description' => 'The refresh token (for refresh_token grant).', - 'type' => 'string', - ), - ); - } - - /** - * Create a token error response. - * - * @param string $error Error code. - * @param string $error_description Error description. - * @return \WP_REST_Response - */ - private function token_error( $error, $error_description ) { - return new \WP_REST_Response( - array( - 'error' => $error, - 'error_description' => $error_description, - ), - 400, - array( 'Content-Type' => 'application/json' ) - ); - } - - /** - * Create a token success response. - * - * @param array $token_data Token data. - * @return \WP_REST_Response - */ - private function token_response( $token_data ) { - return new \WP_REST_Response( - $token_data, - 200, - array( - 'Content-Type' => 'application/json', - 'Cache-Control' => 'no-store', - 'Pragma' => 'no-cache', - ) - ); - } - - /** - * Redirect with an OAuth error. - * - * @param string $redirect_uri The redirect URI. - * @param string $error Error code. - * @param string $description Error description. - * @param string $state The state parameter. - * @return \WP_REST_Response - */ - private function redirect_with_error( $redirect_uri, $error, $description, $state = null ) { - $params = array( - 'error' => $error, - 'error_description' => $description, - ); - - if ( $state ) { - $params['state'] = $state; - } - - $redirect_url = \add_query_arg( $params, $redirect_uri ); - - return new \WP_REST_Response( - null, - 302, - array( 'Location' => $redirect_url ) - ); - } -} diff --git a/includes/rest/oauth/class-authorization-controller.php b/includes/rest/oauth/class-authorization-controller.php new file mode 100644 index 0000000000..ecd939571d --- /dev/null +++ b/includes/rest/oauth/class-authorization-controller.php @@ -0,0 +1,343 @@ +namespace, + '/' . $this->rest_base . '/authorize', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'authorize' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'response_type' => array( + 'description' => 'OAuth response type (must be "code").', + 'type' => 'string', + 'required' => true, + 'enum' => array( 'code' ), + ), + 'client_id' => array( + 'description' => 'The OAuth client identifier.', + 'type' => 'string', + 'required' => true, + ), + 'redirect_uri' => array( + 'description' => 'The URI to redirect to after authorization.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'scope' => array( + 'description' => 'Space-separated list of requested scopes.', + 'type' => 'string', + ), + 'state' => array( + 'description' => 'Opaque value for CSRF protection.', + 'type' => 'string', + ), + 'code_challenge' => array( + 'description' => 'PKCE code challenge (recommended).', + 'type' => 'string', + ), + 'code_challenge_method' => array( + 'description' => 'PKCE code challenge method.', + 'type' => 'string', + 'enum' => array( 'S256', 'plain' ), + 'default' => 'S256', + ), + ), + ), + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'authorize_submit' ), + 'permission_callback' => array( $this, 'authorize_submit_permissions_check' ), + 'args' => array( + 'response_type' => array( + 'description' => 'OAuth response type (must be "code").', + 'type' => 'string', + 'required' => true, + 'enum' => array( 'code' ), + ), + 'client_id' => array( + 'description' => 'The OAuth client identifier.', + 'type' => 'string', + 'required' => true, + ), + 'redirect_uri' => array( + 'description' => 'The URI to redirect to after authorization.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'scope' => array( + 'description' => 'Space-separated list of requested scopes.', + 'type' => 'string', + ), + 'state' => array( + 'description' => 'Opaque value for CSRF protection.', + 'type' => 'string', + ), + 'code_challenge' => array( + 'description' => 'PKCE code challenge (recommended).', + 'type' => 'string', + ), + 'code_challenge_method' => array( + 'description' => 'PKCE code challenge method.', + 'type' => 'string', + 'enum' => array( 'S256', 'plain' ), + 'default' => 'S256', + ), + 'approve' => array( + 'description' => 'Whether the user approved the authorization.', + 'type' => 'boolean', + 'required' => true, + ), + '_wpnonce' => array( + 'description' => 'WordPress nonce for CSRF protection.', + 'type' => 'string', + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Handle authorization request (GET /oauth/authorize). + * + * Validates request parameters and redirects to wp-admin consent page. + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error + */ + public function authorize( \WP_REST_Request $request ) { + $client_id = $request->get_param( 'client_id' ); + $redirect_uri = $request->get_param( 'redirect_uri' ); + $response_type = $request->get_param( 'response_type' ); + $scope = $request->get_param( 'scope' ); + $state = $request->get_param( 'state' ); + + // Validate client. + $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { + return $client; + } + + // Validate redirect URI. + if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { + return new \WP_Error( + 'activitypub_invalid_redirect_uri', + \__( 'Invalid redirect URI for this client.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Only support 'code' response type. + if ( 'code' !== $response_type ) { + return $this->redirect_with_error( + $redirect_uri, + 'unsupported_response_type', + 'Only authorization code flow is supported.', + $state + ); + } + + // Check for PKCE (recommended but optional for compatibility). + $code_challenge = $request->get_param( 'code_challenge' ); + + /* + * Redirect to wp-login.php with action=activitypub_authorize. + * This uses WordPress's login_form_{action} hook for proper cookie auth. + */ + $login_url = \wp_login_url(); + $login_url = \add_query_arg( + array( + 'action' => 'activitypub_authorize', + 'client_id' => $client_id, + 'redirect_uri' => $redirect_uri, + 'response_type' => $response_type, + 'scope' => $scope, + 'state' => $state, + 'code_challenge' => $code_challenge, + 'code_challenge_method' => $request->get_param( 'code_challenge_method' ) ?: 'S256', + ), + $login_url + ); + + return new \WP_REST_Response( + null, + 302, + array( 'Location' => $login_url ) + ); + } + + /** + * Handle authorization approval (POST /oauth/authorize). + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error + */ + public function authorize_submit( \WP_REST_Request $request ) { + $client_id = $request->get_param( 'client_id' ); + $redirect_uri = $request->get_param( 'redirect_uri' ); + $scope = $request->get_param( 'scope' ); + $state = $request->get_param( 'state' ); + $code_challenge = $request->get_param( 'code_challenge' ); + $code_challenge_method = $request->get_param( 'code_challenge_method' ) ?: 'S256'; + $approve = $request->get_param( 'approve' ); + + // Re-validate client and redirect URI (form fields could be tampered with). + $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { + return $client; + } + + if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { + return new \WP_Error( + 'activitypub_invalid_redirect_uri', + \__( 'Invalid redirect URI for this client.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // User denied authorization. + if ( ! $approve ) { + return $this->redirect_with_error( + $redirect_uri, + 'access_denied', + 'The user denied the authorization request.', + $state + ); + } + + // Create authorization code. + $scopes = Scope::validate( Scope::parse( $scope ) ); + $code = Authorization_Code::create( + \get_current_user_id(), + $client_id, + $redirect_uri, + $scopes, + $code_challenge, + $code_challenge_method + ); + + if ( \is_wp_error( $code ) ) { + return $this->redirect_with_error( + $redirect_uri, + 'server_error', + $code->get_error_message(), + $state + ); + } + + // Redirect back to client with code. + $redirect_url = \add_query_arg( + array( + 'code' => $code, + 'state' => $state, + ), + $redirect_uri + ); + + return new \WP_REST_Response( + null, + 302, + array( 'Location' => $redirect_url ) + ); + } + + /** + * Permission check for authorization submission. + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if allowed, error otherwise. + */ + public function authorize_submit_permissions_check( \WP_REST_Request $request ) { + if ( ! \is_user_logged_in() ) { + return new \WP_Error( + 'activitypub_not_logged_in', + \__( 'You must be logged in to authorize applications.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Verify nonce. + $nonce = $request->get_param( '_wpnonce' ); + if ( ! \wp_verify_nonce( $nonce, 'activitypub_oauth_authorize' ) ) { + return new \WP_Error( + 'activitypub_invalid_nonce', + \__( 'Invalid security token. Please try again.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Redirect with an OAuth error. + * + * @param string $redirect_uri The redirect URI. + * @param string $error Error code. + * @param string $description Error description. + * @param string $state The state parameter. + * @return \WP_REST_Response + */ + private function redirect_with_error( $redirect_uri, $error, $description, $state = null ) { + $params = array( + 'error' => $error, + 'error_description' => $description, + ); + + if ( $state ) { + $params['state'] = $state; + } + + $redirect_url = \add_query_arg( $params, $redirect_uri ); + + return new \WP_REST_Response( + null, + 302, + array( 'Location' => $redirect_url ) + ); + } +} diff --git a/includes/rest/oauth/class-clients-controller.php b/includes/rest/oauth/class-clients-controller.php new file mode 100644 index 0000000000..93daa92d9b --- /dev/null +++ b/includes/rest/oauth/class-clients-controller.php @@ -0,0 +1,163 @@ +namespace, + '/' . $this->rest_base . '/clients', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'register_client' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'client_name' => array( + 'description' => 'Human-readable name of the client.', + 'type' => 'string', + 'required' => true, + ), + 'redirect_uris' => array( + 'description' => 'Array of redirect URIs.', + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'required' => true, + ), + 'client_uri' => array( + 'description' => 'URL of the client homepage.', + 'type' => 'string', + 'format' => 'uri', + ), + 'scope' => array( + 'description' => 'Space-separated list of requested scopes.', + 'type' => 'string', + ), + ), + ), + ) + ); + + // Authorization Server Metadata (RFC 8414). + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/authorization-server-metadata', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_metadata' ), + 'permission_callback' => '__return_true', + ), + ) + ); + } + + /** + * Handle dynamic client registration (POST /oauth/clients). + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error + */ + public function register_client( \WP_REST_Request $request ) { + /** + * Filters whether RFC 7591 dynamic client registration is allowed. + * + * Enabled by default so C2S clients can register on the fly. + * Return false to restrict registration to pre-configured clients only. + * + * @param bool $allowed Whether dynamic registration is allowed. Default true. + */ + if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', true ) ) { + return new \WP_Error( + 'activitypub_registration_disabled', + \__( 'Dynamic client registration is not allowed.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $client_name = $request->get_param( 'client_name' ); + $redirect_uris = $request->get_param( 'redirect_uris' ); + $client_uri = $request->get_param( 'client_uri' ); + $scope = $request->get_param( 'scope' ); + + $result = Client::register( + array( + 'name' => $client_name, + 'redirect_uris' => $redirect_uris, + 'description' => $client_uri ?? '', + 'is_public' => true, // Dynamic clients are always public. + 'scopes' => $scope ? Scope::parse( $scope ) : Scope::ALL, + ) + ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + // RFC 7591 response format. + $response = array( + 'client_id' => $result['client_id'], + 'client_name' => $client_name, + 'redirect_uris' => $redirect_uris, + 'token_endpoint_auth_method' => 'none', + ); + + if ( isset( $result['client_secret'] ) ) { + $response['client_secret'] = $result['client_secret']; + } + + return new \WP_REST_Response( $response, 201 ); + } + + /** + * Get OAuth server metadata. + * + * @return \WP_REST_Response + */ + public function get_metadata() { + return new \WP_REST_Response( + OAuth_Server::get_metadata(), + 200, + array( 'Content-Type' => 'application/json' ) + ); + } +} diff --git a/includes/rest/oauth/class-token-controller.php b/includes/rest/oauth/class-token-controller.php new file mode 100644 index 0000000000..0c36d9cb7b --- /dev/null +++ b/includes/rest/oauth/class-token-controller.php @@ -0,0 +1,375 @@ +namespace, + '/' . $this->rest_base . '/token', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'token' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'grant_type' => array( + 'description' => 'The grant type.', + 'type' => 'string', + 'required' => true, + 'enum' => array( 'authorization_code', 'refresh_token' ), + ), + 'client_id' => array( + 'description' => 'The OAuth client identifier.', + 'type' => 'string', + 'required' => true, + ), + 'client_secret' => array( + 'description' => 'The OAuth client secret (for confidential clients).', + 'type' => 'string', + ), + 'code' => array( + 'description' => 'The authorization code (for authorization_code grant).', + 'type' => 'string', + ), + 'redirect_uri' => array( + 'description' => 'The redirect URI (must match original for authorization_code grant).', + 'type' => 'string', + 'format' => 'uri', + ), + 'code_verifier' => array( + 'description' => 'PKCE code verifier.', + 'type' => 'string', + ), + 'refresh_token' => array( + 'description' => 'The refresh token (for refresh_token grant).', + 'type' => 'string', + ), + ), + ), + ) + ); + + // Revocation endpoint (RFC 7009 — requires authentication). + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/revoke', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'revoke' ), + 'permission_callback' => array( $this, 'revoke_permissions_check' ), + 'args' => array( + 'token' => array( + 'description' => 'The token to revoke.', + 'type' => 'string', + 'required' => true, + ), + 'token_type_hint' => array( + 'description' => 'Hint about the token type.', + 'type' => 'string', + 'enum' => array( 'access_token', 'refresh_token' ), + ), + ), + ), + ) + ); + + // Token introspection endpoint (RFC 7662). + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/introspect', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'introspect' ), + 'permission_callback' => array( $this, 'introspect_permissions_check' ), + 'args' => array( + 'token' => array( + 'description' => 'The token to introspect.', + 'type' => 'string', + 'required' => true, + ), + 'token_type_hint' => array( + 'description' => 'Hint about the token type.', + 'type' => 'string', + 'enum' => array( 'access_token', 'refresh_token' ), + ), + ), + ), + ) + ); + } + + /** + * Handle token request (POST /oauth/token). + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error + */ + public function token( \WP_REST_Request $request ) { + // Rate-limit token requests to prevent brute-force attacks (max 20 per minute per IP). + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders + $transient_key = 'ap_oauth_tok_' . \md5( $ip ); + $count = (int) \get_transient( $transient_key ); + + if ( $count >= 20 ) { + return $this->token_error( 'invalid_request', 'Too many token requests. Please try again later.' ); + } + + \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS ); + + $grant_type = $request->get_param( 'grant_type' ); + $client_id = $request->get_param( 'client_id' ); + + // Validate client. + $client = Client::get( $client_id ); + if ( \is_wp_error( $client ) ) { + return $this->token_error( 'invalid_client', 'Unknown client.' ); + } + + // Validate client credentials if confidential. + if ( ! $client->is_public() ) { + $client_secret = $request->get_param( 'client_secret' ); + if ( ! Client::validate( $client_id, $client_secret ) ) { + return $this->token_error( 'invalid_client', 'Invalid client credentials.' ); + } + } + + switch ( $grant_type ) { + case 'authorization_code': + return $this->handle_authorization_code_grant( $request, $client_id ); + + case 'refresh_token': + return $this->handle_refresh_token_grant( $request, $client_id ); + + default: + return $this->token_error( 'unsupported_grant_type', 'Grant type not supported.' ); + } + } + + /** + * Handle authorization code grant. + * + * @param \WP_REST_Request $request The request object. + * @param string $client_id The client ID. + * @return \WP_REST_Response|\WP_Error + */ + private function handle_authorization_code_grant( \WP_REST_Request $request, $client_id ) { + $code = $request->get_param( 'code' ); + $redirect_uri = $request->get_param( 'redirect_uri' ); + $code_verifier = $request->get_param( 'code_verifier' ); + + if ( empty( $code ) ) { + return $this->token_error( 'invalid_request', 'Authorization code is required.' ); + } + + $result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier ); + + if ( \is_wp_error( $result ) ) { + return $this->token_error( 'invalid_grant', $result->get_error_message() ); + } + + return $this->token_response( $result ); + } + + /** + * Handle refresh token grant. + * + * @param \WP_REST_Request $request The request object. + * @param string $client_id The client ID. + * @return \WP_REST_Response|\WP_Error + */ + private function handle_refresh_token_grant( \WP_REST_Request $request, $client_id ) { + $refresh_token = $request->get_param( 'refresh_token' ); + + if ( empty( $refresh_token ) ) { + return $this->token_error( 'invalid_request', 'Refresh token is required.' ); + } + + $result = Token::refresh( $refresh_token, $client_id ); + + if ( \is_wp_error( $result ) ) { + return $this->token_error( 'invalid_grant', $result->get_error_message() ); + } + + return $this->token_response( $result ); + } + + /** + * Handle token revocation (POST /oauth/revoke). + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response + */ + public function revoke( \WP_REST_Request $request ) { + $token = $request->get_param( 'token' ); + + // Per RFC 7009, always return 200 even if token doesn't exist. + Token::revoke( $token ); + + return new \WP_REST_Response( null, 200 ); + } + + /** + * Handle token introspection (POST /oauth/introspect). + * + * Implements RFC 7662 Token Introspection. + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response + */ + public function introspect( \WP_REST_Request $request ) { + $token = $request->get_param( 'token' ); + + // Introspect the token. + $response = Token::introspect( $token ); + + // Scope introspection to same client: non-admin users can only + // introspect tokens belonging to the same client as their own. + if ( $response['active'] && ! \current_user_can( 'manage_options' ) ) { + $current_token = OAuth_Server::get_current_token(); + if ( $current_token && $current_token->get_client_id() !== $response['client_id'] ) { + $response = array( 'active' => false ); + } + } + + return new \WP_REST_Response( $response, 200 ); + } + + /** + * Permission check for token revocation. + * + * Per RFC 7009, the revocation endpoint must be protected. + * Requires either a logged-in user or a valid Bearer token. + * + * @return bool|\WP_Error True if allowed, error otherwise. + */ + public function revoke_permissions_check() { + if ( \is_user_logged_in() ) { + return true; + } + + $token = OAuth_Server::get_bearer_token(); + + if ( $token ) { + $validated = Token::validate( $token ); + + if ( ! \is_wp_error( $validated ) ) { + \wp_set_current_user( $validated->get_user_id() ); + return true; + } + } + + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'Authentication required.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + /** + * Permission check for token introspection. + * + * Per RFC 7662, the introspection endpoint must be protected. + * + * @return bool|\WP_Error True if allowed, error otherwise. + */ + public function introspect_permissions_check() { + if ( \is_user_logged_in() ) { + return true; + } + + // Support Bearer token auth for public OAuth clients. + $token = OAuth_Server::get_bearer_token(); + + if ( $token ) { + $validated = Token::validate( $token ); + + if ( ! \is_wp_error( $validated ) ) { + \wp_set_current_user( $validated->get_user_id() ); + return true; + } + } + + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'Authentication required.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + /** + * Create a token error response. + * + * @param string $error Error code. + * @param string $error_description Error description. + * @return \WP_REST_Response + */ + private function token_error( $error, $error_description ) { + return new \WP_REST_Response( + array( + 'error' => $error, + 'error_description' => $error_description, + ), + 400, + array( 'Content-Type' => 'application/json' ) + ); + } + + /** + * Create a token success response. + * + * @param array $token_data Token data. + * @return \WP_REST_Response + */ + private function token_response( $token_data ) { + return new \WP_REST_Response( + $token_data, + 200, + array( + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store', + 'Pragma' => 'no-cache', + ) + ); + } +} From 1450dc1386e041be9b3f39a99e92e25030bababd Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 20 Feb 2026 15:38:53 +0100 Subject: [PATCH 089/105] Fix review issues: backslash prefixes, handler returns, rate limiting, and test improvements - Add missing backslash prefixes for WordPress functions in OAuth and REST classes - Replace return null with return false in outbox handlers to prevent unhandled activities from falling through to raw outbox insertion - Return WP_Error from Update handler when Posts::update() fails instead of swallowing the error - Add explicit return values in Like and Announce handlers - Rename filter rest_activitypub_outbox_activity_types to activitypub_outbox_activity_types - Use strpos for CORS route matching instead of strict equality - Add per-user rate limiting (30/min) to proxy controller - Fix outbox pagination test to use a fresh user for empty collection assertions - Add @group annotations to all OAuth test classes --- includes/handler/outbox/class-announce.php | 4 +++- includes/handler/outbox/class-create.php | 6 +++--- includes/handler/outbox/class-delete.php | 4 ++-- includes/handler/outbox/class-like.php | 4 +++- includes/handler/outbox/class-update.php | 14 +++++++------- includes/oauth/class-client.php | 2 +- includes/oauth/class-server.php | 4 ++-- includes/oauth/class-token.php | 6 +++--- includes/rest/class-inbox-controller.php | 2 +- includes/rest/class-outbox-controller.php | 6 +++--- includes/rest/class-proxy-controller.php | 15 +++++++++++++++ .../includes/handler/outbox/class-test-create.php | 4 ++-- .../includes/handler/outbox/class-test-delete.php | 2 +- .../oauth/class-test-authorization-code.php | 3 +++ .../tests/includes/oauth/class-test-client.php | 3 +++ .../tests/includes/oauth/class-test-scope.php | 3 +++ .../tests/includes/oauth/class-test-token.php | 3 +++ .../rest/class-test-outbox-controller.php | 15 +++++++-------- .../oauth/class-test-authorization-controller.php | 3 +++ .../rest/oauth/class-test-clients-controller.php | 3 +++ .../rest/oauth/class-test-token-controller.php | 3 +++ 21 files changed, 74 insertions(+), 35 deletions(-) diff --git a/includes/handler/outbox/class-announce.php b/includes/handler/outbox/class-announce.php index 9cd31b0932..4e9ef5f68c 100644 --- a/includes/handler/outbox/class-announce.php +++ b/includes/handler/outbox/class-announce.php @@ -32,7 +32,7 @@ public static function handle_announce( $data, $user_id = null ) { $object_url = object_to_uri( $data['object'] ?? '' ); if ( empty( $object_url ) ) { - return; + return $data; } /** @@ -43,5 +43,7 @@ public static function handle_announce( $data, $user_id = null ) { * @param int $user_id The user ID. */ \do_action( 'activitypub_outbox_announce_sent', $object_url, $data, $user_id ); + + return $data; } } diff --git a/includes/handler/outbox/class-create.php b/includes/handler/outbox/class-create.php index c0d42d73ac..de8c61fefe 100644 --- a/includes/handler/outbox/class-create.php +++ b/includes/handler/outbox/class-create.php @@ -34,7 +34,7 @@ public static function init() { * @param int $user_id The local user ID. * @param string|null $visibility Content visibility. * - * @return int|\WP_Error|null The outbox ID on success, WP_Error on failure, null if not handled. + * @return int|\WP_Error|false The outbox ID on success, WP_Error on failure, false if not handled. */ public static function handle_create( $activity, $user_id = null, $visibility = null ) { // Skip private/direct activities. @@ -52,7 +52,7 @@ public static function handle_create( $activity, $user_id = null, $visibility = // Only handle Note and Article types for now. if ( ! \in_array( $object_type, array( 'Note', 'Article' ), true ) ) { - return null; + return false; } if ( is_activity_reply( $activity ) ) { @@ -61,7 +61,7 @@ public static function handle_create( $activity, $user_id = null, $visibility = // TODO: Handle quotes differently. if ( is_quote_activity( $activity ) ) { - return null; + return false; } return self::create_post( $activity, $user_id, $visibility ); diff --git a/includes/handler/outbox/class-delete.php b/includes/handler/outbox/class-delete.php index b4944bef3c..81ea720e2e 100644 --- a/includes/handler/outbox/class-delete.php +++ b/includes/handler/outbox/class-delete.php @@ -31,13 +31,13 @@ public static function init() { * @param array $data The activity data array. * @param int $user_id The user ID. * - * @return \WP_Post|\WP_Comment|null The deleted object, or null on failure. + * @return \WP_Post|\WP_Comment|false The deleted object, or false on failure. */ public static function handle_delete( $data, $user_id = null ) { $object_id = object_to_uri( $data['object'] ?? '' ); if ( empty( $object_id ) ) { - return null; + return false; } // Try to delete a comment first, then fall back to a post. diff --git a/includes/handler/outbox/class-like.php b/includes/handler/outbox/class-like.php index 166a872c96..5a87ac091e 100644 --- a/includes/handler/outbox/class-like.php +++ b/includes/handler/outbox/class-like.php @@ -32,7 +32,7 @@ public static function handle_like( $data, $user_id = null ) { $object_url = object_to_uri( $data['object'] ?? '' ); if ( empty( $object_url ) ) { - return; + return $data; } /** @@ -43,5 +43,7 @@ public static function handle_like( $data, $user_id = null ) { * @param int $user_id The user ID. */ \do_action( 'activitypub_outbox_like_sent', $object_url, $data, $user_id ); + + return $data; } } diff --git a/includes/handler/outbox/class-update.php b/includes/handler/outbox/class-update.php index d4f14ad4d5..1a6c6abc75 100644 --- a/includes/handler/outbox/class-update.php +++ b/includes/handler/outbox/class-update.php @@ -33,7 +33,7 @@ public static function init() { * @param int $user_id The local user ID. * @param string|null $visibility Content visibility. * - * @return \WP_Post|null The updated post on success, null if not handled. + * @return \WP_Post|\WP_Error|false The updated post on success, WP_Error on failure, false if not handled. */ public static function handle_update( $activity, $user_id = null, $visibility = null ) { // Skip private/direct activities. @@ -44,20 +44,20 @@ public static function handle_update( $activity, $user_id = null, $visibility = $object = $activity['object'] ?? array(); if ( ! \is_array( $object ) ) { - return null; + return false; } $type = $object['type'] ?? ''; // Only handle Note and Article types. if ( ! \in_array( $type, array( 'Note', 'Article' ), true ) ) { - return null; + return false; } $object_id = $object['id'] ?? ''; if ( empty( $object_id ) ) { - return null; + return false; } /* @@ -73,7 +73,7 @@ public static function handle_update( $activity, $user_id = null, $visibility = } if ( ! $post instanceof \WP_Post ) { - return null; + return false; } /* @@ -82,7 +82,7 @@ public static function handle_update( $activity, $user_id = null, $visibility = * represents the site itself. */ if ( (int) $post->post_author !== $user_id && $user_id > 0 ) { - return null; + return false; } // Verify the user has permission to edit this post. @@ -97,7 +97,7 @@ public static function handle_update( $activity, $user_id = null, $visibility = $post = Posts::update( $post, $activity, $visibility ); if ( \is_wp_error( $post ) ) { - return null; + return $post; } /** diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index dab0aa65d3..085d7eafec 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -653,7 +653,7 @@ public static function generate_client_secret() { * @return bool True if valid. */ private static function validate_uri_format( $uri ) { - $parsed = wp_parse_url( $uri ); + $parsed = \wp_parse_url( $uri ); if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { return false; diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 6290d2fe6e..76043d2ce2 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -288,7 +288,7 @@ private static function route_needs_cors( $route ) { // All ActivityPub endpoints need CORS except the interactive OAuth authorize endpoint. if ( 0 === strpos( $route, $namespace ) ) { - return $namespace . '/oauth/authorize' !== $route; + return false === strpos( $route, $namespace . '/oauth/authorize' ); } return false; @@ -331,7 +331,7 @@ public static function login_form_authorize() { \auth_redirect(); } - $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; + $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; if ( 'GET' === $request_method ) { self::render_authorize_form(); diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index 5fbd5cdad2..695d95c6f5 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -441,7 +441,7 @@ public static function revoke_for_client( $client_id ) { continue; } - $token_data = maybe_unserialize( $meta_values[0] ); + $token_data = \maybe_unserialize( $meta_values[0] ); if ( ! is_array( $token_data ) ) { continue; @@ -484,7 +484,7 @@ public static function get_all_for_user( $user_id ) { continue; } - $token_data = maybe_unserialize( $meta_values[0] ); + $token_data = \maybe_unserialize( $meta_values[0] ); if ( is_array( $token_data ) ) { // Don't expose hashes. @@ -648,7 +648,7 @@ public static function cleanup_expired() { continue; } - $token_data = maybe_unserialize( $meta_values[0] ); + $token_data = \maybe_unserialize( $meta_values[0] ); if ( ! is_array( $token_data ) ) { \delete_user_meta( $user_id, $meta_key ); diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index d0353f3ae0..2d9f54fad1 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -157,7 +157,7 @@ public function create_item( $request ) { * @param string $type The type of the activity. * @param Activity|\WP_Error $activity The Activity object. */ - do_action( 'activitypub_rest_inbox_disallowed', $data, null, $type, $activity ); + \do_action( 'activitypub_rest_inbox_disallowed', $data, null, $type, $activity ); } else { $recipients = $this->get_local_recipients( $data ); diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 531c6f3496..917ba5b0c9 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -129,7 +129,7 @@ public function get_items( $request ) { * * @param string[] $activity_types The list of activity types. */ - $activity_types = apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) ); + $activity_types = \apply_filters( 'activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) ); $args = array( 'posts_per_page' => $request->get_param( 'per_page' ), @@ -201,7 +201,7 @@ public function get_items( $request ) { * @param array $query_result The result of the query. * @param \WP_REST_Request $request The request object. */ - do_action( 'activitypub_rest_outbox_item_error', $outbox_item, $args, $query_result, $request ); + \do_action( 'activitypub_rest_outbox_item_error', $outbox_item, $args, $query_result, $request ); continue; } @@ -210,7 +210,7 @@ public function get_items( $request ) { } $response = $this->prepare_collection_response( $response, $request ); - if ( is_wp_error( $response ) ) { + if ( \is_wp_error( $response ) ) { return $response; } diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php index 9a6d34de9d..5b40c7061d 100644 --- a/includes/rest/class-proxy-controller.php +++ b/includes/rest/class-proxy-controller.php @@ -94,6 +94,21 @@ public function validate_url( $url ) { * @return \WP_REST_Response|\WP_Error Response object on success, WP_Error on failure. */ public function create_item( $request ) { + // Rate-limit proxy requests (max 30 per minute per user). + $user_id = \get_current_user_id(); + $transient_key = 'ap_proxy_' . $user_id; + $count = (int) \get_transient( $transient_key ); + + if ( $count >= 30 ) { + return new \WP_Error( + 'activitypub_rate_limit', + \__( 'Too many proxy requests. Please try again later.', 'activitypub' ), + array( 'status' => 429 ) + ); + } + + \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS ); + $url = $request->get_param( 'id' ); // Try to fetch as an actor first using Remote_Actors which handles caching. diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-create.php b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php index df0fb241e7..b2693c42cc 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php @@ -111,7 +111,7 @@ public function test_outgoing_unsupported_type_returns_null() { $result = Create::handle_create( $activity, 1 ); - $this->assertNull( $result ); + $this->assertFalse( $result ); } /** @@ -399,6 +399,6 @@ public function test_outgoing_quote_returns_null() { $result = Create::handle_create( $activity, 1 ); - $this->assertNull( $result ); + $this->assertFalse( $result ); } } diff --git a/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php b/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php index 9a2c734515..e1e674424b 100644 --- a/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php @@ -141,7 +141,7 @@ public function test_handle_delete_empty_object() { ); $result = Delete::handle_delete( $data, $this->user_id ); - $this->assertNull( $result ); + $this->assertFalse( $result ); } /** diff --git a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php index 1b98ca9ece..d5cfe54c0e 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php +++ b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php @@ -17,6 +17,9 @@ * Test class for OAuth Authorization_Code. * * @coversDefaultClass \Activitypub\OAuth\Authorization_Code + * + * @group activitypub + * @group oauth */ class Test_Authorization_Code extends \WP_UnitTestCase { diff --git a/tests/phpunit/tests/includes/oauth/class-test-client.php b/tests/phpunit/tests/includes/oauth/class-test-client.php index cd3c117f59..8f0304b5cf 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-client.php +++ b/tests/phpunit/tests/includes/oauth/class-test-client.php @@ -16,6 +16,9 @@ * Test class for OAuth Client. * * @coversDefaultClass \Activitypub\OAuth\Client + * + * @group activitypub + * @group oauth */ class Test_Client extends \WP_UnitTestCase { diff --git a/tests/phpunit/tests/includes/oauth/class-test-scope.php b/tests/phpunit/tests/includes/oauth/class-test-scope.php index 6a4111951a..958c3ccadb 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-scope.php +++ b/tests/phpunit/tests/includes/oauth/class-test-scope.php @@ -13,6 +13,9 @@ * Test class for OAuth Scope. * * @coversDefaultClass \Activitypub\OAuth\Scope + * + * @group activitypub + * @group oauth */ class Test_Scope extends \WP_UnitTestCase { diff --git a/tests/phpunit/tests/includes/oauth/class-test-token.php b/tests/phpunit/tests/includes/oauth/class-test-token.php index 9be7d76280..06bd693935 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-token.php +++ b/tests/phpunit/tests/includes/oauth/class-test-token.php @@ -16,6 +16,9 @@ * Test class for OAuth Token. * * @coversDefaultClass \Activitypub\OAuth\Token + * + * @group activitypub + * @group oauth */ class Test_Token extends \WP_UnitTestCase { diff --git a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php index 47efca65d4..d1f07cd709 100644 --- a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php @@ -152,18 +152,17 @@ public function test_get_items_pagination() { $this->assertStringContainsString( 'page=3', $data['next'] ); // Empty collections skip pagination metadata. - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/1/outbox' ); + // Use a fresh user with no outbox entries to test empty collection behavior. + $empty_user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + \get_user_by( 'ID', $empty_user_id )->add_cap( 'activitypub' ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $empty_user_id . '/outbox' ); $request->set_param( 'per_page', 3 ); $response = \rest_get_server()->dispatch( $request ); $data = $response->get_data(); - if ( empty( $data['orderedItems'] ) && ( ! isset( $data['totalItems'] ) || 0 === $data['totalItems'] ) ) { - $this->assertArrayNotHasKey( 'first', $data ); - $this->assertArrayNotHasKey( 'last', $data ); - } else { - $this->assertArrayHasKey( 'first', $data ); - $this->assertArrayHasKey( 'last', $data ); - } + $this->assertArrayNotHasKey( 'first', $data ); + $this->assertArrayNotHasKey( 'last', $data ); } /** diff --git a/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php b/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php index 5038f9c6c9..ab5253bf83 100644 --- a/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php @@ -14,6 +14,9 @@ * Test class for the OAuth Authorization Controller. * * @coversDefaultClass \Activitypub\Rest\OAuth\Authorization_Controller + * + * @group activitypub + * @group oauth */ class Test_Authorization_Controller extends \WP_UnitTestCase { diff --git a/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php b/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php index a18c88265e..c01d683da3 100644 --- a/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php @@ -13,6 +13,9 @@ * Test class for the OAuth Clients Controller. * * @coversDefaultClass \Activitypub\Rest\OAuth\Clients_Controller + * + * @group activitypub + * @group oauth */ class Test_Clients_Controller extends \WP_UnitTestCase { diff --git a/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php b/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php index 23ed5f22bb..a470f31332 100644 --- a/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php @@ -17,6 +17,9 @@ * Test class for the OAuth Token Controller. * * @coversDefaultClass \Activitypub\Rest\OAuth\Token_Controller + * + * @group activitypub + * @group oauth */ class Test_Token_Controller extends \WP_UnitTestCase { From 17396963cd140b93a589b3b7f1984d7924f1cc04 Mon Sep 17 00:00:00 2001 From: Django Date: Sat, 21 Feb 2026 04:25:39 -0700 Subject: [PATCH 090/105] =?UTF-8?q?Fix=20proxyUrl=20with=20urldecode=20in?= =?UTF-8?q?=20sanitize=20and=20validate=20callbacks=20before=E2=80=A6=20(#?= =?UTF-8?q?2957)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matthias Pfefferle --- includes/rest/class-proxy-controller.php | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php index 5b40c7061d..0a7036b282 100644 --- a/includes/rest/class-proxy-controller.php +++ b/includes/rest/class-proxy-controller.php @@ -55,7 +55,7 @@ public function register_routes() { 'description' => 'The URI of the remote ActivityPub object to fetch.', 'type' => 'string', 'required' => true, - 'sanitize_callback' => 'sanitize_url', + 'sanitize_callback' => array( $this, 'sanitize_url' ), 'validate_callback' => array( $this, 'validate_url' ), ), ), @@ -65,6 +65,18 @@ public function register_routes() { ); } + /** + * Sanitizes the URL parameter. + * + * @see https://developer.wordpress.org/reference/functions/sanitize_url/ + * + * @param string $url The urlencoded URL to sanitize. + * @return string The sanitized URL. + */ + public function sanitize_url( $url ) { + // Decode and sanitize the URL. + return sanitize_url( urldecode( $url ) ); + } /** * Validate the URL parameter. * @@ -76,13 +88,16 @@ public function register_routes() { * @return bool True if valid, false otherwise. */ public function validate_url( $url ) { + // Decode the url. + $decoded_url = urldecode( $url ); + // Must be HTTPS. - if ( 'https' !== \wp_parse_url( $url, PHP_URL_SCHEME ) ) { + if ( 'https' !== \wp_parse_url( $decoded_url, PHP_URL_SCHEME ) ) { return false; } // Use WordPress built-in validation (blocks local IPs, restricts ports). - return (bool) \wp_http_validate_url( $url ); + return (bool) \wp_http_validate_url( $decoded_url ); } /** From 9554eedd897dc9b660a86861e9a4e027a19f750e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 08:35:12 +0100 Subject: [PATCH 091/105] Fix OAuth template rendering, coding standards, and CORS headers - Use wp_kses() instead of esc_html__() with printf() for HTML in OAuth consent template to prevent escaped tags from rendering as text. - Add backslash prefix to filter_var() calls in namespaced code. - Change @since 1.3.0 to @since unreleased on new outbox handlers hook. - Add Access-Control-Allow-Credentials header for browser-based C2S clients. --- includes/class-handler.php | 2 +- includes/oauth/class-client.php | 4 ++-- includes/oauth/class-server.php | 1 + templates/oauth-authorize.php | 24 +++++++++++++++--------- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/includes/class-handler.php b/includes/class-handler.php index c70127881f..c507402ba0 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -59,7 +59,7 @@ public static function register_outbox_handlers() { /** * Register additional outbox handlers. * - * @since 1.3.0 + * @since unreleased */ do_action( 'activitypub_register_outbox_handlers' ); } diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index 085d7eafec..f1e4d044ed 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -149,7 +149,7 @@ public static function get( $client_id ) { } // If client_id is a URL, try auto-discovery. - if ( filter_var( $client_id, FILTER_VALIDATE_URL ) ) { + if ( \filter_var( $client_id, FILTER_VALIDATE_URL ) ) { return self::discover_and_register( $client_id ); } @@ -425,7 +425,7 @@ public function is_valid_redirect_uri( $redirect_uri ) { * The redirect_uri must be on the same host as the client_id. */ $client_id = $this->get_client_id(); - if ( filter_var( $client_id, FILTER_VALIDATE_URL ) ) { + if ( \filter_var( $client_id, FILTER_VALIDATE_URL ) ) { $client_host = \wp_parse_url( $client_id, PHP_URL_HOST ); $redirect_host = \wp_parse_url( $redirect_uri, PHP_URL_HOST ); diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 76043d2ce2..3be0f3acc7 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -271,6 +271,7 @@ public static function add_cors_headers( $response, $server, $request ) { $response->header( 'Access-Control-Allow-Headers', 'Accept, Content-Type, Authorization' ); if ( $origin ) { + $response->header( 'Access-Control-Allow-Credentials', 'true' ); $response->header( 'Vary', 'Origin' ); } diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php index 997d516fc4..8ae5851cfb 100644 --- a/templates/oauth-authorize.php +++ b/templates/oauth-authorize.php @@ -62,11 +62,14 @@ ID, 48 ); ?>

' . esc_html( $current_user->display_name ) . '', - esc_html( $current_user->user_login ) + echo wp_kses( + sprintf( + /* translators: 1: User display name, 2: User login */ + __( 'Logged in as %1$s (%2$s). You can revoke access at any time.', 'activitypub' ), + '' . esc_html( $current_user->display_name ) . '', + esc_html( $current_user->user_login ) + ), + array( 'strong' => array() ) ); ?>

@@ -93,10 +96,13 @@
' . esc_html( $redirect_uri ) . '' + echo wp_kses( + sprintf( + /* translators: %s: Redirect URI */ + __( 'You will be redirected to %s after authorization.', 'activitypub' ), + '' . esc_html( $redirect_uri ) . '' + ), + array( 'code' => array() ) ); ?>
From 21a8f7931f2a7062fd26e61f84f82f38bf961184 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 08:38:00 +0100 Subject: [PATCH 092/105] Add filterable get_client_ip() for rate limiting behind proxies Extract IP detection into a helper with an `activitypub_client_ip` filter so sites behind reverse proxies can return the real client IP. --- includes/functions.php | 27 ++++++++++++++ includes/oauth/class-client.php | 4 +- .../rest/oauth/class-token-controller.php | 4 +- .../tests/includes/class-test-functions.php | 37 +++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/includes/functions.php b/includes/functions.php index e9e01ef4f1..0b00393a1a 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -337,3 +337,30 @@ function enrich_content_data( $content, $regex, $regex_callback ) { function get_embed_html( $url, $inline_css = true ) { return Embed::get_html( $url, $inline_css ); } + +/** + * Get the client IP address for rate-limiting purposes. + * + * Uses REMOTE_ADDR by default. Sites behind a reverse proxy (Nginx, + * Cloudflare, etc.) should use the `activitypub_client_ip` filter to + * return the real client IP from a trusted header like X-Forwarded-For. + * + * @since unreleased + * + * @return string The client IP address. + */ +function get_client_ip() { + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders + + /** + * Filter the client IP address used for rate limiting. + * + * Sites behind a reverse proxy should hook here to return the real + * client IP from a trusted header (e.g. X-Forwarded-For, CF-Connecting-IP). + * + * @since unreleased + * + * @param string $ip The client IP address from REMOTE_ADDR. + */ + return \apply_filters( 'activitypub_client_ip', $ip ); +} diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index f1e4d044ed..4c0172ce59 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -7,6 +7,8 @@ namespace Activitypub\OAuth; +use function Activitypub\get_client_ip; + /** * Client class for managing OAuth 2.0 client registrations. * @@ -171,7 +173,7 @@ public static function get( $client_id ) { */ private static function discover_and_register( $client_id ) { // Rate-limit auto-discovery to prevent SSRF abuse (max 10 per minute per IP). - $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; + $ip = get_client_ip(); $transient_key = 'ap_oauth_disc_' . \md5( $ip ); $count = (int) \get_transient( $transient_key ); diff --git a/includes/rest/oauth/class-token-controller.php b/includes/rest/oauth/class-token-controller.php index 0c36d9cb7b..8c7bb6da63 100644 --- a/includes/rest/oauth/class-token-controller.php +++ b/includes/rest/oauth/class-token-controller.php @@ -12,6 +12,8 @@ use Activitypub\OAuth\Server as OAuth_Server; use Activitypub\OAuth\Token; +use function Activitypub\get_client_ip; + /** * Token_Controller class for handling OAuth 2.0 token endpoints. * @@ -147,7 +149,7 @@ public function register_routes() { */ public function token( \WP_REST_Request $request ) { // Rate-limit token requests to prevent brute-force attacks (max 20 per minute per IP). - $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders + $ip = get_client_ip(); $transient_key = 'ap_oauth_tok_' . \md5( $ip ); $count = (int) \get_transient( $transient_key ); diff --git a/tests/phpunit/tests/includes/class-test-functions.php b/tests/phpunit/tests/includes/class-test-functions.php index 2efc8470b6..ad99189fe3 100644 --- a/tests/phpunit/tests/includes/class-test-functions.php +++ b/tests/phpunit/tests/includes/class-test-functions.php @@ -303,6 +303,43 @@ public function test_get_object_id_with_unsupported_type() { $this->assertNull( \Activitypub\get_object_id( new \stdClass() ) ); } + /** + * Test get_client_ip returns REMOTE_ADDR by default. + * + * @covers \Activitypub\get_client_ip + */ + public function test_get_client_ip_default() { + $_SERVER['REMOTE_ADDR'] = '192.168.1.1'; + $this->assertSame( '192.168.1.1', \Activitypub\get_client_ip() ); + } + + /** + * Test get_client_ip is filterable. + * + * @covers \Activitypub\get_client_ip + */ + public function test_get_client_ip_filter() { + $_SERVER['REMOTE_ADDR'] = '10.0.0.1'; + + $filter = function () { + return '203.0.113.50'; + }; + + \add_filter( 'activitypub_client_ip', $filter ); + $this->assertSame( '203.0.113.50', \Activitypub\get_client_ip() ); + \remove_filter( 'activitypub_client_ip', $filter ); + } + + /** + * Test get_client_ip returns unknown when REMOTE_ADDR is missing. + * + * @covers \Activitypub\get_client_ip + */ + public function test_get_client_ip_missing_remote_addr() { + unset( $_SERVER['REMOTE_ADDR'] ); + $this->assertSame( 'unknown', \Activitypub\get_client_ip() ); + } + /** * Data provider for seconds_to_iso8601 tests. * From 15a8747028627f112d31b0378edf5d4d221ae0d6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 08:38:52 +0100 Subject: [PATCH 093/105] Enforce per-user token limit to prevent usermeta flooding Cap active OAuth tokens at 50 per user. When exceeded, the oldest tokens are automatically revoked, preventing usermeta accumulation from repeated authentication. --- includes/oauth/class-token.php | 65 +++++++++++++++++++ .../tests/includes/oauth/class-test-token.php | 34 ++++++++++ 2 files changed, 99 insertions(+) diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index 695d95c6f5..00055b9e58 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -31,6 +31,15 @@ class Token { */ const USERS_OPTION = 'activitypub_oauth_token_users'; + /** + * Maximum number of active tokens per user. + * + * When exceeded, the oldest tokens are revoked automatically. + * + * @since unreleased + */ + const MAX_TOKENS_PER_USER = 50; + /** * Default access token expiration in seconds (1 hour). */ @@ -125,6 +134,9 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D // Track user for cleanup. self::track_user( $user_id ); + // Enforce per-user token limit by revoking the oldest tokens. + self::enforce_token_limit( $user_id ); + /* * Get the actor URI for the 'me' parameter (IndieAuth convention). * Fall back to blog actor when user actors are disabled. @@ -604,6 +616,59 @@ private static function track_user( $user_id ) { } } + /** + * Enforce per-user token limit by revoking oldest tokens. + * + * @since unreleased + * + * @param int $user_id The user ID. + */ + private static function enforce_token_limit( $user_id ) { + $all_meta = \get_user_meta( $user_id ); + $tokens = array(); + + foreach ( $all_meta as $meta_key => $meta_values ) { + if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { + continue; + } + + $token_data = \maybe_unserialize( $meta_values[0] ); + + if ( is_array( $token_data ) ) { + $tokens[ $meta_key ] = $token_data; + } + } + + if ( count( $tokens ) <= self::MAX_TOKENS_PER_USER ) { + return; + } + + // Sort by created_at ascending (oldest first). + uasort( + $tokens, + function ( $a, $b ) { + return ( $a['created_at'] ?? 0 ) - ( $b['created_at'] ?? 0 ); + } + ); + + $to_remove = count( $tokens ) - self::MAX_TOKENS_PER_USER; + + foreach ( $tokens as $meta_key => $token_data ) { + if ( $to_remove <= 0 ) { + break; + } + + \delete_user_meta( $user_id, $meta_key ); + + // Also delete the refresh token index. + if ( isset( $token_data['refresh_token_hash'] ) ) { + \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] ); + } + + --$to_remove; + } + } + /** * Untrack a user (when they have no more tokens). * diff --git a/tests/phpunit/tests/includes/oauth/class-test-token.php b/tests/phpunit/tests/includes/oauth/class-test-token.php index 06bd693935..a0574c1601 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-token.php +++ b/tests/phpunit/tests/includes/oauth/class-test-token.php @@ -339,6 +339,40 @@ public function test_revoke_all_for_user() { $this->assertInstanceOf( \WP_Error::class, Token::validate( $token3['access_token'] ) ); } + /** + * Test per-user token limit enforcement. + * + * @covers ::create + */ + public function test_enforce_token_limit() { + $scopes = array( Scope::READ ); + + // Create tokens up to the limit + 5. + $tokens = array(); + for ( $i = 0; $i < Token::MAX_TOKENS_PER_USER + 5; $i++ ) { + $tokens[] = Token::create( $this->user_id, $this->client_id, $scopes ); + } + + // Count remaining tokens in user meta. + $all_meta = \get_user_meta( $this->user_id ); + $count = 0; + foreach ( $all_meta as $meta_key => $meta_values ) { + if ( 0 === strpos( $meta_key, Token::META_PREFIX ) ) { + ++$count; + } + } + + $this->assertLessThanOrEqual( Token::MAX_TOKENS_PER_USER, $count ); + + // The most recently created token should still be valid. + $latest = end( $tokens ); + $this->assertNotInstanceOf( \WP_Error::class, Token::validate( $latest['access_token'] ) ); + + // The earliest tokens should have been revoked. + $earliest = $tokens[0]; + $this->assertInstanceOf( \WP_Error::class, Token::validate( $earliest['access_token'] ) ); + } + /** * Test cleanup_expired method. * From e97b9948d1da348b9ec2a2bfb60711d2aa657a15 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 08:41:21 +0100 Subject: [PATCH 094/105] Add filter to enforce PKCE for public OAuth clients Public clients can skip PKCE by default for backward compatibility, but site operators can require it via the `activitypub_oauth_require_pkce` filter, aligning with OAuth 2.1 which mandates PKCE for all public clients. --- includes/oauth/class-authorization-code.php | 26 +++++++- .../oauth/class-test-authorization-code.php | 66 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index 8f1a6a367e..dedcbae2d7 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -59,9 +59,31 @@ public static function create( } /* - * PKCE is recommended for public clients (RFC 7636) but not enforced - * to maintain compatibility with existing C2S clients. + * PKCE is strongly recommended for public clients (RFC 7636) and + * mandatory in the OAuth 2.1 draft. By default, it is not enforced + * to maintain compatibility with existing C2S clients, but site + * operators can require it via filter. */ + if ( empty( $code_challenge ) && $client->is_public() ) { + /** + * Filter whether PKCE is required for public OAuth clients. + * + * Return true to enforce PKCE (recommended per OAuth 2.1). + * Default false for backward compatibility with older clients. + * + * @since unreleased + * + * @param bool $require Whether to require PKCE. Default false. + * @param string $client_id The OAuth client ID. + */ + if ( \apply_filters( 'activitypub_oauth_require_pkce', false, $client_id ) ) { + return new \WP_Error( + 'activitypub_pkce_required', + \__( 'PKCE is required for public clients. Please include a code_challenge parameter.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + } // Filter scopes to only allowed ones. $filtered_scopes = $client->filter_scopes( Scope::validate( $scopes ) ); diff --git a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php index d5cfe54c0e..59a1c87bf2 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php +++ b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php @@ -444,6 +444,72 @@ public function test_exchange_filters_scopes() { Client::delete( $limited_client['client_id'] ); } + /** + * Test that public clients can skip PKCE by default. + * + * @covers ::create + */ + public function test_create_without_pkce_allowed_by_default() { + $code = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ ), + '', // No PKCE. + 'S256' + ); + + $this->assertIsString( $code ); + } + + /** + * Test that the activitypub_oauth_require_pkce filter enforces PKCE. + * + * @covers ::create + */ + public function test_create_without_pkce_blocked_by_filter() { + \add_filter( 'activitypub_oauth_require_pkce', '__return_true' ); + + $result = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ ), + '', // No PKCE. + 'S256' + ); + + \remove_filter( 'activitypub_oauth_require_pkce', '__return_true' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_pkce_required', $result->get_error_code() ); + } + + /** + * Test that the PKCE filter does not affect requests that include PKCE. + * + * @covers ::create + */ + public function test_create_with_pkce_passes_when_filter_enabled() { + \add_filter( 'activitypub_oauth_require_pkce', '__return_true' ); + + $verifier = $this->generate_code_verifier(); + $challenge = Authorization_Code::compute_code_challenge( $verifier ); + + $code = Authorization_Code::create( + $this->user_id, + $this->client_id, + $this->redirect_uri, + array( Scope::READ ), + $challenge, + 'S256' + ); + + \remove_filter( 'activitypub_oauth_require_pkce', '__return_true' ); + + $this->assertIsString( $code ); + } + /** * Test cleanup method. * From 099ee9efb1e0ee397deb8964fc89ce13a63f3349 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 08:41:53 +0100 Subject: [PATCH 095/105] Translate error message in outbox Create handler --- includes/handler/outbox/class-create.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/handler/outbox/class-create.php b/includes/handler/outbox/class-create.php index de8c61fefe..0c9241f3fc 100644 --- a/includes/handler/outbox/class-create.php +++ b/includes/handler/outbox/class-create.php @@ -45,7 +45,7 @@ public static function handle_create( $activity, $user_id = null, $visibility = $object = $activity['object'] ?? array(); if ( ! \is_array( $object ) ) { - return new \WP_Error( 'invalid_object', 'Invalid object in activity.' ); + return new \WP_Error( 'invalid_object', \__( 'Invalid object in activity.', 'activitypub' ) ); } $object_type = $object['type'] ?? ''; From 8b905234aaea8e84156c3f061d8f728497b2e1a6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 08:42:27 +0100 Subject: [PATCH 096/105] Revert "Translate error message in outbox Create handler" This reverts commit 099ee9efb1e0ee397deb8964fc89ce13a63f3349. --- includes/handler/outbox/class-create.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/handler/outbox/class-create.php b/includes/handler/outbox/class-create.php index 0c9241f3fc..de8c61fefe 100644 --- a/includes/handler/outbox/class-create.php +++ b/includes/handler/outbox/class-create.php @@ -45,7 +45,7 @@ public static function handle_create( $activity, $user_id = null, $visibility = $object = $activity['object'] ?? array(); if ( ! \is_array( $object ) ) { - return new \WP_Error( 'invalid_object', \__( 'Invalid object in activity.', 'activitypub' ) ); + return new \WP_Error( 'invalid_object', 'Invalid object in activity.' ); } $object_type = $object['type'] ?? ''; From 97a39bcf0c4267907ac48423de21adda1fcd542c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 08:44:33 +0100 Subject: [PATCH 097/105] Use relative URLs in OAuth E2E tests instead of hardcoded port Replace hardcoded http://localhost:8889 with relative paths resolved against Playwright's baseURL config, and use the baseURL fixture for the webfinger resource parameter. --- .../includes/rest/oauth-controller.test.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/e2e/specs/includes/rest/oauth-controller.test.js b/tests/e2e/specs/includes/rest/oauth-controller.test.js index e0f0d0d314..3cef5eb479 100644 --- a/tests/e2e/specs/includes/rest/oauth-controller.test.js +++ b/tests/e2e/specs/includes/rest/oauth-controller.test.js @@ -4,10 +4,10 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; test.describe( 'OAuth Controller CORS Headers', () => { - const restBase = 'http://localhost:8889/index.php?rest_route='; + const restRoute = '/index.php?rest_route='; test( 'should include CORS headers on outbox endpoint', async ( { request } ) => { - const response = await request.get( `${ restBase }/activitypub/1.0/actors/1/outbox` ); + const response = await request.get( `${ restRoute }/activitypub/1.0/actors/1/outbox` ); expect( response.status() ).toBe( 200 ); expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); @@ -17,21 +17,21 @@ test.describe( 'OAuth Controller CORS Headers', () => { } ); test( 'should include CORS headers on inbox endpoint', async ( { request } ) => { - const response = await request.get( `${ restBase }/activitypub/1.0/actors/1/inbox` ); + const response = await request.get( `${ restRoute }/activitypub/1.0/actors/1/inbox` ); expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); } ); - test( 'should include CORS headers on webfinger endpoint', async ( { request } ) => { - const resource = encodeURIComponent( 'http://localhost:8889/?author=1' ); - const response = await request.get( `${ restBase }/activitypub/1.0/webfinger&resource=${ resource }` ); + test( 'should include CORS headers on webfinger endpoint', async ( { request, baseURL } ) => { + const resource = encodeURIComponent( `${ baseURL }?author=1` ); + const response = await request.get( `${ restRoute }/activitypub/1.0/webfinger&resource=${ resource }` ); expect( response.status() ).toBe( 200 ); expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); } ); test( 'should include CORS headers on OAuth token endpoint', async ( { request } ) => { - const response = await request.post( `${ restBase }/activitypub/1.0/oauth/token`, { + const response = await request.post( `${ restRoute }/activitypub/1.0/oauth/token`, { form: { grant_type: 'authorization_code', code: 'invalid', @@ -48,14 +48,14 @@ test.describe( 'OAuth Controller CORS Headers', () => { } ); test( 'should include CORS headers on actors endpoint', async ( { request } ) => { - const response = await request.get( `${ restBase }/activitypub/1.0/actors/1` ); + const response = await request.get( `${ restRoute }/activitypub/1.0/actors/1` ); expect( response.status() ).toBe( 200 ); expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); } ); test( 'should include CORS headers on followers endpoint', async ( { request } ) => { - const response = await request.get( `${ restBase }/activitypub/1.0/actors/1/followers` ); + const response = await request.get( `${ restRoute }/activitypub/1.0/actors/1/followers` ); expect( response.status() ).toBe( 200 ); expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); From b557f422ef76d913301a1182c39d71503b363a35 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 12:44:11 +0100 Subject: [PATCH 098/105] Clean up OAuth clients and tokens on plugin uninstall The uninstall hook was not removing OAuth data (ap_oauth_client posts and user meta tokens), leaving orphaned data in the database after plugin deletion. --- includes/class-activitypub.php | 2 ++ includes/oauth/class-client.php | 27 +++++++++++++++++++++++++++ includes/oauth/class-token.php | 20 ++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index aebefb8b95..23655c2dfb 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -10,6 +10,7 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Following; use Activitypub\Collection\Remote_Posts; +use Activitypub\OAuth\Client; /** * ActivityPub Class. @@ -87,6 +88,7 @@ public static function uninstall() { Migration::update_comment_counts( 2000 ); Remote_Posts::delete_all(); + Client::delete_all(); Options::delete(); } diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index 4c0172ce59..b130e324da 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -679,6 +679,33 @@ private static function validate_uri_format( $uri ) { return true; } + /** + * Delete all OAuth clients and their associated tokens. + * + * Used during plugin uninstall to clean up all OAuth data. + * + * @return int The number of clients deleted. + */ + public static function delete_all() { + $post_ids = \get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => array( 'any', 'trash', 'auto-draft' ), + 'fields' => 'ids', + 'numberposts' => -1, + ) + ); + + foreach ( $post_ids as $post_id ) { + \wp_delete_post( $post_id, true ); + } + + // Also revoke all tokens stored in user meta. + Token::revoke_all(); + + return count( $post_ids ); + } + /** * Delete a client and all its tokens. * diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index 00055b9e58..de2c038166 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -434,6 +434,26 @@ public static function revoke_all_for_user( $user_id ) { return $count; } + /** + * Revoke all tokens for all users. + * + * Used during plugin uninstall to clean up all OAuth token data. + * + * @return int Number of tokens revoked. + */ + public static function revoke_all() { + $users = self::get_tracked_users(); + $count = 0; + + foreach ( $users as $user_id ) { + $count += self::revoke_all_for_user( $user_id ); + } + + \delete_option( self::USERS_OPTION ); + + return $count; + } + /** * Revoke all tokens for a specific client. * From 4768ec363836bfa31ef68cbd84dad47befae8947 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 12:59:49 +0100 Subject: [PATCH 099/105] Replace global token tracking option with per-client post meta Replace the serialized activitypub_oauth_token_users option with _activitypub_user_id post meta on ap_oauth_client posts, following the same pattern as _activitypub_following on ap_actor posts. This eliminates race conditions from concurrent read-modify-write on a shared option and allows direct client-to-user lookups. --- includes/class-post-types.php | 12 ++ includes/oauth/class-token.php | 213 +++++++++++++++++++++------------ 2 files changed, 151 insertions(+), 74 deletions(-) diff --git a/includes/class-post-types.php b/includes/class-post-types.php index a6a3009b93..4cc1a3471f 100644 --- a/includes/class-post-types.php +++ b/includes/class-post-types.php @@ -17,6 +17,7 @@ use Activitypub\Collection\Remote_Posts; use Activitypub\OAuth\Client; use Activitypub\OAuth\Scope; +use Activitypub\OAuth\Token; /** * Post Types class. @@ -547,6 +548,17 @@ public static function register_oauth_post_types() { 'default' => true, ) ); + + \register_post_meta( + Client::POST_TYPE, + Token::USER_META_KEY, + array( + 'type' => 'integer', + 'single' => false, + 'description' => 'User IDs that have active tokens for this client.', + 'sanitize_callback' => 'absint', + ) + ); } /** diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index de2c038166..29734e86d1 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -27,9 +27,12 @@ class Token { const REFRESH_INDEX_PREFIX = '_activitypub_oauth_refresh_'; /** - * Option key for tracking users with tokens (for cleanup). + * Post meta key on OAuth client posts to track users with tokens. + * + * Stored as non-unique post meta (one row per user) on ap_oauth_client posts, + * following the same pattern as _activitypub_following on ap_actor posts. */ - const USERS_OPTION = 'activitypub_oauth_token_users'; + const USER_META_KEY = '_activitypub_user_id'; /** * Maximum number of active tokens per user. @@ -131,8 +134,8 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D $refresh_index_key = self::REFRESH_INDEX_PREFIX . self::hash_token( $refresh_token ); \update_user_meta( $user_id, $refresh_index_key, $access_hash ); - // Track user for cleanup. - self::track_user( $user_id ); + // Track user on the client post for cleanup. + self::track_user( $user_id, $client_id ); // Enforce per-user token limit by revoking the oldest tokens. self::enforce_token_limit( $user_id ); @@ -341,6 +344,7 @@ public static function revoke( $token ) { if ( $user_id ) { $user_id = (int) $user_id; $token_data = \get_user_meta( $user_id, $access_meta_key, true ); + $client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : ''; // Delete the token. \delete_user_meta( $user_id, $access_meta_key ); @@ -351,7 +355,7 @@ public static function revoke( $token ) { \delete_user_meta( $user_id, $refresh_index_key ); } - self::maybe_untrack_user( $user_id ); + self::maybe_untrack_user( $user_id, $client_id ); return true; } @@ -367,14 +371,17 @@ public static function revoke( $token ) { if ( $user_id ) { $user_id = (int) $user_id; $access_hash = \get_user_meta( $user_id, $refresh_index_key, true ); + $client_id = ''; // Delete the token and index. if ( $access_hash ) { + $token_data = \get_user_meta( $user_id, self::META_PREFIX . $access_hash, true ); + $client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : ''; \delete_user_meta( $user_id, self::META_PREFIX . $access_hash ); } \delete_user_meta( $user_id, $refresh_index_key ); - self::maybe_untrack_user( $user_id ); + self::maybe_untrack_user( $user_id, $client_id ); return true; } @@ -383,25 +390,25 @@ public static function revoke( $token ) { } /** - * Untrack user if they have no remaining tokens. + * Untrack user from a client if they have no remaining tokens for that client. * - * @param int $user_id The user ID. + * @param int $user_id The user ID. + * @param string $client_id The OAuth client ID. */ - private static function maybe_untrack_user( $user_id ) { - global $wpdb; - - // Check if user has any remaining tokens. - $count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $wpdb->prepare( - "SELECT COUNT(*) FROM $wpdb->usermeta WHERE user_id = %d AND meta_key LIKE %s", - $user_id, - $wpdb->esc_like( self::META_PREFIX ) . '%' - ) - ); + private static function maybe_untrack_user( $user_id, $client_id ) { + if ( empty( $client_id ) ) { + return; + } - if ( 0 === (int) $count ) { - self::untrack_user( $user_id ); + // Check if user has any remaining tokens for this client. + $tokens = self::get_all_for_user( $user_id ); + foreach ( $tokens as $token_data ) { + if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) { + return; // Still has tokens for this client. + } } + + self::untrack_user( $user_id, $client_id ); } /** @@ -411,12 +418,17 @@ private static function maybe_untrack_user( $user_id ) { * @return int Number of tokens revoked. */ public static function revoke_all_for_user( $user_id ) { - $all_meta = \get_user_meta( $user_id ); - $count = 0; + $all_meta = \get_user_meta( $user_id ); + $count = 0; + $client_ids = array(); foreach ( $all_meta as $meta_key => $meta_values ) { - // Delete token entries. + // Delete token entries and collect client IDs. if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) { + $token_data = \maybe_unserialize( $meta_values[0] ); + if ( is_array( $token_data ) && ! empty( $token_data['client_id'] ) ) { + $client_ids[] = $token_data['client_id']; + } \delete_user_meta( $user_id, $meta_key ); ++$count; } @@ -426,9 +438,9 @@ public static function revoke_all_for_user( $user_id ) { } } - // Remove user from tracking if no more tokens. - if ( $count > 0 ) { - self::untrack_user( $user_id ); + // Remove user from all client tracking. + foreach ( array_unique( $client_ids ) as $client_id ) { + self::untrack_user( $user_id, $client_id ); } return $count; @@ -442,15 +454,13 @@ public static function revoke_all_for_user( $user_id ) { * @return int Number of tokens revoked. */ public static function revoke_all() { - $users = self::get_tracked_users(); - $count = 0; + $user_ids = self::get_all_tracked_users(); + $count = 0; - foreach ( $users as $user_id ) { + foreach ( $user_ids as $user_id ) { $count += self::revoke_all_for_user( $user_id ); } - \delete_option( self::USERS_OPTION ); - return $count; } @@ -461,12 +471,11 @@ public static function revoke_all() { * @return int Number of tokens revoked. */ public static function revoke_for_client( $client_id ) { - $users = self::get_tracked_users(); - $count = 0; + $user_ids = self::get_tracked_users( $client_id ); + $count = 0; - foreach ( $users as $user_id ) { - $all_meta = \get_user_meta( $user_id ); - $user_tokens = 0; + foreach ( $user_ids as $user_id ) { + $all_meta = \get_user_meta( $user_id ); foreach ( $all_meta as $meta_key => $meta_values ) { if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { @@ -479,7 +488,7 @@ public static function revoke_for_client( $client_id ) { continue; } - // Check if this token belongs to the client. + // Only revoke tokens belonging to this client. if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) { \delete_user_meta( $user_id, $meta_key ); // Also delete refresh token index. @@ -487,17 +496,13 @@ public static function revoke_for_client( $client_id ) { \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] ); } ++$count; - } else { - ++$user_tokens; } } - - // Untrack user if no more tokens. - if ( 0 === $user_tokens ) { - self::untrack_user( $user_id ); - } } + // Remove all user tracking for this client. + self::untrack_all_users( $client_id ); + return $count; } @@ -624,15 +629,26 @@ public static function hash_token( $token ) { } /** - * Track a user as having tokens. + * Track a user as having tokens for a client. * - * @param int $user_id The user ID. + * Stores user ID as non-unique post meta on the client post, + * following the same pattern as _activitypub_following on ap_actor posts. + * + * @param int $user_id The user ID. + * @param string $client_id The OAuth client ID. */ - private static function track_user( $user_id ) { - $users = self::get_tracked_users(); - if ( ! in_array( $user_id, $users, true ) ) { - $users[] = $user_id; - \update_option( self::USERS_OPTION, $users, false ); + private static function track_user( $user_id, $client_id ) { + $client = Client::get( $client_id ); + + if ( \is_wp_error( $client ) ) { + return; + } + + $post_id = $client->get_post_id(); + $existing = \get_post_meta( $post_id, self::USER_META_KEY, false ); + + if ( ! in_array( $user_id, array_map( 'intval', $existing ), true ) ) { + \add_post_meta( $post_id, self::USER_META_KEY, $user_id ); } } @@ -690,27 +706,74 @@ function ( $a, $b ) { } /** - * Untrack a user (when they have no more tokens). + * Untrack a user from a specific client. * - * @param int $user_id The user ID. + * @param int $user_id The user ID. + * @param string $client_id The OAuth client ID. + */ + private static function untrack_user( $user_id, $client_id ) { + $client = Client::get( $client_id ); + + if ( \is_wp_error( $client ) ) { + return; + } + + \delete_post_meta( $client->get_post_id(), self::USER_META_KEY, $user_id ); + } + + /** + * Untrack all users from a specific client. + * + * @param string $client_id The OAuth client ID. */ - private static function untrack_user( $user_id ) { - $users = self::get_tracked_users(); - $key = array_search( $user_id, $users, true ); - if ( false !== $key ) { - unset( $users[ $key ] ); - \update_option( self::USERS_OPTION, array_values( $users ), false ); + private static function untrack_all_users( $client_id ) { + $client = Client::get( $client_id ); + + if ( \is_wp_error( $client ) ) { + return; } + + \delete_post_meta( $client->get_post_id(), self::USER_META_KEY ); } /** - * Get all tracked users with tokens. + * Get tracked users for a specific client. * + * @param string $client_id The OAuth client ID. * @return array User IDs. */ - private static function get_tracked_users() { - $users = \get_option( self::USERS_OPTION, array() ); - return is_array( $users ) ? $users : array(); + private static function get_tracked_users( $client_id ) { + $client = Client::get( $client_id ); + + if ( \is_wp_error( $client ) ) { + return array(); + } + + $user_ids = \get_post_meta( $client->get_post_id(), self::USER_META_KEY, false ); + + return array_map( 'intval', $user_ids ); + } + + /** + * Get all user IDs with tokens across all clients. + * + * @return array Unique user IDs. + */ + private static function get_all_tracked_users() { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $user_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT pm.meta_value FROM $wpdb->postmeta pm + INNER JOIN $wpdb->posts p ON pm.post_id = p.ID + WHERE p.post_type = %s AND pm.meta_key = %s", + Client::POST_TYPE, + self::USER_META_KEY + ) + ); + + return array_map( 'intval', $user_ids ); } /** @@ -721,12 +784,12 @@ private static function get_tracked_users() { * @return int Number of tokens deleted. */ public static function cleanup_expired() { - $users = self::get_tracked_users(); - $count = 0; + $user_ids = self::get_all_tracked_users(); + $count = 0; - foreach ( $users as $user_id ) { - $all_meta = \get_user_meta( $user_id ); - $user_tokens = 0; + foreach ( $user_ids as $user_id ) { + $all_meta = \get_user_meta( $user_id ); + $client_ids = array(); foreach ( $all_meta as $meta_key => $meta_values ) { if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) { @@ -754,14 +817,16 @@ public static function cleanup_expired() { \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] ); } ++$count; - } else { - ++$user_tokens; + + if ( ! empty( $token_data['client_id'] ) ) { + $client_ids[] = $token_data['client_id']; + } } } - // Untrack user if no more tokens. - if ( 0 === $user_tokens ) { - self::untrack_user( $user_id ); + // Untrack user from clients where all tokens were removed. + foreach ( array_unique( $client_ids ) as $client_id ) { + self::maybe_untrack_user( $user_id, $client_id ); } } From ac626640b60e0ee306ee6167608e881985fe5478 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 25 Feb 2026 09:16:04 +0100 Subject: [PATCH 100/105] Refactor OAuth consent form to use Client model methods Add get_display_name() and get_link_url() to Client class so the template delegates display logic to the model. For DCR apps where client_id is a UUID, the link falls back to client_uri or the redirect URI origin instead of producing a broken link. Consolidate loose request variables into a single $authorize_params array to reduce template scope. --- includes/oauth/class-client.php | 48 +++++++++++++++++++++++++++++++++ includes/oauth/class-server.php | 36 +++++++++++-------------- templates/oauth-authorize.php | 38 +++++++++++++------------- 3 files changed, 82 insertions(+), 40 deletions(-) diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php index b130e324da..90fa6bdc7b 100644 --- a/includes/oauth/class-client.php +++ b/includes/oauth/class-client.php @@ -539,6 +539,17 @@ public function get_name() { return $post ? $post->post_title : ''; } + /** + * Get client display name, falling back to client ID. + * + * @since unreleased + * + * @return string The display name. + */ + public function get_display_name() { + return $this->get_name() ?: $this->get_client_id(); + } + /** * Get client description. * @@ -596,6 +607,43 @@ public function get_client_uri() { return \get_post_meta( $this->post_id, '_activitypub_client_uri', true ) ?: ''; } + /** + * Get a URL suitable for linking to this client. + * + * For CIMD apps the client_id is already a URL. For DCR apps it's a UUID, + * so we fall back to the stored client_uri, then to the first redirect URI's origin. + * + * @since unreleased + * + * @return string A URL for the client, or empty string if none available. + */ + public function get_link_url() { + $client_id = $this->get_client_id(); + + if ( \filter_var( $client_id, FILTER_VALIDATE_URL ) ) { + return $client_id; + } + + $client_uri = $this->get_client_uri(); + + if ( $client_uri ) { + return $client_uri; + } + + $redirect_uris = $this->get_redirect_uris(); + + if ( ! empty( $redirect_uris ) ) { + $scheme = \wp_parse_url( $redirect_uris[0], PHP_URL_SCHEME ); + $host = \wp_parse_url( $redirect_uris[0], PHP_URL_HOST ); + + if ( $scheme && $host ) { + return \trailingslashit( sprintf( '%s://%s', $scheme, $host ) ); + } + } + + return ''; + } + /** * Check if this client was auto-discovered. * diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 3be0f3acc7..e8a49c731b 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -348,16 +348,18 @@ public static function login_form_authorize() { */ private static function render_authorize_form() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Initial form display, nonce checked on POST. - $client_id = isset( $_GET['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_GET['client_id'] ) ) : ''; - $redirect_uri = isset( $_GET['redirect_uri'] ) ? \esc_url_raw( \wp_unslash( $_GET['redirect_uri'] ) ) : ''; - $scope = isset( $_GET['scope'] ) ? \sanitize_text_field( \wp_unslash( $_GET['scope'] ) ) : ''; - $state = isset( $_GET['state'] ) ? \wp_unslash( $_GET['state'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly. - $code_challenge = isset( $_GET['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge'] ) ) : ''; - $code_challenge_method = isset( $_GET['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge_method'] ) ) : 'S256'; + $authorize_params = array( + 'client_id' => isset( $_GET['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_GET['client_id'] ) ) : '', + 'redirect_uri' => isset( $_GET['redirect_uri'] ) ? \esc_url_raw( \wp_unslash( $_GET['redirect_uri'] ) ) : '', + 'scope' => isset( $_GET['scope'] ) ? \sanitize_text_field( \wp_unslash( $_GET['scope'] ) ) : '', + 'state' => isset( $_GET['state'] ) ? \wp_unslash( $_GET['state'] ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly. + 'code_challenge' => isset( $_GET['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge'] ) ) : '', + 'code_challenge_method' => isset( $_GET['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge_method'] ) ) : 'S256', + ); // phpcs:enable WordPress.Security.NonceVerification.Recommended // Validate client. - $client = Client::get( $client_id ); + $client = Client::get( $authorize_params['client_id'] ); if ( \is_wp_error( $client ) ) { \wp_die( \esc_html( $client->get_error_message() ), @@ -367,7 +369,7 @@ private static function render_authorize_form() { } // Validate redirect URI. - if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) { + if ( ! $client->is_valid_redirect_uri( $authorize_params['redirect_uri'] ) ) { \wp_die( \esc_html__( 'Invalid redirect URI for this client.', 'activitypub' ), \esc_html__( 'Authorization Error', 'activitypub' ), @@ -375,28 +377,22 @@ private static function render_authorize_form() { ); } + // Use the canonical client ID (may differ from the raw input for discovered clients). + $authorize_params['client_id'] = $client->get_client_id(); + // These variables are used in the template. $current_user = \wp_get_current_user(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - $scopes = Scope::validate( Scope::parse( $scope ) ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - $client_name = $client->get_name(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $scopes = Scope::validate( Scope::parse( $authorize_params['scope'] ) ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable // Build form action URL. // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable $form_url = \add_query_arg( - array( - 'action' => 'activitypub_authorize', - 'client_id' => $client_id, - 'redirect_uri' => $redirect_uri, - 'scope' => $scope, - 'state' => $state, - 'code_challenge' => $code_challenge, - 'code_challenge_method' => $code_challenge_method, - ), + array_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ), \wp_login_url() ); // Include the template. - include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-authorize.php'; + include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-authorize.php'; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $authorize_params used in template. } /** diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php index 8ae5851cfb..ee320ffe4a 100644 --- a/templates/oauth-authorize.php +++ b/templates/oauth-authorize.php @@ -5,16 +5,11 @@ * @package Activitypub * * Variables available (passed via include from class-server.php): - * @var WP_User $current_user The current logged-in user. - * @var array $scopes Array of requested scopes. - * @var string $client_id The client ID. - * @var string $client_name The client name. - * @var string $redirect_uri The redirect URI. - * @var string $state The state parameter. - * @var string $code_challenge The PKCE code challenge. - * @var string $code_challenge_method The PKCE method. - * @var string $form_url The form action URL. - * @var string $scope The original scope string. + * @var WP_User $current_user The current logged-in user. + * @var array $scopes Array of requested scopes. + * @var Activitypub\OAuth\Client $client The client object. + * @var array $authorize_params OAuth request parameters (client_id, redirect_uri, scope, state, code_challenge, code_challenge_method). + * @var string $form_url The form action URL. */ // phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable -- Variables passed via include. @@ -24,7 +19,7 @@ // Use WordPress login page header. $login_errors = new WP_Error(); -if ( empty( $code_challenge ) ) { +if ( empty( $authorize_params['code_challenge'] ) ) { $login_errors->add( 'pkce_missing', __( 'Warning: This client does not support PKCE. The connection may be less secure.', 'activitypub' ), @@ -34,7 +29,7 @@ login_header( /* translators: %s: Client name */ - sprintf( __( 'Authorize %s', 'activitypub' ), esc_html( $client_name ?: $client_id ) ), + sprintf( __( 'Authorize %s', 'activitypub' ), esc_html( $client->get_display_name() ) ), '', $login_errors ); @@ -45,11 +40,17 @@

get_link_url(); + $client_display = esc_html( $client->get_display_name() ); + $client_label = $client_link_url + ? sprintf( '%s', esc_url( $client_link_url ), $client_display ) + : $client_display; + echo wp_kses( sprintf( /* translators: %s: Client name or ID */ __( '%s wants to access your account.', 'activitypub' ), - '' . esc_html( $client_name ?: $client_id ) . '' + $client_label ), array( 'a' => array( 'href' => array() ) ) ); @@ -100,7 +101,7 @@ sprintf( /* translators: %s: Redirect URI */ __( 'You will be redirected to %s after authorization.', 'activitypub' ), - '' . esc_html( $redirect_uri ) . '' + '' . esc_html( $authorize_params['redirect_uri'] ) . '' ), array( 'code' => array() ) ); @@ -108,12 +109,9 @@

- - - - - - + $param_value ) : ?> + +