get_col( $wpdb->prepare( "SELECT DISTINCT inbox.meta_value FROM {$wpdb->postmeta} inbox INNER JOIN {$wpdb->posts} actor ON actor.ID = inbox.post_id WHERE actor.post_type = %s AND actor.post_status = %s AND inbox.meta_key = '_activitypub_inbox' AND inbox.meta_value <> '' ORDER BY inbox.meta_value", self::POST_TYPE, 'publish' ) ); $inboxes = \array_filter( $results ); \wp_cache_set( self::CACHE_KEY_INBOXES, $inboxes, 'activitypub' ); return $inboxes; } /** * Get an Remote Actor 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 ) { $post = \get_post( $id ); if ( $post && self::POST_TYPE === $post->post_type ) { return $post; } return null; } /** * Upsert (insert or update) a remote actor as a custom post type. * * The actor is looked up and stored under its own `id`. Callers that obtain * the actor from an untrusted fetch MUST fetch it via {@see Proxy::get()}, * which self-confirms the document is served under its own id, so a document * claiming another actor's id can never reach this method. * * @param array|Actor $actor ActivityPub actor object (array or actor, must include 'id'). * * @return int|\WP_Error Post ID on success, WP_Error on failure. */ public static function upsert( $actor ) { if ( \is_array( $actor ) ) { $actor = Actor::init_from_array( $actor ); } $post = self::get_by_uri( $actor->get_id() ); if ( ! \is_wp_error( $post ) ) { return self::update( $post, $actor ); } return self::create( $actor ); } /** * Create a remote actor as a custom post type. * * @param array|Actor $actor ActivityPub actor object (array or Actor, must include 'id'). * * @return int|\WP_Error Post ID on success, WP_Error on failure. */ public static function create( $actor ) { $args = self::prepare_custom_post_type( $actor ); if ( \is_wp_error( $args ) ) { return $args; } return self::persist( $args ); } /** * Update a remote Actor object by actor URL (guid). * * @param int|\WP_Post $post The post ID or object. * @param array|Actor $actor The ActivityPub actor object as associative array (must include 'id'). * * @return int|\WP_Error The post ID or WP_Error. */ public static function update( $post, $actor ) { $post = \get_post( $post, ARRAY_A ); if ( ! $post ) { return new \WP_Error( 'activitypub_actor_not_found', \__( 'Actor not found', 'activitypub' ), array( 'status' => 404 ) ); } $args = self::prepare_custom_post_type( $actor ); if ( \is_wp_error( $args ) ) { return $args; } return self::persist( \wp_parse_args( $args, $post ) ); } /** * Persist prepared remote actor post data. * * @since 9.4.0 * * @param array $args Prepared post data. An ID indicates an update. * * @return int|\WP_Error Post ID on success, WP_Error on failure. */ private static function persist( $args ) { $is_update = ! empty( $args['ID'] ); $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $has_kses ) { // Prevent KSES from corrupting JSON in post_content. \kses_remove_filters(); } $post_id = $is_update ? \wp_update_post( $args ) : \wp_insert_post( $args ); if ( $has_kses ) { // Restore KSES filters. \kses_init_filters(); } if ( $post_id && ! \is_wp_error( $post_id ) ) { self::clear_inbox_caches( self::get_follower_ids( $post_id ) ); } return $post_id; } /** * Delete a remote actor object by actor URL (guid). * * @param int $post_id The post ID. * * @return bool True on success, false on failure. */ public static function delete( $post_id ) { // Read the followers before the delete takes the meta with it. $user_ids = self::get_follower_ids( $post_id ); $result = \wp_delete_post( $post_id ); // Clear after the row is gone, which narrows the window in which a concurrent read re-caches the deleted inbox. self::clear_inbox_caches( $user_ids ); return $result; } /** * Get the IDs of the local users a remote actor follows. * * @since 9.4.0 * * @param int $post_id The remote actor post ID. * * @return int[] The user IDs. */ private static function get_follower_ids( $post_id ) { $user_ids = \get_post_meta( $post_id, Followers::FOLLOWER_META_KEY, false ); if ( ! \is_array( $user_ids ) ) { return array(); } return \array_unique( \array_map( 'intval', $user_ids ) ); } /** * Clear cached inbox lists affected by a remote actor change. * * Every write to an actor post or its follower meta has to end up here; a bare * `wp_delete_post()` or `add_post_meta()` elsewhere leaves a stale inbox list behind. * * @since 9.4.0 * * @param int[] $user_ids The local users whose follower inbox lists include the actor. * * @return void */ private static function clear_inbox_caches( $user_ids ) { $keys = array( self::CACHE_KEY_INBOXES ); foreach ( $user_ids as $user_id ) { $keys[] = \sprintf( Followers::CACHE_KEY_INBOXES, $user_id ); } // One round trip, however many followers the actor has, because a persistent cache charges per call. \wp_cache_delete_multiple( $keys, 'activitypub' ); } /** * Get a remote actor post by actor URI (guid). * * @param string $actor_uri The actor URI. * * @return \WP_Post|\WP_Error Post object or WP_Error if not found. */ public static function get_by_uri( $actor_uri ) { 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", // What `pre_post_guid` stores on this WordPress version; prepare() handles the SQL escaping. \wp_unslash( \sanitize_post_field( 'guid', \esc_url_raw( $actor_uri ), 0, 'db' ) ), self::POST_TYPE ) ); if ( ! $post_id ) { return new \WP_Error( 'activitypub_actor_not_found', \__( 'Actor not found', 'activitypub' ), array( 'status' => 404 ) ); } $post = \get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return new \WP_Error( 'activitypub_actor_not_found', \__( 'Actor not found', 'activitypub' ), array( 'status' => 404 ) ); } return $post; } /** * Search cached remote actors by name, handle, or actor URI. * * Backs the actor autocomplete endpoint. Matches the search term against the stored post title * (the actor's name or preferred username) and the webfinger handle (user@host), newest first. * The actor URI (guid) is matched only when the query itself looks like a URL: every URI shares * the same scheme and host shape, so matching it for a short term would return nearly every * cached actor. * * @since 9.1.0 * * @param string $query The search term. * @param int $number Optional. Maximum number of actors to return. Default 10. * * @return \WP_Post[] The matching remote actor posts. */ public static function search( $query, $number = 10 ) { global $wpdb; $like = '%' . $wpdb->esc_like( $query ) . '%'; $conditions = 'p.post_title LIKE %s OR acct.meta_value LIKE %s'; $params = array( self::POST_TYPE, $like, $like ); // Match the actor URI only for URL-like queries, so short terms don't match every https:// guid. if ( \str_contains( $query, '://' ) ) { $conditions .= ' OR p.guid LIKE %s'; $params[] = $like; } $params[] = $number; /* * Select full rows so get_post() hydrates each in place instead of re-querying per ID. The * interpolated $conditions is built from the literal fragments above, not from user input, * and the placeholder count is dynamic because the guid clause is optional. */ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $posts = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT p.* FROM $wpdb->posts p LEFT JOIN $wpdb->postmeta acct ON acct.post_id = p.ID AND acct.meta_key = '_activitypub_acct' WHERE p.post_type = %s AND p.post_status = 'publish' AND ( $conditions ) ORDER BY p.ID DESC LIMIT %d", $params ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber return \array_map( '\get_post', $posts ); } /** * Look up which of the given URIs already exist as cached remote actors. * * Single batched query (chunked at 200 placeholders to stay well within * common DB limits). Use this instead of looping over `get_by_uri()` when * a caller only needs to know which URIs are known — e.g. the inbox * recipient resolver, where a flood of unknown recipients would otherwise * trigger one DB query per recipient. * * @since 8.2.1 * * @param string[] $uris Candidate actor URIs. * * @return array Map of URIs that exist, keyed for O(1) lookup. */ public static function get_existing_uris( $uris ) { if ( empty( $uris ) ) { return array(); } global $wpdb; $existing = array(); foreach ( \array_chunk( \array_values( \array_unique( $uris ) ), 200 ) as $chunk ) { // Map each stored spelling back to the URI the caller asked for, the same value get_by_uri() matches. $guids = array(); foreach ( $chunk as $uri ) { $guids[ \wp_unslash( \sanitize_post_field( 'guid', \esc_url_raw( $uri ), 0, 'db' ) ) ] = $uri; } $placeholders = \implode( ', ', \array_fill( 0, \count( $guids ), '%s' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $found = $wpdb->get_col( $wpdb->prepare( "SELECT guid FROM $wpdb->posts WHERE post_type = %s AND guid IN ( $placeholders )", \array_merge( array( self::POST_TYPE ), \array_map( 'strval', \array_keys( $guids ) ) ) ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared foreach ( $found as $guid ) { if ( isset( $guids[ $guid ] ) ) { $existing[ $guids[ $guid ] ] = true; } } } return $existing; } /** * Fetch a remote actor post by either actor URI or acct, fetching from remote if not found locally. * * @param string $uri_or_acct The actor URI or acct identifier. * * @return \WP_Post|\WP_Error Post object or WP_Error if not found. */ public static function fetch_by_various( $uri_or_acct ) { if ( \filter_var( $uri_or_acct, FILTER_VALIDATE_URL ) ) { return self::fetch_by_uri( $uri_or_acct ); } if ( Webfinger::is_acct( $uri_or_acct ) ) { return self::fetch_by_acct( $uri_or_acct ); } return new \WP_Error( 'activitypub_invalid_actor_identifier', 'The actor identifier is not supported', array( 'status' => 400 ) ); } /** * Lookup a remote actor post by actor URI (guid), fetching from remote if not found locally. * * @param string $actor_uri The actor URI. * * @return \WP_Post|\WP_Error Post object or WP_Error if not found. */ public static function fetch_by_uri( $actor_uri ) { $post = self::get_by_uri( $actor_uri ); if ( ! \is_wp_error( $post ) ) { return $post; } // Proxy::get() self-confirms the actor is served under its own id, so it is safe to cache. $object = Proxy::get( $actor_uri, array( 'cached' => false ) ); if ( \is_wp_error( $object ) ) { return $object; } if ( ! is_actor( $object ) ) { return new \WP_Error( 'activitypub_no_actor', \__( 'Object is not an Actor', 'activitypub' ), array( 'status' => 400 ) ); } $post_id = self::upsert( $object ); if ( \is_wp_error( $post_id ) ) { return $post_id; } $post = \get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return new \WP_Error( 'activitypub_actor_not_found', \__( 'Actor not found', 'activitypub' ), array( 'status' => 404 ) ); } return $post; } /** * Fetch a remote actor post by acct, fetching from remote if not found locally. * * @param string $acct The acct identifier. * * @return \WP_Post|\WP_Error Post object or WP_Error if not found. */ public static function fetch_by_acct( $acct ) { $acct = Sanitize::webfinger( $acct ); // Check local DB for acct post meta. global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $post_id = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key='_activitypub_acct' AND meta_value=%s", $acct ) ); if ( $post_id ) { $post = \get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return new \WP_Error( 'activitypub_actor_not_found', \__( 'Actor not found', 'activitypub' ), array( 'status' => 404 ) ); } return $post; } $profile_uri = Webfinger::resolve( $acct ); if ( \is_wp_error( $profile_uri ) ) { return $profile_uri; } $post = self::fetch_by_uri( $profile_uri ); if ( ! \is_wp_error( $post ) ) { \update_post_meta( $post->ID, '_activitypub_acct', $acct ); } return $post; } /** * Store an error that occurred when sending an ActivityPub message to a follower. * * The error will be stored in post meta. * * @param int $post_id The ID of the WordPress Custom-Post-Type. * @param string|\WP_Error $error The error message. * * @return int|false The meta ID on success, false on failure. */ public static function add_error( $post_id, $error ) { if ( \is_string( $error ) ) { $error_message = $error; } elseif ( \is_wp_error( $error ) ) { $error_message = $error->get_error_message(); } else { $error_message = \__( 'Unknown Error or misconfigured Error-Message', 'activitypub' ); } return \add_post_meta( $post_id, '_activitypub_errors', $error_message ); } /** * Count the errors for an actor. * * @param int $post_id The ID of the WordPress Custom-Post-Type. * * @return int The number of errors. */ public static function count_errors( $post_id ) { return \count( \get_post_meta( $post_id, '_activitypub_errors', false ) ); } /** * Get all error messages for an actor. * * @param int $post_id The post ID. * * @return string[] Array of error messages. */ public static function get_errors( $post_id ) { return \get_post_meta( $post_id, '_activitypub_errors', false ); } /** * Clear all errors for an actor. * * @param int $post_id The ID of the WordPress Custom-Post-Type. * * @return bool True on success, false on failure. */ public static function clear_errors( $post_id ) { return \delete_post_meta( $post_id, '_activitypub_errors' ); } /** * Get all remote actors (Custom Post Type) that had errors. * * @param int $number Optional. Number of actors to return. Default 20. * * @return \WP_Post[] Array of faulty actor posts. */ public static function get_faulty( $number = 20 ) { $args = array( 'post_type' => self::POST_TYPE, 'posts_per_page' => $number, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( 'relation' => 'OR', array( 'key' => '_activitypub_errors', 'compare' => 'EXISTS', ), array( 'key' => '_activitypub_inbox', 'compare' => 'NOT EXISTS', ), array( 'key' => '_activitypub_inbox', 'value' => '', 'compare' => '=', ), ), ); return ( new \WP_Query() )->query( $args ); } /** * Get all remote actor posts not updated for a given time. * * @param int $number Optional. Limits the result. Default 50. * @param int $older_than Optional. The time in seconds. Default DAY_IN_SECONDS. * * @return \WP_Post[] The list of actors. */ public static function get_outdated( $number = 50, $older_than = DAY_IN_SECONDS ) { $args = array( 'post_type' => self::POST_TYPE, 'posts_per_page' => $number, 'orderby' => 'modified', 'order' => 'ASC', 'post_status' => 'any', // 'any' includes 'trash'. 'date_query' => array( array( 'column' => 'post_modified_gmt', 'before' => \gmdate( 'Y-m-d', \time() - $older_than ), ), ), ); return ( new \WP_Query() )->query( $args ); } /** * Convert a custom post type input to an Activitypub\Activity\Actor. * * @param int|\WP_Post $post The post ID or object. * * @return Actor|\WP_Error The actor object or WP_Error on failure. */ public static function get_actor( $post ) { $post = \get_post( $post ); if ( ! $post ) { return new \WP_Error( 'activitypub_actor_not_found', \__( 'Actor not found', 'activitypub' ), array( 'status' => 404 ) ); } $json = $post->post_content; if ( empty( $json ) ) { $json = \get_post_meta( $post->ID, '_activitypub_actor_json', true ); } $actor = Actor::init_from_json( $json ); if ( \is_wp_error( $actor ) ) { self::add_error( $post->ID, $actor ); return $actor; } if ( ! $actor->get_webfinger() ) { $actor->set_webfinger( self::get_acct( $post->ID ) ); } return $actor; } /** * Prepare actor object for insert or update as a custom post type. * * @param array|Actor $actor The actor data. * * @return array|\WP_Error Array of post arguments or WP_Error on failure. */ private static function prepare_custom_post_type( $actor ) { if ( \is_array( $actor ) ) { $actor = Actor::init_from_array( $actor ); } /* * Reject non-actor objects here, the single chokepoint every * create/update/upsert funnels through, so callers do not each have to * guard against accidentally caching a Note or other non-actor object. */ if ( ! $actor instanceof Actor || ! is_actor( $actor ) ) { return new \WP_Error( 'activitypub_invalid_actor_data', \__( 'Invalid actor data', 'activitypub' ), array( 'status' => 400 ) ); } if ( ! empty( $actor->get_endpoints()['sharedInbox'] ) ) { $inbox = $actor->get_endpoints()['sharedInbox']; } elseif ( ! empty( $actor->get_inbox() ) ) { $inbox = $actor->get_inbox(); } else { return new \WP_Error( 'activitypub_invalid_actor_data', \__( 'Invalid actor data', 'activitypub' ), array( 'status' => 400 ) ); } if ( $actor->get_webfinger() ) { $webfinger = Sanitize::webfinger( $actor->get_webfinger() ); } else { $webfinger = Webfinger::uri_to_acct( $actor->get_id() ); $webfinger = \is_wp_error( $webfinger ) ? Webfinger::guess( $actor ) : Sanitize::webfinger( $webfinger ); } list( $actor_json, $actor_array ) = self::serialize_for_storage( $actor ); $meta_input = array( '_activitypub_inbox' => $inbox, '_activitypub_acct' => $webfinger, ); // Add emoji meta if actor has emoji in tags. $emoji_meta = Emoji::prepare_actor_meta( $actor_array ); $meta_input = \array_merge( $meta_input, $emoji_meta ); return array( 'guid' => \esc_url_raw( $actor->get_id() ), 'post_title' => \wp_slash( \wp_strip_all_tags( $actor->get_name() ?: $actor->get_preferred_username() ) ), 'post_author' => 0, 'post_type' => self::POST_TYPE, 'post_content' => \wp_slash( $actor_json ), 'post_excerpt' => \wp_slash( \wp_kses( (string) $actor->get_summary(), 'user_description' ) ), 'post_status' => 'publish', 'meta_input' => $meta_input, ); } /** * Serialize a remote actor without outbound content filters. * * Workaround: the Mention/Hashtag/Link filters process content for the *outgoing* (federation) * context and must not run when storing *incoming* remote data. Suspending them here is the * stopgap for a missing incoming/outgoing serialization-context split; until that exists, every * storage path (see also Inbox storage) needs the same treatment. * * @since 9.4.0 * * @param Actor $actor The actor to serialize. * * @return array The actor JSON and array representations. */ private static function serialize_for_storage( $actor ) { $callbacks = array( array( 'Activitypub\Mention', 'filter_activity_object' ), array( 'Activitypub\Hashtag', 'filter_activity_object' ), array( 'Activitypub\Link', 'filter_activity_object' ), ); $registered = array(); foreach ( $callbacks as $callback ) { $priority = \has_filter( 'activitypub_activity_object_array', $callback ); if ( false !== $priority ) { $registered[] = array( $callback, $priority ); \remove_filter( 'activitypub_activity_object_array', $callback, $priority ); } } $serialized = array( $actor->to_json(), $actor->to_array() ); foreach ( $registered as $filter ) { \add_filter( 'activitypub_activity_object_array', $filter[0], $filter[1] ); } return $serialized; } /** * Normalize actor identifier to a URI. * * Handles webfinger addresses, URLs without schemes, objects, and arrays. * * @param string|object|array $actor Actor URI, webfinger address, actor object, or array. * @return string|null Normalized actor URI or null if unable to resolve. */ public static function normalize_identifier( $actor ) { $actor = object_to_uri( $actor ); if ( ! \is_string( $actor ) ) { return null; } $actor = \trim( $actor, '@' ); // If it's an email-like webfinger address, resolve it. if ( \filter_var( $actor, FILTER_VALIDATE_EMAIL ) ) { $resolved = Webfinger::resolve( $actor ); return \is_wp_error( $resolved ) ? null : object_to_uri( $resolved ); } // If it's a URL without scheme, add https://. if ( empty( \wp_parse_url( $actor, PHP_URL_SCHEME ) ) ) { $actor = \esc_url_raw( 'https://' . \ltrim( $actor, '/' ) ); } return $actor; } /** * Get public key from key_id. * * @since 9.4.0 Added the `$owner` parameter. * * @param string $key_id The URL to the public key. * @param string|null $owner Optional. Set to the id of the actor the key was taken from. * * @return resource|\WP_Error The public key resource or WP_Error. */ public static function get_public_key( $key_id, &$owner = null ) { $no_profile_error = new \WP_Error( 'activitypub_no_remote_profile_found', 'No Profile found or Profile not accessible', array( 'status' => 401 ) ); $no_key_error = new \WP_Error( 'activitypub_no_remote_key_found', 'No Public-Key found', array( 'status' => 401 ) ); $actor = self::get_by_uri( \strip_fragment_from_url( $key_id ) ); if ( ! \is_wp_error( $actor ) ) { $actor = \json_decode( $actor->post_content, true ); } else { $data = Proxy::get( $key_id ); if ( \is_wp_error( $data ) ) { return $no_profile_error; } // If we fetched a standalone key object, follow the owner to get the actor. if ( isset( $data['owner'] ) && ! isset( $data['publicKey'] ) ) { // Verify the owner is on the same host as the key, so a key on one host cannot be claimed for an actor on another. if ( ! is_same_host( $key_id, $data['owner'] ) ) { return $no_key_error; } $data = Proxy::get( $data['owner'] ); } $actor = $data; } if ( \is_wp_error( $actor ) ) { return $no_profile_error; } // Authorization uses the requested key's host, even after actor discovery redirects. if ( ! \is_string( $actor['id'] ?? null ) || ! is_same_host( $key_id, $actor['id'] ) ) { return $no_key_error; } $public_key_pem = self::extract_public_key_pem( $actor ); if ( ! $public_key_pem ) { return $no_key_error; } $key_resource = \openssl_pkey_get_public( \rtrim( $public_key_pem ) ); if ( ! $key_resource ) { return $no_key_error; } // The actor this key belongs to, so the caller can bind it to the activity's actor. $owner = $actor['id']; return $key_resource; } /** * Refresh a stored actor from its own host after its key failed to verify. * * The key of a stored actor is read from the stored record first, so an actor that rotates its * key would stay locked out until something refreshes that record. A key taken from the proxy * is at most as old as the proxy's cache, so only stored actors are refreshed. * * Each actor is refreshed at most once every few minutes, so failing signatures cannot cause a * fetch every time. The throttle is a transient, not the record's modified date, so the * scheduler still refreshes, counts errors for and cleans up this actor as before. * * @since 9.4.0 * * @param string $key_id The keyId whose key did not verify. * * @return bool True if the stored actor changed, false otherwise. */ public static function refresh_public_key( $key_id ) { $post = self::get_by_uri( \strip_fragment_from_url( $key_id ) ); if ( \is_wp_error( $post ) ) { return false; } // The stored document's id, not the post's guid, which core may rewrite on save. $stored = \json_decode( $post->post_content, true ); $id = $stored['id'] ?? null; if ( ! \is_string( $id ) ) { return false; } $throttle = 'activitypub_key_refresh_' . \md5( $id ); if ( \get_transient( $throttle ) ) { return false; } \set_transient( $throttle, 1, 5 * MINUTE_IN_SECONDS ); // Proxy::get() only answers with the document its own host serves under this id. $actor = Proxy::get( $id, array( 'cached' => false ) ); if ( ! \is_array( $actor ) || ( $actor['id'] ?? null ) !== $id ) { return false; } $fresh = Actor::init_from_array( $actor ); // Only a changed document is worth a write, compared the way it would be stored. if ( ! $fresh instanceof Actor || \json_decode( self::serialize_for_storage( $fresh )[0], true ) === $stored ) { return false; } return ! \is_wp_error( self::update( $post, $fresh ) ); } /** * Extract public key PEM from a fetched object. * * Supports two formats: * 1. Actor objects with a nested `publicKey` property (e.g. Mastodon-style `#main-key` fragments). * 2. Actor objects with a `publicKey` URL reference (e.g. `tags.pub`). * The URL is dereferenced and the key's owner is verified against the actor. * * @since 8.0.0 * * @param array $data The fetched actor JSON data. * * @return string|false The public key PEM string, or false if not found. */ private static function extract_public_key_pem( $data ) { // Standard actor with nested publicKey. if ( isset( $data['publicKey']['publicKeyPem'] ) ) { return $data['publicKey']['publicKeyPem']; } // Actor with publicKey as a URL reference (e.g. tags.pub). if ( ! isset( $data['publicKey'] ) || ! \is_string( $data['publicKey'] ) ) { return false; } // Verify the key URL is on the same host as the actor. if ( ! is_same_host( $data['id'] ?? '', $data['publicKey'] ) ) { return false; } $key_data = Proxy::get( $data['publicKey'] ); if ( \is_wp_error( $key_data ) || ! isset( $key_data['publicKeyPem'] ) ) { return false; } // Check the resolved key as well as its original URL before trusting its owner claim. if ( ! \is_string( $key_data['id'] ?? null ) || ! is_same_host( $data['id'], $key_data['id'] ) ) { return false; } // Verify the key's owner matches the actor. if ( ! isset( $key_data['owner'] ) || $key_data['owner'] !== $data['id'] ) { return false; } return $key_data['publicKeyPem']; } /** * Get the acct of a remote actor. * * @uses Webfinger::uri_to_acct to resolve the acct by the actor URI. * @uses Webfinger::guess to guess a acct if the actors acct is not resolvable. * * @param int $id The ID of the remote actor. * * @return string The acct of the remote actor or empty string on failure. */ public static function get_acct( $id ) { $acct = \get_post_meta( $id, '_activitypub_acct', true ); if ( $acct ) { return $acct; } $post = \get_post( $id ); if ( ! $post ) { return ''; } $acct = Webfinger::uri_to_acct( $post->guid ); if ( \is_wp_error( $acct ) ) { $actor = Actor::init_from_json( $post->post_content ); if ( \is_wp_error( $actor ) ) { return ''; } $acct = Webfinger::guess( $actor ); } $acct = Sanitize::webfinger( $acct ); \update_post_meta( $id, '_activitypub_acct', $acct ); return $acct; } /** * Get the avatar URL for a remote actor. * * Uses lazy caching - the avatar is only downloaded when first accessed. * Passes the URL through the activitypub_remote_media_url filter which * triggers caching if enabled. * * @param int $id The ID of the remote actor post. * * @return string The avatar URL or a default one if not found. */ public static function get_avatar_url( $id ) { $default_avatar_url = ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg'; // Extract remote avatar URL from actor data. $post = \get_post( $id ); if ( ! $post || empty( $post->post_content ) ) { return $default_avatar_url; } $actor_data = \json_decode( $post->post_content, true ); if ( empty( $actor_data['icon'] ) ) { return $default_avatar_url; } $remote_avatar_url = object_to_uri( $actor_data['icon'] ); if ( empty( $remote_avatar_url ) ) { return $default_avatar_url; } /** * Filters a remote media URL before use. * * Cache handlers hook into this filter to provide lazy caching. * Returns cached local URL if available, otherwise original URL. * * @since 5.6.0 * * @param string $url The remote avatar URL. * @param string $context The context ('avatar', 'media', 'emoji'). * @param int|null $entity_id The entity ID (actor post ID, post ID, or null for emoji). * @param array $options Optional. Additional options like 'updated' timestamp. */ return \apply_filters( 'activitypub_remote_media_url', $remote_avatar_url, 'avatar', $id, array() ); } }