From eebc615031355e9ceebf83a7cfbdd9df5252a9dc Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 18:50:44 +0100 Subject: [PATCH 1/6] Extract Application from the shared actor pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Application actor is not a real actor — it cannot be followed, addressed, or interacted with. It exists only as a JSON-LD document and a signing identity for outbound HTTP GET requests. This introduces a standalone `Application` utility class with static methods for identity (`get_id()`, `get_url()`, `get_webfinger()`) and key management (`get_key_id()`, `get_public_key()`, `get_private_key()`). - Remove `APPLICATION_USER_ID` (-1) from `Actors` collection - Remove Application cases from `user_can_activitypub()`, Follow handler, Mailer, Outbox, CLI, Health Check, and Stream connector - Make `Application_Controller` delegate to the new `Application` class - Deprecate `Model\Application` (kept for backward compatibility) - Rename option from `activitypub_keypair_for_-1` to `activitypub_application_keypair` with migration - Add self-contained WebFinger discovery via `webfinger_data` filter, handling `acct:`, `/@application`, and REST API URL patterns --- activitypub.php | 1 + includes/class-application.php | 298 ++++++++++++++++++ includes/class-http.php | 4 +- includes/class-mailer.php | 5 - includes/class-migration.php | 14 +- includes/class-query.php | 3 +- includes/class-router.php | 1 + includes/cli/class-actor-command.php | 5 - includes/collection/class-actors.php | 23 -- includes/collection/class-outbox.php | 3 - includes/functions-user.php | 4 - includes/handler/class-follow.php | 5 - includes/model/class-application.php | 26 +- .../rest/class-application-controller.php | 105 +++++- includes/wp-admin/class-health-check.php | 13 +- integration/stream/class-connector.php | 2 - .../tests/includes/class-test-migration.php | 119 +++++-- .../includes/collection/class-test-actors.php | 20 -- .../includes/handler/class-test-follow.php | 11 +- .../class-test-application-controller.php | 62 +++- 20 files changed, 606 insertions(+), 118 deletions(-) create mode 100644 includes/class-application.php diff --git a/activitypub.php b/activitypub.php index 1774583568..911168f703 100644 --- a/activitypub.php +++ b/activitypub.php @@ -78,6 +78,7 @@ function rest_init() { */ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\Application', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Avatars', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Cache', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Comment', 'init' ) ); diff --git a/includes/class-application.php b/includes/class-application.php new file mode 100644 index 0000000000..e8c88d745b --- /dev/null +++ b/includes/class-application.php @@ -0,0 +1,298 @@ + 'sha512', + 'private_key_bits' => 2048, + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, + ); + + $key = \openssl_pkey_new( $config ); + $private_key = null; + $detail = array(); + if ( $key ) { + \openssl_pkey_export( $key, $private_key ); + $detail = \openssl_pkey_get_details( $key ); + } + + // Check if keys are valid. + if ( + empty( $private_key ) || ! is_string( $private_key ) || + ! isset( $detail['key'] ) || ! is_string( $detail['key'] ) + ) { + return array( + 'private_key' => null, + 'public_key' => null, + ); + } + + $key_pair = array( + 'private_key' => $private_key, + 'public_key' => $detail['key'], + ); + + \add_option( self::KEYPAIR_OPTION_KEY, $key_pair ); + + return $key_pair; + } + + /** + * Check if the URI matches the Application actor and return WebFinger data. + * + * Handles the following URI formats: + * - acct:application@example.com / application@example.com + * - http(s)://example.com/@application + * - http(s)://example.com/wp-json/activitypub/1.0/application + * + * @param string $uri The WebFinger resource URI. + * + * @return array|false The WebFinger profile data or false if not the Application. + */ + public static function get_webfinger_data( $uri ) { + if ( ! self::is_application_resource( $uri ) ) { + return false; + } + + $application_id = self::get_id(); + + return array( + 'subject' => sprintf( 'acct:%s', self::get_webfinger() ), + 'aliases' => array( $application_id ), + 'links' => array( + array( + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => $application_id, + 'properties' => array( + 'https://www.w3.org/ns/activitystreams#type' => 'Application', + ), + ), + ), + ); + } + + /** + * Check if a URI refers to the Application actor. + * + * @param string $uri The URI to check. + * + * @return bool True if the URI refers to the Application. + */ + public static function is_application_resource( $uri ) { + $scheme = 'acct'; + $match = array(); + + if ( \preg_match( '/^([a-zA-Z][a-zA-Z0-9+\-.]*):(.*)$/i', $uri, $match ) ) { + $scheme = \strtolower( $match[1] ); + } + + switch ( $scheme ) { + case 'http': + case 'https': + // Check for http(s)://example.com/@application. + $resource_path = \wp_parse_url( $uri, PHP_URL_PATH ); + + if ( $resource_path ) { + $blog_path = \wp_parse_url( \home_url(), PHP_URL_PATH ); + + if ( $blog_path ) { + $resource_path = \str_replace( $blog_path, '', $resource_path ); + } + + $resource_path = \trim( $resource_path, '/' ); + + if ( '@' . self::USERNAME === $resource_path ) { + return true; + } + } + + // Check for the REST API URL. + if ( normalize_url( $uri ) === normalize_url( self::get_id() ) ) { + return true; + } + + return false; + + case 'acct': + default: + $uri_clean = \str_replace( 'acct:', '', $uri ); + $host = home_host(); + + if ( self::USERNAME . '@' . $host === $uri_clean ) { + return true; + } + + // Also check normalized host. + $normalized_host = normalize_host( $host ); + $uri_host = \substr( \strrchr( $uri_clean, '@' ), 1 ); + + if ( ! $uri_host || normalize_host( $uri_host ) !== $normalized_host ) { + return false; + } + + $identifier = \substr( $uri_clean, 0, \strrpos( $uri_clean, '@' ) ); + + return self::USERNAME === $identifier; + } + } + + /** + * Checks for legacy key pair options. + * + * @return array|false The key pair or false. + */ + private static function check_legacy_key_pair() { + $public_key = \get_option( 'activitypub_application_user_public_key' ); + $private_key = \get_option( 'activitypub_application_user_private_key' ); + + if ( ! empty( $public_key ) && is_string( $public_key ) && ! empty( $private_key ) && is_string( $private_key ) ) { + return array( + 'private_key' => $private_key, + 'public_key' => $public_key, + ); + } + + return false; + } +} diff --git a/includes/class-http.php b/includes/class-http.php index 7f99d46ae3..5d14fe2dca 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -166,8 +166,8 @@ public static function get( $url, $args = array(), $cached = false ) { 'Content-Type' => 'application/activity+json', 'Date' => \gmdate( 'D, d M Y H:i:s T' ), ), - 'key_id' => Actors::get_by_id( Actors::APPLICATION_USER_ID )->get_id() . '#main-key', - 'private_key' => Actors::get_private_key( Actors::APPLICATION_USER_ID ), + 'key_id' => Application::get_key_id(), + 'private_key' => Application::get_private_key(), ); $args = \wp_parse_args( $args, $defaults ); diff --git a/includes/class-mailer.php b/includes/class-mailer.php index ad2ba60d6c..f494833745 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -144,11 +144,6 @@ public static function new_follower( $activity, $user_ids, $success ) { // Extract the user ID (follows are always for a single user). $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids; - // Do not send notifications to the Application user. - if ( Actors::APPLICATION_USER_ID === $user_id ) { - return; - } - if ( $user_id > Actors::BLOG_USER_ID ) { if ( ! \get_user_option( 'activitypub_mailer_new_follower', $user_id ) ) { return; diff --git a/includes/class-migration.php b/includes/class-migration.php index 8e59ae6260..6d3b2cc6a6 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -221,6 +221,7 @@ public static function maybe_migrate() { } if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + self::migrate_application_keypair_option(); Activitypub::flush_rewrite_rules(); } @@ -1028,7 +1029,7 @@ public static function remove_pending_application_user_follow_requests() { $wpdb->postmeta, array( 'meta_key' => '_activitypub_following', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - 'meta_value' => Actors::APPLICATION_USER_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'meta_value' => -1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ) ); } @@ -1216,4 +1217,15 @@ public static function migrate_actor_emoji( $batch_size = 50, $offset = 0 ) { return null; } + + /** + * Migrate the Application key pair option from the old name to the new name. + * + * Renames `activitypub_keypair_for_-1` to `activitypub_application_keypair` + * and consolidates the legacy separate key options. + */ + public static function migrate_application_keypair_option() { + wp_cache_flush(); + self::update_options_key( 'activitypub_keypair_for_-1', Application::KEYPAIR_OPTION_KEY ); + } } diff --git a/includes/class-query.php b/includes/class-query.php index 2030db4ddd..800fb31f64 100644 --- a/includes/class-query.php +++ b/includes/class-query.php @@ -227,10 +227,9 @@ public function get_queried_object() { * Get the virtual object. * * Virtual objects are objects that are not stored in the database, but are created on the fly. - * The plugins currently supports two virtual objects: The Blog-Actor and the Application-Actor. + * The plugin currently supports one virtual object: The Blog-Actor. * * @see \Activitypub\Model\Blog - * @see \Activitypub\Model\Application * * @return object|null The virtual object. */ diff --git a/includes/class-router.php b/includes/class-router.php index b49934f12f..b1e946c764 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -63,6 +63,7 @@ public static function add_rewrite_rules() { ); } + \add_rewrite_rule( '^@application\/?$', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/application', 'top' ); \add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' ); \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES ); } diff --git a/includes/cli/class-actor-command.php b/includes/cli/class-actor-command.php index 06477a536a..e48ebfdfab 100644 --- a/includes/cli/class-actor-command.php +++ b/includes/cli/class-actor-command.php @@ -7,7 +7,6 @@ namespace Activitypub\Cli; -use Activitypub\Collection\Actors; use Activitypub\Scheduler\Actor; /** @@ -39,10 +38,6 @@ class Actor_Command extends \WP_CLI_Command { * @param array $assoc_args The associative arguments (unused). */ public function delete( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - if ( Actors::APPLICATION_USER_ID === (int) $args[0] ) { - \WP_CLI::error( 'You cannot delete the application actor.' ); - } - \add_filter( 'activitypub_user_can_activitypub', '__return_true' ); Actor::schedule_user_delete( $args[0] ); \remove_filter( 'activitypub_user_can_activitypub', '__return_true' ); diff --git a/includes/collection/class-actors.php b/includes/collection/class-actors.php index 71deb0ec4b..cfa1c34821 100644 --- a/includes/collection/class-actors.php +++ b/includes/collection/class-actors.php @@ -8,7 +8,6 @@ namespace Activitypub\Collection; use Activitypub\Activity\Actor; -use Activitypub\Model\Application; use Activitypub\Model\Blog; use Activitypub\Model\User; @@ -32,13 +31,6 @@ class Actors { */ const BLOG_USER_ID = 0; - /** - * The ID of the Application User. - * - * @var int - */ - const APPLICATION_USER_ID = -1; - /** * Get the Actor by ID. * @@ -62,8 +54,6 @@ public static function get_by_id( $user_id ) { switch ( $user_id ) { case self::BLOG_USER_ID: return new Blog(); - case self::APPLICATION_USER_ID: - return new Application(); default: return User::from_wp_user( $user_id ); } @@ -120,11 +110,6 @@ public static function get_id_by_username( $username ) { return self::BLOG_USER_ID; } - // Check for application user. - if ( 'application' === $username ) { - return self::APPLICATION_USER_ID; - } - // Check for 'activitypub_username' meta. $user = new \WP_User_Query( array( @@ -421,10 +406,6 @@ static function ( $actor ) { public static function get_type_by_id( $user_id ) { $user_id = (int) $user_id; - if ( self::APPLICATION_USER_ID === $user_id ) { - return 'application'; - } - if ( self::BLOG_USER_ID === $user_id ) { return 'blog'; } @@ -570,10 +551,6 @@ protected static function check_legacy_key_pair( $user_id ) { $public_key = \get_option( 'activitypub_blog_user_public_key' ); $private_key = \get_option( 'activitypub_blog_user_private_key' ); break; - case -1: - $public_key = \get_option( 'activitypub_application_user_public_key' ); - $private_key = \get_option( 'activitypub_application_user_private_key' ); - break; default: $public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true ); $private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true ); diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index c3d7b7d12e..6bba0d1aca 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -306,9 +306,6 @@ public static function get_actor( $outbox_item ) { case 'blog': $actor_id = Actors::BLOG_USER_ID; break; - case 'application': - $actor_id = Actors::APPLICATION_USER_ID; - break; case 'user': default: $actor_id = $outbox_item->post_author; diff --git a/includes/functions-user.php b/includes/functions-user.php index 59632fc5b9..cd36a2b2b3 100644 --- a/includes/functions-user.php +++ b/includes/functions-user.php @@ -108,10 +108,6 @@ function user_can_activitypub( $user_id ) { } switch ( $user_id ) { - case Actors::APPLICATION_USER_ID: - $enabled = true; // Application user is always enabled. - break; - case Actors::BLOG_USER_ID: $enabled = ! is_user_type_disabled( 'blog' ); break; diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php index 3095411875..85c71d3c83 100644 --- a/includes/handler/class-follow.php +++ b/includes/handler/class-follow.php @@ -36,11 +36,6 @@ public static function handle_follow( $activity, $user_ids ) { // Extract the user ID (follow requests are always for a single user). $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids; - if ( Actors::APPLICATION_USER_ID === $user_id ) { - self::queue_reject( $activity, $user_id ); - return; - } - // Check if the actor already follows the user. $already_following = false; $remote_actor = Remote_Actors::get_by_uri( $activity['actor'] ); diff --git a/includes/model/class-application.php b/includes/model/class-application.php index 101b2e162c..cf7128eb04 100644 --- a/includes/model/class-application.php +++ b/includes/model/class-application.php @@ -2,13 +2,16 @@ /** * Application model file. * + * @deprecated Use {@see \Activitypub\Application} for key management + * and {@see \Activitypub\Rest\Application_Controller} for the REST endpoint. + * * @package Activitypub */ namespace Activitypub\Model; use Activitypub\Activity\Actor; -use Activitypub\Collection\Actors; +use Activitypub\Application as Application_Utility; use function Activitypub\get_rest_url_by_path; use function Activitypub\home_host; @@ -16,7 +19,7 @@ /** * Application class. * - * @method int get__id() Gets the internal user ID for the application (always returns APPLICATION_USER_ID). + * @deprecated Use {@see \Activitypub\Application} instead. */ class Application extends Actor { /** @@ -24,7 +27,7 @@ class Application extends Actor { * * @var int */ - protected $_id = Actors::APPLICATION_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore + protected $_id = -1; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore /** * Whether the Application is discoverable. @@ -101,13 +104,20 @@ class Application extends Actor { */ protected $preferred_username = 'application'; + /** + * Constructor. + */ + public function __construct() { + _deprecated_class( __CLASS__, 'unreleased', 'Activitypub\Application' ); + } + /** * Returns the ID of the Application. * * @return string The ID of the Application. */ public function get_id() { - return get_rest_url_by_path( 'application' ); + return Application_Utility::get_id(); } /** @@ -207,7 +217,7 @@ public function get_published() { * @return string The Inbox-Endpoint. */ public function get_inbox() { - return get_rest_url_by_path( sprintf( 'actors/%d/inbox', $this->get__id() ) ); + return get_rest_url_by_path( 'inbox' ); } /** @@ -216,7 +226,7 @@ public function get_inbox() { * @return string The Outbox-Endpoint. */ public function get_outbox() { - return get_rest_url_by_path( sprintf( 'actors/%d/outbox', $this->get__id() ) ); + return get_rest_url_by_path( 'application/outbox' ); } /** @@ -235,9 +245,9 @@ public function get_webfinger() { */ public function get_public_key() { return array( - 'id' => $this->get_id() . '#main-key', + 'id' => Application_Utility::get_key_id(), 'owner' => $this->get_id(), - 'publicKeyPem' => Actors::get_public_key( Actors::APPLICATION_USER_ID ), + 'publicKeyPem' => Application_Utility::get_public_key(), ); } diff --git a/includes/rest/class-application-controller.php b/includes/rest/class-application-controller.php index f577437c9e..d0b2335d4d 100644 --- a/includes/rest/class-application-controller.php +++ b/includes/rest/class-application-controller.php @@ -2,12 +2,22 @@ /** * Application Controller file. * + * Self-contained controller for the ActivityPub Application actor. + * The Application is not a real actor in the plugin's internal sense — + * it cannot be followed, addressed, or interacted with. It exists only as: + * 1. A JSON-LD document at /wp-json/activitypub/1.0/application + * 2. A signing identity for outbound HTTP GET requests + * * @package Activitypub */ namespace Activitypub\Rest; -use Activitypub\Model\Application; +use Activitypub\Activity\Actor; +use Activitypub\Application; + +use function Activitypub\get_rest_url_by_path; +use function Activitypub\home_host; /** * ActivityPub Application Controller. @@ -52,7 +62,41 @@ public function register_routes() { * @return \WP_REST_Response Response object. */ public function get_item( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - $json = ( new Application() )->to_array(); + $id = Application::get_id(); + + $json = array( + '@context' => Actor::JSON_LD_CONTEXT, + 'id' => $id, + 'type' => 'Application', + 'name' => Application::USERNAME, + 'preferredUsername' => Application::USERNAME, + 'summary' => sprintf( + /* translators: %s: Domain of the site */ + \__( 'This is the Application Actor for %s.', 'activitypub' ), + home_host() + ), + 'url' => $id, + 'icon' => self::get_icon(), + 'published' => self::get_published(), + 'inbox' => get_rest_url_by_path( 'inbox' ), + 'outbox' => get_rest_url_by_path( 'application/outbox' ), + 'manuallyApprovesFollowers' => true, + 'discoverable' => false, + 'indexable' => false, + 'invisible' => true, + 'webfinger' => Application::USERNAME . '@' . home_host(), + 'publicKey' => array( + 'id' => Application::get_key_id(), + 'owner' => $id, + 'publicKeyPem' => Application::get_public_key(), + ), + 'implements' => array( + array( + 'href' => 'https://datatracker.ietf.org/doc/html/rfc9421', + 'name' => 'RFC-9421: HTTP Message Signatures', + ), + ), + ); $rest_response = new \WP_REST_Response( $json, 200 ); $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); @@ -60,6 +104,63 @@ public function get_item( $request ) { // phpcs:ignore VariableAnalysis.CodeAnal return $rest_response; } + /** + * Returns the icon for the Application. + * + * @return string[] The icon array with 'type' and 'url'. + */ + private static function get_icon() { + // Try site icon first. + $icon_id = \get_option( 'site_icon' ); + + // Try custom logo second. + if ( ! $icon_id ) { + $icon_id = \get_theme_mod( 'custom_logo' ); + } + + $icon_url = false; + + if ( $icon_id ) { + $icon = \wp_get_attachment_image_src( $icon_id, 'full' ); + if ( $icon ) { + $icon_url = $icon[0]; + } + } + + if ( ! $icon_url ) { + // Fallback to default icon. + $icon_url = \plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE ); + } + + return array( + 'type' => 'Image', + 'url' => \esc_url( $icon_url ), + ); + } + + /** + * Returns the published date. + * + * @return string The published date in RFC3339 format. + */ + private static function get_published() { + $first_post = new \WP_Query( + array( + 'orderby' => 'date', + 'order' => 'ASC', + 'number' => 1, + ) + ); + + if ( ! empty( $first_post->posts[0] ) ) { + $time = \strtotime( $first_post->posts[0]->post_date_gmt ); + } else { + $time = \time(); + } + + return \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $time ); + } + /** * Retrieves the schema for the application endpoint. * diff --git a/includes/wp-admin/class-health-check.php b/includes/wp-admin/class-health-check.php index 27f9108706..28f70a9800 100644 --- a/includes/wp-admin/class-health-check.php +++ b/includes/wp-admin/class-health-check.php @@ -255,7 +255,13 @@ public static function is_author_url_accessible() { * @return boolean|\WP_Error */ public static function is_webfinger_endpoint_accessible() { - $user = Actors::get_by_id( Actors::APPLICATION_USER_ID ); + $user = Actors::get_by_id( \get_current_user_id() ); + if ( \is_wp_error( $user ) ) { + $user = Actors::get_by_id( Actors::BLOG_USER_ID ); + } + if ( \is_wp_error( $user ) ) { + return true; // Skip check if no actor is available. + } $resource = $user->get_webfinger(); $url = Webfinger::resolve( $resource ); @@ -753,9 +759,8 @@ public static function test_rest_api_accessibility() { * @return bool|\WP_Error True if accessible, WP_Error otherwise. */ public static function is_rest_api_accessible() { - // Test the application actor's inbox endpoint (always available). - $actor = Actors::get_by_id( Actors::APPLICATION_USER_ID ); - $url = $actor->get_inbox(); + // Test the shared inbox endpoint (always available). + $url = \Activitypub\get_rest_url_by_path( 'inbox' ); // Make an unauthenticated request. $response = \wp_remote_get( diff --git a/integration/stream/class-connector.php b/integration/stream/class-connector.php index 8331e8834e..1a33cfbe80 100644 --- a/integration/stream/class-connector.php +++ b/integration/stream/class-connector.php @@ -238,8 +238,6 @@ protected function prepare_outbox_data_for_response( $outbox_item ) { $object_title = \get_userdata( $author_id )->display_name; } elseif ( Actors::BLOG_USER_ID === $author_id ) { $object_title = \__( 'Blog User', 'activitypub' ); - } elseif ( Actors::APPLICATION_USER_ID === $author_id ) { - $object_title = \__( 'Application User', 'activitypub' ); } } } diff --git a/tests/phpunit/tests/includes/class-test-migration.php b/tests/phpunit/tests/includes/class-test-migration.php index 4b4438910e..3dbae2e359 100644 --- a/tests/phpunit/tests/includes/class-test-migration.php +++ b/tests/phpunit/tests/includes/class-test-migration.php @@ -8,7 +8,7 @@ namespace Activitypub\Tests; use Activitypub\Activity\Actor; -use Activitypub\Collection\Actors; +use Activitypub\Application; use Activitypub\Collection\Extra_Fields; use Activitypub\Collection\Followers; use Activitypub\Collection\Following; @@ -907,22 +907,22 @@ public function test_remove_pending_application_user_follow_requests() { $post3 = self::factory()->post->create(); // Add _activitypub_following meta with APPLICATION_USER_ID value. - \add_post_meta( $post1, '_activitypub_following', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post2, '_activitypub_following', Actors::APPLICATION_USER_ID ); + \add_post_meta( $post1, '_activitypub_following', -1 ); + \add_post_meta( $post2, '_activitypub_following', -1 ); // Add _activitypub_following meta with different values (should not be removed). \add_post_meta( $post3, '_activitypub_following', '123' ); \add_post_meta( $post1, '_activitypub_following', '456' ); // Add other meta keys (should not be affected). - \add_post_meta( $post1, '_activitypub_other_meta', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post2, 'some_other_meta', Actors::APPLICATION_USER_ID ); + \add_post_meta( $post1, '_activitypub_other_meta', -1 ); + \add_post_meta( $post2, 'some_other_meta', -1 ); // Verify initial state. $initial_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value = %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 2, $initial_count, 'Should have 2 _activitypub_following entries with APPLICATION_USER_ID' ); @@ -930,7 +930,7 @@ public function test_remove_pending_application_user_follow_requests() { $other_following_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value != %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 2, $other_following_count, 'Should have 2 _activitypub_following entries with other values' ); @@ -942,7 +942,7 @@ public function test_remove_pending_application_user_follow_requests() { $remaining_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value = %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 0, $remaining_count, 'All _activitypub_following entries with APPLICATION_USER_ID should be removed' ); @@ -951,14 +951,14 @@ public function test_remove_pending_application_user_follow_requests() { $remaining_other_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value != %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 2, $remaining_other_count, 'Other _activitypub_following entries should remain' ); // Verify other meta keys are unaffected. - $this->assertEquals( Actors::APPLICATION_USER_ID, \get_post_meta( $post1, '_activitypub_other_meta', true ), 'Other meta keys should not be affected' ); - $this->assertEquals( Actors::APPLICATION_USER_ID, \get_post_meta( $post2, 'some_other_meta', true ), 'Other meta keys should not be affected' ); + $this->assertEquals( -1, \get_post_meta( $post1, '_activitypub_other_meta', true ), 'Other meta keys should not be affected' ); + $this->assertEquals( -1, \get_post_meta( $post2, 'some_other_meta', true ), 'Other meta keys should not be affected' ); } /** @@ -978,8 +978,8 @@ public function test_remove_pending_application_user_follow_requests_no_matches( \add_post_meta( $post2, '_activitypub_following', '456' ); // Add other meta keys with APPLICATION_USER_ID. - \add_post_meta( $post1, '_activitypub_other_meta', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post2, 'different_meta', Actors::APPLICATION_USER_ID ); + \add_post_meta( $post1, '_activitypub_other_meta', -1 ); + \add_post_meta( $post2, 'different_meta', -1 ); // Get initial counts. $initial_following_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -1006,8 +1006,8 @@ public function test_remove_pending_application_user_follow_requests_no_matches( // Verify specific entries remain. $this->assertEquals( '123', \get_post_meta( $post1, '_activitypub_following', true ), '_activitypub_following with different value should remain' ); $this->assertEquals( '456', \get_post_meta( $post2, '_activitypub_following', true ), '_activitypub_following with different value should remain' ); - $this->assertEquals( Actors::APPLICATION_USER_ID, \get_post_meta( $post1, '_activitypub_other_meta', true ), 'Other meta keys should not be affected' ); - $this->assertEquals( Actors::APPLICATION_USER_ID, \get_post_meta( $post2, 'different_meta', true ), 'Other meta keys should not be affected' ); + $this->assertEquals( -1, \get_post_meta( $post1, '_activitypub_other_meta', true ), 'Other meta keys should not be affected' ); + $this->assertEquals( -1, \get_post_meta( $post2, 'different_meta', true ), 'Other meta keys should not be affected' ); } /** @@ -1022,9 +1022,9 @@ public function test_remove_pending_application_user_follow_requests_multiple_en $post_id = self::factory()->post->create(); // Add multiple _activitypub_following meta entries with APPLICATION_USER_ID. - \add_post_meta( $post_id, '_activitypub_following', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post_id, '_activitypub_following', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post_id, '_activitypub_following', Actors::APPLICATION_USER_ID ); + \add_post_meta( $post_id, '_activitypub_following', -1 ); + \add_post_meta( $post_id, '_activitypub_following', -1 ); + \add_post_meta( $post_id, '_activitypub_following', -1 ); // Add one with different value. \add_post_meta( $post_id, '_activitypub_following', '789' ); @@ -1033,7 +1033,7 @@ public function test_remove_pending_application_user_follow_requests_multiple_en $initial_app_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value = %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 3, $initial_app_count, 'Should have 3 APPLICATION_USER_ID entries' ); @@ -1045,7 +1045,7 @@ public function test_remove_pending_application_user_follow_requests_multiple_en $remaining_app_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value = %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 0, $remaining_app_count, 'All APPLICATION_USER_ID entries should be removed' ); @@ -1472,4 +1472,83 @@ public function test_migrate_actor_emoji_batching() { $this->assertNotEmpty( $emoji_meta, "Actor {$actor_id} should have emoji meta" ); } } + + /** + * Test migrate_application_keypair_option renames the old option. + * + * @covers ::migrate_application_keypair_option + */ + public function test_migrate_application_keypair_option() { + $key_pair = array( + 'public_key' => 'test-public-key', + 'private_key' => 'test-private-key', + ); + + // Set up the old option name. + \delete_option( Application::KEYPAIR_OPTION_KEY ); + \delete_option( 'activitypub_keypair_for_-1' ); + \add_option( 'activitypub_keypair_for_-1', $key_pair ); + + // Verify old option exists. + $this->assertEquals( $key_pair, \get_option( 'activitypub_keypair_for_-1' ) ); + $this->assertFalse( \get_option( Application::KEYPAIR_OPTION_KEY ) ); + + // Run the migration. + Migration::migrate_application_keypair_option(); + + // Verify option was renamed. + $this->assertFalse( \get_option( 'activitypub_keypair_for_-1' ) ); + $this->assertEquals( $key_pair, \get_option( Application::KEYPAIR_OPTION_KEY ) ); + + // Verify Application class can read the keys. + $this->assertEquals( 'test-public-key', Application::get_public_key() ); + $this->assertEquals( 'test-private-key', Application::get_private_key() ); + + // Clean up. + \delete_option( Application::KEYPAIR_OPTION_KEY ); + } + + /** + * Test migrate_application_keypair_option when old option doesn't exist. + * + * @covers ::migrate_application_keypair_option + */ + public function test_migrate_application_keypair_option_no_old_option() { + // Ensure neither option exists. + \delete_option( 'activitypub_keypair_for_-1' ); + \delete_option( Application::KEYPAIR_OPTION_KEY ); + + // Run the migration — should not error. + Migration::migrate_application_keypair_option(); + + // Both should still not exist. + $this->assertFalse( \get_option( 'activitypub_keypair_for_-1' ) ); + $this->assertFalse( \get_option( Application::KEYPAIR_OPTION_KEY ) ); + } + + /** + * Test migrate_application_keypair_option when new option already exists. + * + * @covers ::migrate_application_keypair_option + */ + public function test_migrate_application_keypair_option_already_migrated() { + $new_key_pair = array( + 'public_key' => 'new-public-key', + 'private_key' => 'new-private-key', + ); + + // Set up the new option (already migrated). + \delete_option( 'activitypub_keypair_for_-1' ); + \delete_option( Application::KEYPAIR_OPTION_KEY ); + \add_option( Application::KEYPAIR_OPTION_KEY, $new_key_pair ); + + // Run the migration. + Migration::migrate_application_keypair_option(); + + // New option should be unchanged. + $this->assertEquals( $new_key_pair, \get_option( Application::KEYPAIR_OPTION_KEY ) ); + + // Clean up. + \delete_option( Application::KEYPAIR_OPTION_KEY ); + } } diff --git a/tests/phpunit/tests/includes/collection/class-test-actors.php b/tests/phpunit/tests/includes/collection/class-test-actors.php index ce496a403d..8f9cde3179 100644 --- a/tests/phpunit/tests/includes/collection/class-test-actors.php +++ b/tests/phpunit/tests/includes/collection/class-test-actors.php @@ -33,12 +33,9 @@ public function tear_down() { parent::tear_down(); \delete_option( 'activitypub_keypair_for_0' ); - \delete_option( 'activitypub_keypair_for_-1' ); \delete_option( 'activitypub_keypair_for_admin' ); \delete_option( 'activitypub_blog_user_public_key' ); \delete_option( 'activitypub_blog_user_private_key' ); - \delete_option( 'activitypub_application_user_public_key' ); - \delete_option( 'activitypub_application_user_private_key' ); \delete_option( 'activitypub_actor_mode' ); \delete_user_meta( 1, 'magic_sig_public_key' ); \delete_user_meta( 1, 'magic_sig_private_key' ); @@ -104,7 +101,6 @@ public function the_resource_provider() { array( 'acct:_@' . $home_host, 'Activitypub\Model\Blog' ), array( 'acct:aksd@' . $home_host, 'WP_Error' ), array( 'admin@' . $home_host, 'Activitypub\Model\User' ), - array( 'acct:application@' . $home_host, 'Activitypub\Model\Application' ), array( $home_url . '/@admin', 'Activitypub\Model\User' ), array( $home_url . '/@blog', 'Activitypub\Model\Blog' ), array( $https_home_url . '/@blog', 'Activitypub\Model\Blog' ), @@ -128,7 +124,6 @@ public function the_resource_provider() { * @covers ::get_type_by_id */ public function test_get_type_by_id() { - $this->assertSame( 'application', Actors::get_type_by_id( Actors::APPLICATION_USER_ID ) ); $this->assertSame( 'blog', Actors::get_type_by_id( Actors::BLOG_USER_ID ) ); $this->assertSame( 'user', Actors::get_type_by_id( 1 ) ); $this->assertSame( 'user', Actors::get_type_by_id( 2 ) ); @@ -186,21 +181,6 @@ public function test_signature_legacy() { $this->assertEquals( $key_pair['public_key'], $public_key ); $this->assertEquals( $key_pair['private_key'], $private_key ); - // Check application user. - \delete_option( 'activitypub_keypair_for_-1' ); - - $public_key = 'public key ' . Actors::APPLICATION_USER_ID; - $private_key = 'private key ' . Actors::APPLICATION_USER_ID; - - \add_option( 'activitypub_application_user_public_key', $public_key ); - \add_option( 'activitypub_application_user_private_key', $private_key ); - - $key_pair = Actors::get_keypair( Actors::APPLICATION_USER_ID ); - - $this->assertNotEmpty( $key_pair ); - $this->assertEquals( $key_pair['public_key'], $public_key ); - $this->assertEquals( $key_pair['private_key'], $private_key ); - // Check blog user. \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); \delete_option( 'activitypub_actor_mode' ); diff --git a/tests/phpunit/tests/includes/handler/class-test-follow.php b/tests/phpunit/tests/includes/handler/class-test-follow.php index ab2de4b296..16b59d72ea 100644 --- a/tests/phpunit/tests/includes/handler/class-test-follow.php +++ b/tests/phpunit/tests/includes/handler/class-test-follow.php @@ -123,21 +123,14 @@ public function test_handle_follow( $target_user_id, $actor_url, $expected_respo */ public function handle_follow_provider() { return array( - 'application_user_follow' => array( - Actors::APPLICATION_USER_ID, - 'https://example.com/actor', - 'Reject', - false, - 'Following application user should be rejected', - ), - 'regular_user_follow' => array( + 'regular_user_follow' => array( 'test_user', 'https://example.com/regular-actor', 'Accept', true, 'Following regular user should be accepted', ), - 'subdomain_actor_follow' => array( + 'subdomain_actor_follow' => array( 'test_user', 'https://social.example.com/users/actor', 'Accept', diff --git a/tests/phpunit/tests/includes/rest/class-test-application-controller.php b/tests/phpunit/tests/includes/rest/class-test-application-controller.php index 1b8cd8e3a6..8deec77031 100644 --- a/tests/phpunit/tests/includes/rest/class-test-application-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-application-controller.php @@ -7,6 +7,9 @@ namespace Activitypub\Tests\Rest; +use Activitypub\Application; +use Activitypub\Rest\Application_Controller; + /** * Tests for Application REST API endpoint. * @@ -71,8 +74,14 @@ public function test_get_item() { // Test property values. $this->assertEquals( 'Application', $data['type'] ); $this->assertStringContainsString( '/activitypub/1.0/application', $data['id'] ); - $this->assertStringContainsString( '/activitypub/1.0/actors/-1/inbox', $data['inbox'] ); - $this->assertStringContainsString( '/activitypub/1.0/actors/-1/outbox', $data['outbox'] ); + $this->assertStringContainsString( '/activitypub/1.0/inbox', $data['inbox'] ); + $this->assertStringContainsString( '/activitypub/1.0/application/outbox', $data['outbox'] ); + + // Test that Application is not discoverable. + $this->assertFalse( $data['discoverable'] ); + $this->assertFalse( $data['indexable'] ); + $this->assertTrue( $data['invisible'] ); + $this->assertTrue( $data['manuallyApprovesFollowers'] ); } /** @@ -85,9 +94,56 @@ public function test_response_matches_schema() { $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/application' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); - $schema = ( new \Activitypub\Rest\Application_Controller() )->get_item_schema(); + $schema = ( new Application_Controller() )->get_item_schema(); $valid = \rest_validate_value_from_schema( $data, $schema ); $this->assertNotWPError( $valid, 'Response failed schema validation: ' . ( \is_wp_error( $valid ) ? $valid->get_error_message() : '' ) ); } + + /** + * Test key management methods. + * + * @covers \Activitypub\Application::get_key_id + * @covers \Activitypub\Application::get_public_key + * @covers \Activitypub\Application::get_private_key + */ + public function test_key_management() { + \delete_option( Application::KEYPAIR_OPTION_KEY ); + + $key_id = Application::get_key_id(); + $public_key = Application::get_public_key(); + $private_key = Application::get_private_key(); + + $this->assertStringContainsString( '#main-key', $key_id ); + $this->assertStringContainsString( '/activitypub/1.0/application', $key_id ); + $this->assertNotEmpty( $public_key ); + $this->assertNotEmpty( $private_key ); + + // Keys should be consistent across calls. + $this->assertEquals( $public_key, Application::get_public_key() ); + $this->assertEquals( $private_key, Application::get_private_key() ); + } + + /** + * Test legacy key pair migration. + * + * @covers \Activitypub\Application::get_public_key + * @covers \Activitypub\Application::get_private_key + */ + public function test_legacy_key_pair() { + \delete_option( Application::KEYPAIR_OPTION_KEY ); + + $public_key = 'legacy-public-key'; + $private_key = 'legacy-private-key'; + + \add_option( 'activitypub_application_user_public_key', $public_key ); + \add_option( 'activitypub_application_user_private_key', $private_key ); + + $this->assertEquals( $public_key, Application::get_public_key() ); + $this->assertEquals( $private_key, Application::get_private_key() ); + + \delete_option( 'activitypub_application_user_public_key' ); + \delete_option( 'activitypub_application_user_private_key' ); + \delete_option( Application::KEYPAIR_OPTION_KEY ); + } } From ce5c411dfcc33034087dbbc7cc9818934fc1316c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 20:55:09 +0100 Subject: [PATCH 2/6] Fix review issues in Application extraction - Add missing backslash prefix on \_deprecated_class() call. - Fix WP_Query parameter from 'number' to 'posts_per_page'. - Guard strtotime() against false when post_date_gmt is empty. - Add 'invisible' property to Application REST schema. - Use pretty URL for Application 'url' field. - Add @since unreleased tags to new Application class. - Add comment explaining @application rewrite rule ordering. --- includes/class-application.php | 24 +++++++++++++++++++ includes/class-router.php | 1 + includes/model/class-application.php | 12 ++++++---- .../rest/class-application-controller.php | 15 ++++++++---- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/includes/class-application.php b/includes/class-application.php index e8c88d745b..61093e4bcd 100644 --- a/includes/class-application.php +++ b/includes/class-application.php @@ -17,10 +17,14 @@ * * This class provides static utility methods for the Application actor, * primarily key management for HTTP Signatures. + * + * @since unreleased */ class Application { /** * Initialize the class, registering WordPress hooks. + * + * @since unreleased */ public static function init() { \add_filter( 'webfinger_data', array( self::class, 'add_webfinger_discovery' ), 0, 2 ); @@ -29,6 +33,8 @@ public static function init() { /** * WebFinger discovery filter callback. * + * @since unreleased + * * @param array $jrd The jrd array. * @param string $uri The WebFinger resource. * @@ -61,6 +67,8 @@ public static function add_webfinger_discovery( $jrd, $uri ) { /** * Returns the Application actor ID (URL). * + * @since unreleased + * * @return string The Application ID. */ public static function get_id() { @@ -70,6 +78,8 @@ public static function get_id() { /** * Returns the pretty URL for the Application actor. * + * @since unreleased + * * @return string The Application URL (e.g. https://example.com/@application). */ public static function get_url() { @@ -79,6 +89,8 @@ public static function get_url() { /** * Returns the WebFinger identifier for the Application. * + * @since unreleased + * * @return string The WebFinger identifier (e.g. application@example.com). */ public static function get_webfinger() { @@ -88,6 +100,8 @@ public static function get_webfinger() { /** * Returns the key ID for HTTP signatures. * + * @since unreleased + * * @return string The key ID. */ public static function get_key_id() { @@ -97,6 +111,8 @@ public static function get_key_id() { /** * Returns the public key PEM for the Application. * + * @since unreleased + * * @return string|null The public key PEM. */ public static function get_public_key() { @@ -107,6 +123,8 @@ public static function get_public_key() { /** * Returns the private key for the Application. * + * @since unreleased + * * @return string|null The private key. */ public static function get_private_key() { @@ -117,6 +135,8 @@ public static function get_private_key() { /** * Returns the key pair for the Application. * + * @since unreleased + * * @return array The key pair with 'public_key' and 'private_key'. */ public static function get_keypair() { @@ -180,6 +200,8 @@ private static function generate_key_pair() { /** * Check if the URI matches the Application actor and return WebFinger data. * + * @since unreleased + * * Handles the following URI formats: * - acct:application@example.com / application@example.com * - http(s)://example.com/@application @@ -215,6 +237,8 @@ public static function get_webfinger_data( $uri ) { /** * Check if a URI refers to the Application actor. * + * @since unreleased + * * @param string $uri The URI to check. * * @return bool True if the URI refers to the Application. diff --git a/includes/class-router.php b/includes/class-router.php index b1e946c764..02c22bc36c 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -63,6 +63,7 @@ public static function add_rewrite_rules() { ); } + // Must precede the generic @username rule, which would otherwise match "application" as an actor username. \add_rewrite_rule( '^@application\/?$', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/application', 'top' ); \add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' ); \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES ); diff --git a/includes/model/class-application.php b/includes/model/class-application.php index cf7128eb04..2293ff2cb2 100644 --- a/includes/model/class-application.php +++ b/includes/model/class-application.php @@ -108,7 +108,7 @@ class Application extends Actor { * Constructor. */ public function __construct() { - _deprecated_class( __CLASS__, 'unreleased', 'Activitypub\Application' ); + \_deprecated_class( __CLASS__, 'unreleased', 'Activitypub\Application' ); } /** @@ -196,15 +196,17 @@ public function get_header_image() { public function get_published() { $first_post = new \WP_Query( array( - 'orderby' => 'date', - 'order' => 'ASC', - 'number' => 1, + 'orderby' => 'date', + 'order' => 'ASC', + 'posts_per_page' => 1, ) ); if ( ! empty( $first_post->posts[0] ) ) { $time = \strtotime( $first_post->posts[0]->post_date_gmt ); - } else { + } + + if ( empty( $time ) ) { $time = \time(); } diff --git a/includes/rest/class-application-controller.php b/includes/rest/class-application-controller.php index d0b2335d4d..7711ecaf04 100644 --- a/includes/rest/class-application-controller.php +++ b/includes/rest/class-application-controller.php @@ -75,7 +75,7 @@ public function get_item( $request ) { // phpcs:ignore VariableAnalysis.CodeAnal \__( 'This is the Application Actor for %s.', 'activitypub' ), home_host() ), - 'url' => $id, + 'url' => Application::get_url(), 'icon' => self::get_icon(), 'published' => self::get_published(), 'inbox' => get_rest_url_by_path( 'inbox' ), @@ -146,15 +146,17 @@ private static function get_icon() { private static function get_published() { $first_post = new \WP_Query( array( - 'orderby' => 'date', - 'order' => 'ASC', - 'number' => 1, + 'orderby' => 'date', + 'order' => 'ASC', + 'posts_per_page' => 1, ) ); if ( ! empty( $first_post->posts[0] ) ) { $time = \strtotime( $first_post->posts[0]->post_date_gmt ); - } else { + } + + if ( empty( $time ) ) { $time = \time(); } @@ -258,6 +260,9 @@ public function get_item_schema() { 'indexable' => array( 'type' => 'boolean', ), + 'invisible' => array( + 'type' => 'boolean', + ), 'implements' => array( 'type' => 'array', 'items' => array( From 7335e3d7d44ca05c5ca21fbf59ef24ecb81271b7 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 16 Feb 2026 20:59:15 +0100 Subject: [PATCH 3/6] Fix remaining review issues - Remove Application user (ID -1) from Following test. - Add backslash prefix to wp_cache_flush() in Migration. - Add backslash prefix to is_string() in Application. --- includes/class-application.php | 2 +- includes/class-migration.php | 2 +- .../collection/class-test-following.php | 17 ----------------- 3 files changed, 2 insertions(+), 19 deletions(-) diff --git a/includes/class-application.php b/includes/class-application.php index 61093e4bcd..ff059ce9c3 100644 --- a/includes/class-application.php +++ b/includes/class-application.php @@ -310,7 +310,7 @@ private static function check_legacy_key_pair() { $public_key = \get_option( 'activitypub_application_user_public_key' ); $private_key = \get_option( 'activitypub_application_user_private_key' ); - if ( ! empty( $public_key ) && is_string( $public_key ) && ! empty( $private_key ) && is_string( $private_key ) ) { + if ( ! empty( $public_key ) && \is_string( $public_key ) && ! empty( $private_key ) && \is_string( $private_key ) ) { return array( 'private_key' => $private_key, 'public_key' => $public_key, diff --git a/includes/class-migration.php b/includes/class-migration.php index 6d3b2cc6a6..2f2934ee2d 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -1225,7 +1225,7 @@ public static function migrate_actor_emoji( $batch_size = 50, $offset = 0 ) { * and consolidates the legacy separate key options. */ public static function migrate_application_keypair_option() { - wp_cache_flush(); + \wp_cache_flush(); self::update_options_key( 'activitypub_keypair_for_-1', Application::KEYPAIR_OPTION_KEY ); } } diff --git a/tests/phpunit/tests/includes/collection/class-test-following.php b/tests/phpunit/tests/includes/collection/class-test-following.php index a26573d866..6b580006e0 100644 --- a/tests/phpunit/tests/includes/collection/class-test-following.php +++ b/tests/phpunit/tests/includes/collection/class-test-following.php @@ -374,13 +374,11 @@ public function test_unfollow() { $outbox_item_2 = \get_post( follow( 'https://example.com/actor/1', $user_ids[1] ) ); $outbox_item_3 = \get_post( follow( 'https://example.com/actor/1', $user_ids[2] ) ); $outbox_item_4 = \get_post( follow( 'https://example.com/actor/1', 0 ) ); - $outbox_item_5 = \get_post( follow( 'https://example.com/actor/1', -1 ) ); \wp_publish_post( $outbox_item_1 ); \wp_publish_post( $outbox_item_2 ); \wp_publish_post( $outbox_item_3 ); \wp_publish_post( $outbox_item_4 ); - \wp_publish_post( $outbox_item_5 ); $accept_1 = array( 'object' => array( @@ -406,28 +404,17 @@ public function test_unfollow() { 'object' => 'https://example.com/actor/1', ), ); - $accept_5 = array( - 'object' => array( - 'id' => $outbox_item_5->guid, - 'object' => 'https://example.com/actor/1', - ), - ); Accept::handle_accept( $accept_1, $user_ids[0] ); Accept::handle_accept( $accept_2, $user_ids[1] ); Accept::handle_accept( $accept_3, $user_ids[2] ); Accept::handle_accept( $accept_4, 0 ); - Accept::handle_accept( $accept_5, -1 ); // User 1 follows https://example.com/actor/1. $following = Following::query( $user_ids[0] ); $this->assertCount( 1, $following['following'] ); $this->assertSame( 1, $following['total'] ); - $following = Following::query( -1 ); - $this->assertCount( 1, $following['following'] ); - $this->assertSame( 1, $following['total'] ); - // User 3 unfollows https://example.com/actor/1. Following::unfollow( Remote_Actors::get_by_uri( 'https://example.com/actor/1' ), $user_ids[2] ); @@ -438,10 +425,6 @@ public function test_unfollow() { $this->assertCount( 0, $following['following'] ); $this->assertSame( 0, $following['total'] ); - $following = Following::query( -1 ); - $this->assertCount( 1, $following['following'] ); - $this->assertSame( 1, $following['total'] ); - // User 1 still follows https://example.com/actor/1. $posts = get_posts( array( From 1c15e31a5abfc3f1b685a64d681a28aad965331e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 17 Feb 2026 14:13:15 +0100 Subject: [PATCH 4/6] Fix Application WebFinger priority and add outbox endpoint Run the Application WebFinger filter at priority 2 (after Integration\Webfinger::add_pseudo_user_discovery at priority 1) so the Application JRD is not overwritten by a WP_Error. Register a /application/outbox route returning an empty OrderedCollection to back the URL already advertised in the Application actor document. --- includes/class-application.php | 6 ++- .../rest/class-application-controller.php | 38 ++++++++++++++ .../class-test-application-controller.php | 21 ++++++++ .../integration/class-test-webfinger.php | 52 +++++++++++++++++-- 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/includes/class-application.php b/includes/class-application.php index ff059ce9c3..07bf07ea82 100644 --- a/includes/class-application.php +++ b/includes/class-application.php @@ -27,7 +27,11 @@ class Application { * @since unreleased */ public static function init() { - \add_filter( 'webfinger_data', array( self::class, 'add_webfinger_discovery' ), 0, 2 ); + /* + * Priority 2: must run after Integration\Webfinger::add_pseudo_user_discovery (priority 1), + * which returns WP_Error for 'application' since it is not in the Actors collection. + */ + \add_filter( 'webfinger_data', array( self::class, 'add_webfinger_discovery' ), 2, 2 ); } /** diff --git a/includes/rest/class-application-controller.php b/includes/rest/class-application-controller.php index 7711ecaf04..fb36b8b79e 100644 --- a/includes/rest/class-application-controller.php +++ b/includes/rest/class-application-controller.php @@ -53,6 +53,18 @@ public function register_routes() { 'schema' => array( $this, 'get_item_schema' ), ) ); + + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/outbox', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_outbox' ), + 'permission_callback' => '__return_true', + ), + ) + ); } /** @@ -104,6 +116,32 @@ public function get_item( $request ) { // phpcs:ignore VariableAnalysis.CodeAnal return $rest_response; } + /** + * Returns an empty outbox collection for the Application actor. + * + * The Application is a signing-only identity and does not publish + * activities, so its outbox is always an empty OrderedCollection. + * + * @since unreleased + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response Response object. + */ + public function get_outbox( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $json = array( + '@context' => Actor::JSON_LD_CONTEXT, + 'id' => get_rest_url_by_path( 'application/outbox' ), + 'type' => 'OrderedCollection', + 'totalItems' => 0, + 'orderedItems' => array(), + ); + + $rest_response = new \WP_REST_Response( $json, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $rest_response; + } + /** * Returns the icon for the Application. * diff --git a/tests/phpunit/tests/includes/rest/class-test-application-controller.php b/tests/phpunit/tests/includes/rest/class-test-application-controller.php index 8deec77031..b1aa1855dc 100644 --- a/tests/phpunit/tests/includes/rest/class-test-application-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-application-controller.php @@ -26,6 +26,7 @@ class Test_Application_Controller extends \Activitypub\Tests\Test_REST_Controlle public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/application', $routes ); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/application/outbox', $routes ); } /** @@ -100,6 +101,26 @@ public function test_response_matches_schema() { $this->assertNotWPError( $valid, 'Response failed schema validation: ' . ( \is_wp_error( $valid ) ? $valid->get_error_message() : '' ) ); } + /** + * Test get_outbox response. + * + * @covers ::get_outbox + */ + public function test_get_outbox() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/application/outbox' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertStringContainsString( 'application/activity+json', $response->get_headers()['Content-Type'] ); + + $data = $response->get_data(); + $this->assertEquals( 'OrderedCollection', $data['type'] ); + $this->assertEquals( 0, $data['totalItems'] ); + $this->assertIsArray( $data['orderedItems'] ); + $this->assertEmpty( $data['orderedItems'] ); + $this->assertStringContainsString( '/activitypub/1.0/application/outbox', $data['id'] ); + } + /** * Test key management methods. * diff --git a/tests/phpunit/tests/integration/class-test-webfinger.php b/tests/phpunit/tests/integration/class-test-webfinger.php index 3158bfa80f..96a7463b23 100644 --- a/tests/phpunit/tests/integration/class-test-webfinger.php +++ b/tests/phpunit/tests/integration/class-test-webfinger.php @@ -7,6 +7,7 @@ namespace Activitypub\Tests\Integration; +use Activitypub\Application; use Activitypub\Collection\Actors; use Activitypub\Integration\Webfinger; @@ -59,10 +60,11 @@ public static function wpSetUpBeforeClass( $factory ) { */ public function tear_down() { // Remove filters that may have been added during tests. - remove_filter( 'webfinger_user_data', array( Webfinger::class, 'add_user_discovery' ), 1 ); - remove_filter( 'webfinger_data', array( Webfinger::class, 'add_pseudo_user_discovery' ), 1 ); - remove_filter( 'webfinger_user_data', array( Webfinger::class, 'add_interaction_links' ), 1 ); - remove_filter( 'webfinger_data', array( Webfinger::class, 'add_interaction_links' ), 1 ); + \remove_filter( 'webfinger_user_data', array( Webfinger::class, 'add_user_discovery' ), 1 ); + \remove_filter( 'webfinger_data', array( Webfinger::class, 'add_pseudo_user_discovery' ), 1 ); + \remove_filter( 'webfinger_user_data', array( Webfinger::class, 'add_interaction_links' ), 1 ); + \remove_filter( 'webfinger_data', array( Webfinger::class, 'add_interaction_links' ), 1 ); + \remove_filter( 'webfinger_data', array( Application::class, 'add_webfinger_discovery' ), 2 ); parent::tear_down(); } @@ -297,6 +299,48 @@ public function test_add_interaction_links_templates() { } } + /** + * Test that Application WebFinger filter overrides the WP_Error from + * add_pseudo_user_discovery for the application resource. + * + * Integration\Webfinger::add_pseudo_user_discovery (priority 1) returns + * WP_Error for 'application' because it is not in the Actors collection. + * Application::add_webfinger_discovery (priority 2) must run afterward + * and replace the error with valid JRD data. + * + * @covers \Activitypub\Application::add_webfinger_discovery + */ + public function test_application_webfinger_overrides_pseudo_user_error() { + // Register both filters as they would be in production. + Webfinger::init(); + Application::init(); + + $uri = 'acct:' . Application::USERNAME . '@' . \wp_parse_url( \home_url(), PHP_URL_HOST ); + $jrd = array( + 'subject' => $uri, + 'aliases' => array(), + 'links' => array(), + ); + + $result = \apply_filters( 'webfinger_data', $jrd, $uri ); + + // Must be an array (not WP_Error) with the correct subject. + $this->assertIsArray( $result, 'Application WebFinger should return valid JRD, not WP_Error.' ); + $this->assertArrayHasKey( 'subject', $result ); + $this->assertStringContainsString( Application::USERNAME, $result['subject'] ); + + // Must have an ActivityPub self link pointing to the Application ID. + $self_link = null; + foreach ( $result['links'] as $link ) { + if ( 'self' === $link['rel'] && 'application/activity+json' === $link['type'] ) { + $self_link = $link; + break; + } + } + $this->assertNotNull( $self_link, 'Should have ActivityPub self link.' ); + $this->assertEquals( Application::get_id(), $self_link['href'] ); + } + /** * Test that methods are static. * From 67b3ed82d377c0b5f950d10e51ad1a7b4340b458 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 17 Feb 2026 14:43:32 +0100 Subject: [PATCH 5/6] Fix review issues: PHPCS, race condition, health check, missing tags - Backslash-prefix `is_string()` calls in Application::generate_key_pair(). - Use `update_option()` instead of `add_option()` to prevent keypair race. - Use Application::get_webfinger() in health check so it works regardless of actor mode or authentication context. - Add `@since unreleased` to generate_key_pair(), check_legacy_key_pair(), and Application_Controller::get_item(). - Add negative test for `acct:application@host` resolving to WP_Error in the Actors collection. --- includes/class-application.php | 10 +++++++--- includes/rest/class-application-controller.php | 2 ++ includes/wp-admin/class-health-check.php | 10 ++-------- .../tests/includes/collection/class-test-actors.php | 1 + 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/includes/class-application.php b/includes/class-application.php index 07bf07ea82..436c293c55 100644 --- a/includes/class-application.php +++ b/includes/class-application.php @@ -163,6 +163,8 @@ public static function get_keypair() { /** * Generates a new key pair for the Application. * + * @since unreleased + * * @return array The key pair with 'public_key' and 'private_key'. */ private static function generate_key_pair() { @@ -182,8 +184,8 @@ private static function generate_key_pair() { // Check if keys are valid. if ( - empty( $private_key ) || ! is_string( $private_key ) || - ! isset( $detail['key'] ) || ! is_string( $detail['key'] ) + empty( $private_key ) || ! \is_string( $private_key ) || + ! isset( $detail['key'] ) || ! \is_string( $detail['key'] ) ) { return array( 'private_key' => null, @@ -196,7 +198,7 @@ private static function generate_key_pair() { 'public_key' => $detail['key'], ); - \add_option( self::KEYPAIR_OPTION_KEY, $key_pair ); + \update_option( self::KEYPAIR_OPTION_KEY, $key_pair ); return $key_pair; } @@ -308,6 +310,8 @@ public static function is_application_resource( $uri ) { /** * Checks for legacy key pair options. * + * @since unreleased + * * @return array|false The key pair or false. */ private static function check_legacy_key_pair() { diff --git a/includes/rest/class-application-controller.php b/includes/rest/class-application-controller.php index fb36b8b79e..9c4901f5b0 100644 --- a/includes/rest/class-application-controller.php +++ b/includes/rest/class-application-controller.php @@ -70,6 +70,8 @@ public function register_routes() { /** * Retrieves the application actor profile. * + * @since unreleased + * * @param \WP_REST_Request $request The request object. * @return \WP_REST_Response Response object. */ diff --git a/includes/wp-admin/class-health-check.php b/includes/wp-admin/class-health-check.php index 28f70a9800..a19b0a9b7b 100644 --- a/includes/wp-admin/class-health-check.php +++ b/includes/wp-admin/class-health-check.php @@ -7,6 +7,7 @@ namespace Activitypub\WP_Admin; +use Activitypub\Application; use Activitypub\Collection\Actors; use Activitypub\Http; use Activitypub\Sanitize; @@ -255,14 +256,7 @@ public static function is_author_url_accessible() { * @return boolean|\WP_Error */ public static function is_webfinger_endpoint_accessible() { - $user = Actors::get_by_id( \get_current_user_id() ); - if ( \is_wp_error( $user ) ) { - $user = Actors::get_by_id( Actors::BLOG_USER_ID ); - } - if ( \is_wp_error( $user ) ) { - return true; // Skip check if no actor is available. - } - $resource = $user->get_webfinger(); + $resource = Application::get_webfinger(); $url = Webfinger::resolve( $resource ); if ( \is_wp_error( $url ) ) { diff --git a/tests/phpunit/tests/includes/collection/class-test-actors.php b/tests/phpunit/tests/includes/collection/class-test-actors.php index 8f9cde3179..f30afa520d 100644 --- a/tests/phpunit/tests/includes/collection/class-test-actors.php +++ b/tests/phpunit/tests/includes/collection/class-test-actors.php @@ -100,6 +100,7 @@ public function the_resource_provider() { array( 'acct:*@' . $home_host, 'Activitypub\Model\Blog' ), array( 'acct:_@' . $home_host, 'Activitypub\Model\Blog' ), array( 'acct:aksd@' . $home_host, 'WP_Error' ), + array( 'acct:application@' . $home_host, 'WP_Error' ), array( 'admin@' . $home_host, 'Activitypub\Model\User' ), array( $home_url . '/@admin', 'Activitypub\Model\User' ), array( $home_url . '/@blog', 'Activitypub\Model\Blog' ), From da9e67f708056b529cf7d7c446d6e0127ac50585 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 17 Feb 2026 15:13:49 +0100 Subject: [PATCH 6/6] Fix REST probe endpoint, PHP 8 substr safety, migration docblock - Probe the Application endpoint (GET-readable) instead of the shared inbox (POST-only) in is_rest_api_accessible() health check. - Guard strrchr() return in is_application_resource() to avoid passing false to substr() (PHP 8 deprecation). - Fix migration docblock: clarify that legacy separate key options are migrated lazily, not by this function. Add missing @since tag. --- includes/class-application.php | 3 ++- includes/class-migration.php | 7 +++++-- includes/wp-admin/class-health-check.php | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/includes/class-application.php b/includes/class-application.php index 436c293c55..d2b6561a8b 100644 --- a/includes/class-application.php +++ b/includes/class-application.php @@ -295,7 +295,8 @@ public static function is_application_resource( $uri ) { // Also check normalized host. $normalized_host = normalize_host( $host ); - $uri_host = \substr( \strrchr( $uri_clean, '@' ), 1 ); + $uri_host = \strrchr( $uri_clean, '@' ); + $uri_host = false !== $uri_host ? \substr( $uri_host, 1 ) : false; if ( ! $uri_host || normalize_host( $uri_host ) !== $normalized_host ) { return false; diff --git a/includes/class-migration.php b/includes/class-migration.php index 2f2934ee2d..0d077a655b 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -1221,8 +1221,11 @@ public static function migrate_actor_emoji( $batch_size = 50, $offset = 0 ) { /** * Migrate the Application key pair option from the old name to the new name. * - * Renames `activitypub_keypair_for_-1` to `activitypub_application_keypair` - * and consolidates the legacy separate key options. + * Renames `activitypub_keypair_for_-1` to `activitypub_application_keypair`. + * Older separate key options (activitypub_application_user_public_key / + * activitypub_application_user_private_key) are migrated lazily on first read. + * + * @since unreleased */ public static function migrate_application_keypair_option() { \wp_cache_flush(); diff --git a/includes/wp-admin/class-health-check.php b/includes/wp-admin/class-health-check.php index a19b0a9b7b..e65cb54ccc 100644 --- a/includes/wp-admin/class-health-check.php +++ b/includes/wp-admin/class-health-check.php @@ -753,8 +753,8 @@ public static function test_rest_api_accessibility() { * @return bool|\WP_Error True if accessible, WP_Error otherwise. */ public static function is_rest_api_accessible() { - // Test the shared inbox endpoint (always available). - $url = \Activitypub\get_rest_url_by_path( 'inbox' ); + // Test the Application endpoint (always available, publicly readable via GET). + $url = \Activitypub\get_rest_url_by_path( 'application' ); // Make an unauthenticated request. $response = \wp_remote_get(