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/activitypub.php b/activitypub.php index 6bdfd04589..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,6 +73,7 @@ function rest_init() { if ( is_blog_public() ) { ( new Rest\Nodeinfo_Controller() )->register_routes(); } + ( new Rest\Proxy_Controller() )->register_routes(); } \add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' ); @@ -97,6 +101,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' ) ); @@ -168,3 +173,6 @@ function activation_redirect( $plugin ) { if ( defined( 'WP_CLI' ) && WP_CLI ) { Cli::register(); } + +// 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/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/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. diff --git a/includes/cache/class-media.php b/includes/cache/class-media.php index c4884ff72e..e314f98cde 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. @@ -169,7 +169,7 @@ public static function maybe_cache( $url, $context, $entity_id = null, $options * @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..23655c2dfb 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -9,7 +9,8 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Following; -use Activitypub\Collection\Posts; +use Activitypub\Collection\Remote_Posts; +use Activitypub\OAuth\Client; /** * ActivityPub Class. @@ -86,7 +87,8 @@ 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(); + Client::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-dispatcher.php b/includes/class-dispatcher.php index e612e10b0f..9d39555e58 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. @@ -537,6 +538,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/class-handler.php b/includes/class-handler.php index e5edb51960..c507402ba0 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 unreleased + */ + do_action( 'activitypub_register_outbox_handlers' ); + } } diff --git a/includes/class-migration.php b/includes/class-migration.php index 8e59ae6260..a5c0d2528b 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 ); + } if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { Activitypub::flush_rewrite_rules(); diff --git a/includes/class-post-types.php b/includes/class-post-types.php index 3cbf3f9797..4cc1a3471f 100644 --- a/includes/class-post-types.php +++ b/includes/class-post-types.php @@ -13,8 +13,11 @@ 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; +use Activitypub\OAuth\Token; /** * Post Types class. @@ -30,6 +33,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' ) ); @@ -345,7 +349,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' ), @@ -369,7 +373,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, @@ -379,7 +383,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, @@ -388,7 +392,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', @@ -399,7 +403,7 @@ public static function register_post_post_type() { ); \register_post_meta( - Posts::POST_TYPE, + Remote_Posts::POST_TYPE, '_activitypub_user_id', array( 'type' => 'integer', @@ -456,6 +460,107 @@ 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 type for OAuth clients. + * Note: Tokens are stored in user meta and authorization codes in transients. + */ + public static function register_oauth_post_types() { + // 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, + ) + ); + + \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', + ) + ); + } + /** * Register post meta for ActivityPub supported post types. */ @@ -672,7 +777,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( /** @@ -712,7 +817,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-router.php b/includes/class-router.php index b49934f12f..e51cced7d1 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 ); @@ -63,6 +64,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 ); } @@ -79,13 +87,22 @@ 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' ) ) ) { + $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 ) { + /* + * 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 ); } + return $template; } @@ -96,6 +113,7 @@ public static function render_activitypub_template( $template ) { if ( ! $activitypub_object ) { \status_header( 410 ); } + return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php'; } @@ -153,11 +171,26 @@ 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; } - 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' ) ) { @@ -166,7 +199,7 @@ public static function add_headers() { } } - add_action( + \add_action( 'wp_head', static function () use ( $id ) { echo PHP_EOL . '' . PHP_EOL; 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 9756149afe..d7586830ca 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; @@ -71,7 +75,7 @@ public static function add_comment( $activity ) { 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; } @@ -115,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 ); @@ -137,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; } @@ -331,78 +337,103 @@ 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 ); - $url = object_to_uri( $actor['url'] ?? $actor['id'] ); + if ( ! $actor || is_wp_error( $actor ) ) { + return false; + } - 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 ); - } + $comment_author = null; + if ( ! empty( $actor['name'] ) ) { + $comment_author = $actor['name']; + } elseif ( ! empty( $actor['preferredUsername'] ) ) { + $comment_author = $actor['preferredUsername']; + } - $webfinger = Webfinger::uri_to_acct( $url ); - if ( is_wp_error( $webfinger ) ) { - $webfinger = ''; - } else { - $webfinger = str_replace( 'acct:', '', $webfinger ); + if ( empty( $comment_author ) && \get_option( 'require_name_email' ) ) { + return false; + } + + $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', - ), + 'comment_meta' => array(), ); - // 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']['protocol'] = 'activitypub'; + $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/collection/class-outbox.php b/includes/collection/class-outbox.php index bf96797eaa..22248ca8a3 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -13,6 +13,7 @@ use Activitypub\Webfinger; use function Activitypub\add_to_outbox; +use function Activitypub\object_to_uri; /** * ActivityPub Outbox Collection @@ -62,13 +63,22 @@ class Outbox { */ public static function add( Activity $activity, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) { $actor_type = Actors::get_type_by_id( $user_id ); - $object_id = self::get_object_id( $activity ); - $title = self::get_object_title( $activity->get_object() ); if ( ! $activity->get_actor() ) { $activity->set_actor( Actors::get_by_id( $user_id )->get_id() ); } + $object_id = object_to_uri( self::get_object_id( $activity ) ); + $title = self::get_object_title( $activity->get_object() ); + + if ( ! $object_id || ! \is_string( $object_id ) ) { + return new \WP_Error( + 'activitypub_outbox_invalid_object_id', + \__( 'Unable to determine an object ID for this activity.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + if ( ! \filter_var( $object_id, FILTER_VALIDATE_URL ) ) { $object_id = Webfinger::resolve( $object_id ); } @@ -229,6 +239,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. * @@ -368,6 +409,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 ); @@ -390,7 +441,7 @@ public static function maybe_get_activity( $outbox_item ) { * * @param Activity|Base_Object|string $data The activity object. * - * @return string The object ID. + * @return string|null The object ID. */ private static function get_object_id( $data ) { $object = $data->get_object(); @@ -403,7 +454,11 @@ private static function get_object_id( $data ) { return $object; } - return $data->get_id() ?? $data->get_actor(); + if ( $data->get_id() ) { + return $data->get_id(); + } + + return object_to_uri( $data->get_actor() ); } /** diff --git a/includes/collection/class-posts.php b/includes/collection/class-posts.php index 1eec47d578..1a36ac7831 100644 --- a/includes/collection/class-posts.php +++ b/includes/collection/class-posts.php @@ -7,613 +7,184 @@ 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 Client-to-Server (C2S) outbox. + * + * @see Remote_Posts for federated posts received via Server-to-Server (S2S). */ 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. + * Update a WordPress post from an ActivityPub activity. * - * @param int $id The object ID. + * @since unreleased * - * @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. + * @param \WP_Post $post The post to update. + * @param array $activity The activity data. + * @param string|null $visibility Content visibility. * - * @return \WP_Post|\WP_Error|false|null Post data on success, false or null on failure, or WP_Error if no post to delete. + * @return \WP_Post|\WP_Error The updated post on success, WP_Error on failure. */ - public static function delete_by_guid( $guid ) { - $post = self::get_by_guid( $guid ); - if ( \is_wp_error( $post ) ) { - return $post; - } + public static function update( $post, $activity, $visibility = null ) { + $object = $activity['object'] ?? array(); - return self::delete( $post->ID ); - } + $content = \wp_kses_post( $object['content'] ?? '' ); + $name = \sanitize_text_field( $object['name'] ?? '' ); + $summary = \wp_kses_post( $object['summary'] ?? '' ); - /** - * 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(); + // Process content: autop, autolink, hashtags, and convert to blocks. + $content = self::prepare_content( $content ); - if ( empty( $tags ) || ! \is_array( $tags ) ) { - return $hashtags; + // 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, '...' ); } - 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; - } - } + // Determine visibility if not provided. + if ( null === $visibility ) { + $visibility = get_content_visibility( $activity ); } - 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, - ) + $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..febb33f358 --- /dev/null +++ b/includes/collection/class-remote-posts.php @@ -0,0 +1,623 @@ +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-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-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/functions.php b/includes/functions.php index 8a552a1575..20e9a4e2e6 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. * @@ -316,3 +337,57 @@ 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. + * + * Checks common proxy headers before falling back to REMOTE_ADDR, + * similar to Jetpack's approach. The result can be overridden via + * the `activitypub_client_ip` filter. + * + * @since unreleased + * + * @return string The client IP address. + */ +function get_client_ip() { + // phpcs:disable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders + $ip = 'unknown'; + + $headers = array( + 'HTTP_CF_CONNECTING_IP', // Cloudflare. + 'HTTP_CLIENT_IP', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_FORWARDED', + 'HTTP_X_CLUSTER_CLIENT_IP', + 'HTTP_FORWARDED_FOR', + 'HTTP_FORWARDED', + ); + + foreach ( $headers as $header ) { + if ( ! empty( $_SERVER[ $header ] ) ) { + // Some headers (e.g. X-Forwarded-For) may contain a comma-separated list; use the first IP. + $ip_list = \sanitize_text_field( \wp_unslash( $_SERVER[ $header ] ) ); + $ip = \trim( \explode( ',', $ip_list )[0] ); + + if ( \filter_var( $ip, FILTER_VALIDATE_IP ) ) { + break; + } + + $ip = 'unknown'; + } + } + + if ( 'unknown' === $ip && isset( $_SERVER['REMOTE_ADDR'] ) ) { + $ip = \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); + } + // phpcs:enable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders + + /** + * Filter the client IP address used for rate limiting. + * + * @since unreleased + * + * @param string $ip The detected client IP address. + */ + return \apply_filters( 'activitypub_client_ip', $ip ); +} 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-announce.php b/includes/handler/outbox/class-announce.php new file mode 100644 index 0000000000..4e9ef5f68c --- /dev/null +++ b/includes/handler/outbox/class-announce.php @@ -0,0 +1,49 @@ +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..81ea720e2e --- /dev/null +++ b/includes/handler/outbox/class-delete.php @@ -0,0 +1,130 @@ +user_id !== $user_id && $user_id > 0 ) { + return false; + } + + if ( \wp_trash_comment( $comment ) ) { + return $comment; + } + + 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; + + // Fall back to Posts collection for remote posts (ap_post type). + if ( ! $post instanceof \WP_Post ) { + $post = Remote_Posts::get_by_guid( $object_id ); + } + + if ( ! $post instanceof \WP_Post ) { + return false; + } + + // Verify the user owns this post. + if ( (int) $post->post_author !== $user_id && $user_id > 0 ) { + return false; + } + + if ( \wp_trash_post( $post->ID ) ) { + return $post; + } + + return false; + } +} diff --git a/includes/handler/outbox/class-follow.php b/includes/handler/outbox/class-follow.php new file mode 100644 index 0000000000..86bab9825c --- /dev/null +++ b/includes/handler/outbox/class-follow.php @@ -0,0 +1,44 @@ + 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 ) { + case 'Follow': + $stored = \json_decode( $outbox_item->post_content, true ); + $target = object_to_uri( $stored['object'] ?? '' ); + + if ( $target ) { + return unfollow( $target, $user_id ); + } + + return $data; + + default: + return Outbox_Collection::undo( $outbox_item ); + } + } +} diff --git a/includes/handler/outbox/class-update.php b/includes/handler/outbox/class-update.php new file mode 100644 index 0000000000..1a6c6abc75 --- /dev/null +++ b/includes/handler/outbox/class-update.php @@ -0,0 +1,114 @@ +post_author !== $user_id && $user_id > 0 ) { + return false; + } + + // 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 ) + ); + } + + $post = Posts::update( $post, $activity, $visibility ); + + if ( \is_wp_error( $post ) ) { + return $post; + } + + /** + * 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/model/class-blog.php b/includes/model/class-blog.php index bfc8a46841..9e4631caca 100644 --- a/includes/model/class-blog.php +++ b/includes/model/class-blog.php @@ -397,7 +397,10 @@ public function get_following() { */ public function get_endpoints() { return array( - 'sharedInbox' => get_rest_url_by_path( 'inbox' ), + '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' ), ); } diff --git a/includes/model/class-user.php b/includes/model/class-user.php index a987d5b8fd..8f85ea5532 100644 --- a/includes/model/class-user.php +++ b/includes/model/class-user.php @@ -318,7 +318,10 @@ public function get_featured_tags() { */ public function get_endpoints() { return array( - 'sharedInbox' => get_rest_url_by_path( 'inbox' ), + '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' ), ); } diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php new file mode 100644 index 0000000000..dedcbae2d7 --- /dev/null +++ b/includes/oauth/class-authorization-code.php @@ -0,0 +1,319 @@ +is_valid_redirect_uri( $redirect_uri ) ) { + return new \WP_Error( + 'activitypub_invalid_redirect_uri', + \__( 'Invalid redirect URI for this client.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + /* + * 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 ) ); + + // Generate the code. + $code = self::generate_code(); + $code_hash = self::hash_code( $code ); + $expires_at = time() + self::EXPIRATION; + + // 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 ( ! $stored ) { + return new \WP_Error( + 'activitypub_code_storage_failed', + \__( 'Failed to store authorization code.', 'activitypub' ), + array( 'status' => 500 ) + ); + } + + 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 ) { + $code_hash = self::hash_code( $code ); + $transient = self::TRANSIENT_PREFIX . $code_hash; + $code_data = \get_transient( $transient ); + + if ( false === $code_data ) { + return new \WP_Error( + 'activitypub_invalid_code', + \__( 'Invalid or expired authorization code.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // 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' ), + array( 'status' => 400 ) + ); + } + + // 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. + if ( $code_data['redirect_uri'] !== $redirect_uri ) { + return new \WP_Error( + 'activitypub_redirect_uri_mismatch', + \__( 'Redirect URI does not match.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Verify PKCE. + $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( + 'activitypub_invalid_pkce', + \__( 'Invalid PKCE code verifier.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Create and return the tokens. + return Token::create( + $code_data['user_id'], + $client_id, + $code_data['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 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; + } + + 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 ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PKCE BASE64URL encoding per RFC 7636. + return rtrim( strtr( base64_encode( $hash ), '+/', '-_' ), '=' ); + } + + /** + * Generate a random authorization code. + * + * @return string The authorization code. + */ + public static function generate_code() { + 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. + * + * 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. + * + * @return int Number of codes deleted. + */ + 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(); + + // Find expired timeout rows for this prefix. + $timeout_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} + WHERE option_name LIKE %s + 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 + + // Each transient has 2 rows (value + timeout). + return $count ? (int) ( $count / 2 ) : 0; + } +} diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php new file mode 100644 index 0000000000..90fa6bdc7b --- /dev/null +++ b/includes/oauth/class-client.php @@ -0,0 +1,776 @@ +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 ? \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, + ), + ), + 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. + * + * 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. + */ + 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, + 'post_status' => 'publish', + 'meta_key' => '_activitypub_client_id', + 'meta_value' => $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 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. + * 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 = get_client_ip(); + $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 ) ) { + 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::DEFAULT_SCOPES, + '_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_fetch_failed', + /* translators: %d: HTTP status code */ + sprintf( \__( 'Client metadata returned HTTP %d.', 'activitypub' ), $code ), + array( 'status' => 502 ) + ); + } + + $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 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'] ) ) { + $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']; + } + // Mark as actor-based client for lenient redirect validation. + $metadata['is_actor'] = true; + } + + return $metadata; + } + + /** + * 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 \wp_check_password( $client_secret, $stored_hash ); + } + + /** + * Check if redirect URI is valid for this client. + * + * 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. + * @return bool True if valid. + */ + public function is_valid_redirect_uri( $redirect_uri ) { + $allowed_uris = $this->get_redirect_uris(); + + // If explicit redirect URIs are registered, check for match. + if ( ! empty( $allowed_uris ) ) { + // 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. + * 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; + } + + /** + * 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 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. + * + * @return string The client name. + */ + public function get_name() { + $post = \get_post( $this->post_id ); + 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. + * + * @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; + } + + /** + * 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 ) ?: ''; + } + + /** + * 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. + * + * @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. + * + * @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'] ) { + /* + * 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; + } + } elseif ( 'https' !== $parsed['scheme'] ) { + // Only allow https for production. + return false; + } + + 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. + * + * @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 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-scope.php b/includes/oauth/class-scope.php new file mode 100644 index 0000000000..d91757f1ca --- /dev/null +++ b/includes/oauth/class-scope.php @@ -0,0 +1,188 @@ + '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, + self::WRITE, + ); + + /** + * 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..e8a49c731b --- /dev/null +++ b/includes/oauth/class-server.php @@ -0,0 +1,501 @@ +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() { + /* + * 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 \wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ); + } + + if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { + return \wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ); + } + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + + // 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; + } + } + + 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 ) { + /** + * 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; + } + + if ( ! self::is_oauth_request() ) { + return new \WP_Error( + 'activitypub_oauth_required', + \__( 'OAuth authentication required.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + 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; + } + + /** + * 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(); + } + + /** + * 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 ) { + if ( ! self::route_needs_cors( $request->get_route() ) ) { + return $response; + } + + /* + * 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', 'Accept, Content-Type, Authorization' ); + + if ( $origin ) { + $response->header( 'Access-Control-Allow-Credentials', 'true' ); + $response->header( 'Vary', 'Origin' ); + } + + 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; + + // All ActivityPub endpoints need CORS except the interactive OAuth authorize endpoint. + if ( 0 === strpos( $route, $namespace ) ) { + return false === strpos( $route, $namespace . '/oauth/authorize' ); + } + + return false; + } + + /** + * 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', + '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( 'bearer' ), + '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(); + } + + $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. + $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( $authorize_params['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( $authorize_params['redirect_uri'] ) ) { + \wp_die( + \esc_html__( 'Invalid redirect URI for this client.', 'activitypub' ), + \esc_html__( 'Authorization Error', 'activitypub' ), + array( 'response' => 400 ) + ); + } + + // 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( $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_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ), + \wp_login_url() + ); + + // Include the template. + include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-authorize.php'; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $authorize_params used in template. + } + + /** + * 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'] ) ? \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() ), + \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 ) { + self::redirect_to_client( + $redirect_uri, + array( + 'error' => 'access_denied', + 'error_description' => 'The user denied the authorization request.', + 'state' => $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 ) ) { + self::redirect_to_client( + $redirect_uri, + array( + 'error' => 'server_error', + 'error_description' => $code->get_error_message(), + 'state' => $state, + ) + ); + } + + self::redirect_to_client( + $redirect_uri, + array( + 'code' => $code, + 'state' => $state, + ) + ); + } + + /** + * 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 new file mode 100644 index 0000000000..29734e86d1 --- /dev/null +++ b/includes/oauth/class-token.php @@ -0,0 +1,875 @@ +user_id = $user_id; + $this->token_key = $token_key; + $this->data = $data; + } + + /** + * 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 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, + ); + + // Store in user meta with access token hash as key. + $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( + 'activitypub_token_storage_failed', + \__( 'Failed to store access token.', 'activitypub' ), + array( 'status' => 500 ) + ); + } + + // 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 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 ); + + /* + * 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 ); + } + $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( $token_data['scopes'] ), + 'me' => $me, + ); + } + + /** + * 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 ) { + global $wpdb; + + $token_hash = self::hash_token( $token ); + $meta_key = self::META_PREFIX . $token_hash; + + // 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 + ) + ); + + if ( empty( $user_id ) ) { + return new \WP_Error( + 'activitypub_invalid_token', + \__( 'Invalid access token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + $token_data = \get_user_meta( (int) $user_id, $meta_key, true ); + + if ( empty( $token_data ) || ! is_array( $token_data ) ) { + return new \WP_Error( + 'activitypub_invalid_token', + \__( 'Invalid access token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // 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 ) + ); + } + + // 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 ) + ); + } + + // 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 ); + } + + /** + * 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 ) { + global $wpdb; + + $refresh_hash = self::hash_token( $refresh_token ); + $refresh_index_key = self::REFRESH_INDEX_PREFIX . $refresh_hash; + + // 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 + ) + ); + + if ( empty( $user_id ) ) { + return new \WP_Error( + 'activitypub_invalid_refresh_token', + \__( 'Invalid refresh token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + $user_id = (int) $user_id; + + // 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 ) + ); + } + + // Get the full token data. + $meta_key = self::META_PREFIX . $access_hash; + $token_data = \get_user_meta( $user_id, $meta_key, true ); + + if ( empty( $token_data ) || ! is_array( $token_data ) ) { + return new \WP_Error( + 'activitypub_invalid_refresh_token', + \__( 'Invalid refresh token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // 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 ) + ); + } + + // 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'] ); + } + + /** + * Revoke a token. + * + * @param string $token The token to revoke (access or refresh). + * @return bool True on success (always returns true per RFC 7009). + */ + public static function revoke( $token ) { + global $wpdb; + + $token_hash = self::hash_token( $token ); + + // 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 + ) + ); + + 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 ); + + // 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 ); + } + + self::maybe_untrack_user( $user_id, $client_id ); + return true; + } + + // 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 ( $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, $client_id ); + return true; + } + + // Token doesn't exist or already revoked - that's fine per RFC 7009. + return true; + } + + /** + * Untrack user from a client if they have no remaining tokens for that client. + * + * @param int $user_id The user ID. + * @param string $client_id The OAuth client ID. + */ + private static function maybe_untrack_user( $user_id, $client_id ) { + if ( empty( $client_id ) ) { + return; + } + + // 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 ); + } + + /** + * 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 ) { + $all_meta = \get_user_meta( $user_id ); + $count = 0; + $client_ids = array(); + + foreach ( $all_meta as $meta_key => $meta_values ) { + // 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; + } + // Delete refresh token indices. + if ( 0 === strpos( $meta_key, self::REFRESH_INDEX_PREFIX ) ) { + \delete_user_meta( $user_id, $meta_key ); + } + } + + // Remove user from all client tracking. + foreach ( array_unique( $client_ids ) as $client_id ) { + self::untrack_user( $user_id, $client_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() { + $user_ids = self::get_all_tracked_users(); + $count = 0; + + foreach ( $user_ids as $user_id ) { + $count += self::revoke_all_for_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 ) { + $user_ids = self::get_tracked_users( $client_id ); + $count = 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 ) ) { + continue; + } + + $token_data = \maybe_unserialize( $meta_values[0] ); + + if ( ! is_array( $token_data ) ) { + continue; + } + + // 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. + if ( isset( $token_data['refresh_token_hash'] ) ) { + \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] ); + } + ++$count; + } + } + } + + // Remove all user tracking for this client. + self::untrack_all_users( $client_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; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a DB query, just array key. + $tokens[] = $token_data; + } + } + + return $tokens; + } + + /** + * 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() { + return $this->user_id; + } + + /** + * Get the client ID associated with this token. + * + * @return string The OAuth client ID. + */ + public function get_client_id() { + return $this->data['client_id'] ?? ''; + } + + /** + * Get the scopes for this token. + * + * @return array The granted scopes. + */ + public function get_scopes() { + return $this->data['scopes'] ?? array(); + } + + /** + * Get the expiration timestamp. + * + * @return int Unix timestamp. + */ + public function get_expires_at() { + return $this->data['expires_at'] ?? 0; + } + + /** + * Check if the token is expired. + * + * @return bool True if expired. + */ + 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. + * + * @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 ); + } + + /** + * Track a user as having tokens for a client. + * + * 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, $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 ); + } + } + + /** + * 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 from a specific client. + * + * @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_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 tracked users for a specific client. + * + * @param string $client_id The OAuth client ID. + * @return array User IDs. + */ + 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 ); + } + + /** + * Clean up expired tokens. + * + * Should be called periodically via cron. + * + * @return int Number of tokens deleted. + */ + public static function cleanup_expired() { + $user_ids = self::get_all_tracked_users(); + $count = 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 ) ) { + 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 ); + // 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; + + if ( ! empty( $token_data['client_id'] ) ) { + $client_ids[] = $token_data['client_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 ); + } + } + + 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_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. + */ + $actor = Actors::get_by_id( $user_id ); + 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, + '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) $user_id, + 'me' => $me, + ); + } +} 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 47374a8d73..edc1c575d4 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; @@ -46,7 +48,7 @@ public function register_routes() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => '__return_true', + 'permission_callback' => array( $this, 'verify_authentication' ), 'args' => array( 'page' => array( 'description' => 'Current page of the collection.', @@ -59,6 +61,7 @@ public function register_routes() { 'type' => 'integer', 'default' => 20, 'minimum' => 1, + 'maximum' => 100, ), ), 'schema' => array( $this, 'get_collection_schema' ), @@ -66,7 +69,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.', @@ -111,33 +114,72 @@ public function register_routes() { } /** - * Renders the user-inbox. + * Retrieves a collection of inbox items. * - * @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 \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 ); + + if ( \is_wp_error( $user ) ) { + return $user; + } + + /** + * 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, + ), + ), + ); /** - * Fires before the ActivityPub inbox is created and sent to the client. + * 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 ) ) { @@ -145,9 +187,27 @@ 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 ); + + // Fire deprecated hook for backward compatibility. + \do_action_deprecated( + 'activitypub_inbox_post', + array( $request ), + 'unreleased', + 'activitypub_rest_inbox_post' + ); $response = \rest_ensure_response( $response ); $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); @@ -155,6 +215,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-followers-controller.php b/includes/rest/class-followers-controller.php index b53e53e52b..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; @@ -44,7 +43,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 +91,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.', @@ -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 3d4a438701..e86f2839e9 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.', @@ -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/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index b5c53689f5..2d9f54fad1 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -29,6 +29,7 @@ * @see https://www.w3.org/TR/activitypub/#inbox */ class Inbox_Controller extends \WP_REST_Controller { + use Verification; use Language_Map; /** @@ -56,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.', @@ -156,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 399c25074e..8e56b808bd 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -7,12 +7,17 @@ namespace Activitypub\Rest; +use Activitypub\Activity\Activity; use Activitypub\Activity\Base_Object; use Activitypub\Collection\Actors; 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; +use function Activitypub\object_to_uri; /** * ActivityPub Outbox Controller. @@ -23,6 +28,7 @@ */ class Outbox_Controller extends \WP_REST_Controller { use Collection; + use Verification; /** * The namespace of this controller's route. @@ -56,7 +62,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.', @@ -73,6 +79,11 @@ public function register_routes() { ), ), ), + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'verify_authentication' ), + ), 'schema' => array( $this, 'get_item_schema' ), ) ); @@ -118,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' ), @@ -136,7 +147,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, @@ -190,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; } @@ -199,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; } @@ -273,10 +284,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. @@ -284,6 +299,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', @@ -317,4 +337,288 @@ public function overload_total_items( $response, $request ) { return $response; } + + /** + * Create an item in the outbox. + * + * 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. + */ + public function create_item( $request ) { + $user_id = $request->get_param( 'user_id' ); + $user = Actors::get_by_id( $user_id ); + + if ( \is_wp_error( $user ) ) { + return $user; + } + + $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 ) + ); + } + + // 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 ); + + // If it's a bare object, wrap it in a Create activity. + if ( ! $is_activity ) { + $data = $this->wrap_in_create( $data, $user ); + } + + // Default to public addressing if client omits recipients. + $data = $this->ensure_addressing( $data, $user ); + + // Determine visibility from addressing. + $visibility = $this->determine_visibility( $data ); + + $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. + * + * 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. + * @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; + } + + // 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 ) + ); + } + + $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 ) { + // Handler returned an outbox post ID directly. + $outbox_item = \get_post( $result ); + } else { + // 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_item ) { + return new \WP_Error( + 'activitypub_outbox_error', + \__( 'Failed to add activity to outbox.', 'activitypub' ), + array( 'status' => 500 ) + ); + } + + // Get the stored activity. + $activity = Outbox::get_activity( $outbox_item ); + + 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'] ?? $outbox_item->guid ); + $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_data The object data. + * @param mixed $user The user/actor. + * @return array The wrapped Create activity. + */ + 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_data[ $field ] ) ) { + $addressing[ $field ] = $object_data[ $field ]; + } + } + + return array_merge( + array( + '@context' => Base_Object::JSON_LD_CONTEXT, + 'type' => 'Create', + 'actor' => $user->get_id(), + 'object' => $object_data, + ), + $addressing + ); + } + + /** + * 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; + } + + /** + * 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. + * + * @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; + } + + /** + * Ensure the activity object has required fields. + * + * 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 required fields ensured. + */ + private function ensure_object_id( $data, $user ) { + // Check if there's an embedded object that needs fields. + if ( ! isset( $data['object'] ) || ! is_array( $data['object'] ) ) { + return $data; + } + + $object = &$data['object']; + + // 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/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php new file mode 100644 index 0000000000..0a7036b282 --- /dev/null +++ b/includes/rest/class-proxy-controller.php @@ -0,0 +1,185 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + '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' => array( $this, 'sanitize_url' ), + 'validate_callback' => array( $this, 'validate_url' ), + ), + ), + ), + 'schema' => array( $this, 'get_item_schema' ), + ) + ); + } + + /** + * 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. + * + * 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 validate_url( $url ) { + // Decode the url. + $decoded_url = urldecode( $url ); + + // Must be HTTPS. + 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( $decoded_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 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. + $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; + } + + /** + * 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/rest/class-server.php b/includes/rest/class-server.php index 3309eb5953..1d1c64e29b 100644 --- a/includes/rest/class-server.php +++ b/includes/rest/class-server.php @@ -7,10 +7,6 @@ namespace Activitypub\Rest; -use Activitypub\Signature; - -use function Activitypub\use_authorized_fetch; - /** * ActivityPub Server REST-Class. * @@ -29,59 +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; - } - /** * Callback function to validate incoming ActivityPub requests * @@ -178,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; 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/includes/rest/oauth/class-authorization-controller.php b/includes/rest/oauth/class-authorization-controller.php new file mode 100644 index 0000000000..04114576cd --- /dev/null +++ b/includes/rest/oauth/class-authorization-controller.php @@ -0,0 +1,360 @@ +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 ) { + // Rate-limit authorization requests to prevent abuse (max 20 per minute per IP). + $ip = get_client_ip(); + $transient_key = 'ap_oauth_auth_' . \md5( $ip ); + $count = (int) \get_transient( $transient_key ); + + if ( $count >= 20 ) { + return new \WP_Error( + 'activitypub_rate_limit', + \__( 'Too many authorization requests. Please try again later.', 'activitypub' ), + array( 'status' => 429 ) + ); + } + + \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS ); + + $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..8c7bb6da63 --- /dev/null +++ b/includes/rest/oauth/class-token-controller.php @@ -0,0 +1,377 @@ +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 = get_client_ip(); + $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', + ) + ); + } +} diff --git a/includes/rest/trait-verification.php b/includes/rest/trait-verification.php new file mode 100644 index 0000000000..9b3cd3820f --- /dev/null +++ b/includes/rest/trait-verification.php @@ -0,0 +1,204 @@ +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 Application Passwords authentication. + * + * Uses WordPress core Application Passwords via Basic Auth. + * + * @see https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/ + * + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + 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 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 + * + * 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_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. + $oauth_result = OAuth_Server::check_oauth_permission( $request, $scope ); + if ( true === $oauth_result ) { + return $this->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 ( \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; + } + + return $this->maybe_verify_owner( $request ); + } + + /** + * Verify owner if user_id parameter is present. + * + * @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 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; + } + + // Try OAuth token first. + $token = OAuth_Server::get_current_token(); + 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() === (int) $user_id ) { + return true; + } + + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You can only access your own resources.', 'activitypub' ), + 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 ); + } +} diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index f5d114b708..587e8a9128 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -98,6 +98,14 @@ 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'; diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 9d6806491e..0cbd7e1277 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,171 @@ 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' ) ) ); + } + + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( array( 'message' => \__( 'You do not have permission to perform this action.', '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' ) ) ); + } + + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( array( 'message' => \__( 'You do not have permission to perform this action.', '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' ) ) ); + } + + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( array( 'message' => \__( 'You do not have permission to perform this action.', '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' ) ) ); + } + + if ( ! \current_user_can( 'read' ) ) { + \wp_send_json_error( array( 'message' => \__( 'You do not have permission to perform this action.', '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' ) ) ); + } + + if ( ! \current_user_can( 'read' ) ) { + \wp_send_json_error( array( 'message' => \__( 'You do not have permission to perform this action.', '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..d5b33f7565 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' ) + ); + ?> +
+
+
+ +
+
+
+ +
+ 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->get_display_name() ) ), + '', + $login_errors +); +?> + +
+
+

+ + 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' ), + $client_label + ), + array( + 'a' => array( + 'href' => array(), + 'target' => array(), + ), + ) + ); + ?> + +

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

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

+
+ + +
+

+
    + +
  • + + +
  • + +
+
+ + +
+ ' . esc_html( $authorize_params['redirect_uri'] ) . '' + ), + array( 'code' => array() ) + ); + ?> +
+ + + $param_value ) : ?> + + + +

+ + +

+
+ + { - 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' ); - } - } ); - } - } ); -} ); 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..3cef5eb479 --- /dev/null +++ b/tests/e2e/specs/includes/rest/oauth-controller.test.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'OAuth Controller CORS Headers', () => { + const restRoute = '/index.php?rest_route='; + + test( 'should include CORS headers on outbox endpoint', async ( { request } ) => { + 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( '*' ); + 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' ); + } ); + + test( 'should include CORS headers on inbox endpoint', async ( { request } ) => { + 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, 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( `${ restRoute }/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 include CORS headers on actors endpoint', async ( { request } ) => { + 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( `${ restRoute }/activitypub/1.0/actors/1/followers` ); + + expect( response.status() ).toBe( 200 ); + expect( response.headers()[ 'access-control-allow-origin' ] ).toBe( '*' ); + } ); +} ); 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-dispatcher.php b/tests/phpunit/tests/includes/class-test-dispatcher.php index 0904230266..52d330eb84 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() ); } 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..ad99189fe3 100644 --- a/tests/phpunit/tests/includes/class-test-functions.php +++ b/tests/phpunit/tests/includes/class-test-functions.php @@ -244,6 +244,102 @@ 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() ) ); + } + + /** + * 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. * 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/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', 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-create.php b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php new file mode 100644 index 0000000000..b2693c42cc --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-create.php @@ -0,0 +1,404 @@ +user->create( array( 'role' => 'editor' ) ); + $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( array( 'role' => 'editor' ) ); + $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->assertFalse( $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( array( 'role' => 'editor' ) ); + $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->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 ); + } + + /** + * 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( array( 'role' => 'editor' ) ); + $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( array( 'role' => 'editor' ) ); + $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( array( 'role' => 'editor' ) ); + $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( array( 'role' => 'editor' ) ); + $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 ); + // 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 ); + } + + /** + * 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( array( 'role' => 'editor' ) ); + $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( + 'role' => 'editor', + '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->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 new file mode 100644 index 0000000000..e1e674424b --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-delete.php @@ -0,0 +1,263 @@ +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, + ); + + $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 ); + } + + /** + * 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_handled_delete', $callback ); + + $data = array( + 'type' => 'Delete', + 'object' => $permalink, + ); + + Delete::handle_delete( $data, $this->user_id ); + + $this->assertTrue( $fired, 'activitypub_outbox_handled_delete action should fire.' ); + + \remove_action( 'activitypub_outbox_handled_delete', $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' => '', + ); + + $result = Delete::handle_delete( $data, $this->user_id ); + $this->assertFalse( $result ); + } + + /** + * Test outgoing Delete with non-existent object does nothing. + * + * @covers ::handle_delete + */ + public function test_handle_delete_nonexistent_object() { + $data = array( + 'type' => 'Delete', + 'object' => 'https://example.com/nonexistent-post', + ); + + $result = Delete::handle_delete( $data, $this->user_id ); + $this->assertFalse( $result ); + } + + /** + * 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 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. + * + * @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..eb7d0ecf1e --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-follow.php @@ -0,0 +1,170 @@ +user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + + $user = \get_user_by( 'id', $this->user_id ); + $user->add_cap( 'activitypub' ); + + // Prevent outbox processing from dispatching during tests. + \remove_all_actions( 'activitypub_process_outbox' ); + } + + /** + * Test that handle_follow returns data for empty object. + * + * @covers ::handle_follow + */ + public function test_handle_follow_empty_object() { + $data = array( + 'type' => 'Follow', + 'object' => '', + ); + + $result = Follow::handle_follow( $data, $this->user_id ); + + $this->assertSame( $data, $result, 'Should return original data for empty object.' ); + } + + /** + * Test that handle_follow adds pending following. + * + * @covers ::handle_follow + */ + public function test_handle_follow_adds_pending() { + $actor_url = 'https://example.com/users/testuser'; + + $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, + ); + + $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 ); + + $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 ); + $this->assertNotWPError( $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..aae0f74539 --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-undo.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(); + } + + /** + * Create a fake outbox Follow item and return its GUID. + * + * @param string $target_url The follow target actor URL. + * @return string The outbox item GUID. + */ + 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, + ), + ) + ); + + 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' => 'Test Actor', + 'preferredUsername' => 'testactor', + '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 ); + + \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.' ); + + $data = array( + 'type' => 'Undo', + 'object' => $follow_guid, + ); + + 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.' ); + } + + /** + * 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'; + $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 ); + + // 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' => $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.' ); + } + + /** + * Test that handle_undo returns data for unknown outbox item. + * + * @covers ::handle_undo + */ + public function test_handle_undo_unknown_guid_returns_data() { + $data = array( + 'type' => 'Undo', + 'object' => 'https://example.com/unknown-activity/999', + ); + + $result = Undo::handle_undo( $data, $this->user_id ); + + $this->assertSame( $data, $result, 'Should return original data for unknown GUID.' ); + } + + /** + * Test that handle_undo returns data for empty object. + * + * @covers ::handle_undo + */ + public function test_handle_undo_empty_object() { + $data = array( + 'type' => 'Undo', + 'object' => '', + ); + + $result = Undo::handle_undo( $data, $this->user_id ); + + $this->assertSame( $data, $result, 'Should return original data for empty object.' ); + } + + /** + * Test that handle_undo resolves object from array with id. + * + * @covers ::handle_undo + */ + 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 ); + + \add_post_meta( $remote_actor->ID, Following::FOLLOWING_META_KEY, (string) $this->user_id ); + + // Pass object as array with id (object_to_uri resolves this). + $data = array( + 'type' => 'Undo', + 'object' => array( + 'id' => $follow_guid, + 'type' => 'Follow', + ), + ); + + Undo::handle_undo( $data, $this->user_id ); + + $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.' ); + } + + /** + * 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.' + ); + } +} 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..2643ff7497 --- /dev/null +++ b/tests/phpunit/tests/includes/handler/outbox/class-test-update.php @@ -0,0 +1,255 @@ +user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + } + + /** + * 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', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + '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->assertStringContainsString( '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', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + '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', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + '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', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + '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', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + '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/oauth/class-test-authorization-code.php b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php new file mode 100644 index 0000000000..59a1c87bf2 --- /dev/null +++ b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php @@ -0,0 +1,569 @@ +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. + * + * 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' ) ); + + // 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' ) ); + } + + /** + * 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_client_mismatch', $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 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. + * + * @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..8f0304b5cf --- /dev/null +++ b/tests/phpunit/tests/includes/oauth/class-test-client.php @@ -0,0 +1,510 @@ +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..958c3ccadb --- /dev/null +++ b/tests/phpunit/tests/includes/oauth/class-test-scope.php @@ -0,0 +1,302 @@ +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. + */ + 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..a0574c1601 --- /dev/null +++ b/tests/phpunit/tests/includes/oauth/class-test-token.php @@ -0,0 +1,392 @@ +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_client_mismatch', $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 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. + * + * @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 ); + } +} 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..ada960366c 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 ); + } } /** @@ -142,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 ); } /** @@ -647,4 +656,198 @@ 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' ); + } + } + + /** + * 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.' ); + } + } + + /** + * Test C2S POST with an intransitive activity and object actor payload. + * + * Ensures activities like Arrive do not trigger object-ID resolution errors + * when the actor is provided as an object and no explicit object is present. + * + * @covers ::create_item + */ + public function test_c2s_arrive_with_actor_object() { + $user = \Activitypub\Collection\Actors::get_by_id( self::$user_id ); + + $data = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Arrive', + 'actor' => array( + 'id' => $user->get_id(), + 'name' => $user->get_name(), + 'url' => $user->get_url(), + ), + 'location' => array( + 'id' => 'https://places.pub/relation/659839', + 'name' => 'Ettlingen', + ), + 'content' => 'Arrived.', + 'to' => 'https://www.w3.org/ns/activitystreams#Public', + 'cc' => $user->get_followers(), + ); + + $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(); + $this->assertSame( 'Arrive', $response_data['type'] ); + + $outbox_items = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'any', + 'author' => self::$user_id, + 'posts_per_page' => 1, + 'orderby' => 'ID', + 'order' => 'DESC', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Arrive', + ), + ), + ) + ); + + $this->assertNotEmpty( $outbox_items ); + $this->assertSame( $user->get_id(), \get_post_meta( $outbox_items[0]->ID, '_activitypub_object_id', true ) ); + } } diff --git a/tests/phpunit/tests/includes/rest/class-test-proxy-controller.php b/tests/phpunit/tests/includes/rest/class-test-proxy-controller.php new file mode 100644 index 0000000000..7248fbd5c8 --- /dev/null +++ b/tests/phpunit/tests/includes/rest/class-test-proxy-controller.php @@ -0,0 +1,188 @@ +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; + + \do_action( 'rest_api_init' ); + } + + /** + * 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 ::validate_url + */ + 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( 'rest_invalid_param', $response->get_data()['code'] ); + + $this->unmock_oauth_auth(); + } + + /** + * Test that proxy rejects private network URLs. + * + * Uses wp_http_validate_url() which blocks private IP ranges. + * + * @covers ::validate_url + */ + 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' ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] ); + + $this->unmock_oauth_auth(); + } + + /** + * Test proxy requires authentication. + * + * @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' ) ); + + $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 ::create_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' ); + } +} 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. * 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 ); + } +} 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'] ); } /** 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..ab5253bf83 --- /dev/null +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php @@ -0,0 +1,261 @@ +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..c01d683da3 --- /dev/null +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php @@ -0,0 +1,155 @@ +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..a470f31332 --- /dev/null +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php @@ -0,0 +1,352 @@ +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'] ); + } +}