<?php /** * ActivityPub Embed Handler. * * @package Activitypub */ namespace Activitypub; /** * Class to handle embedding ActivityPub content */ class Embed { /** * Initialize the embed handler */ public static function init() { \add_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 ); \add_filter( 'oembed_dataparse', array( self::class, 'handle_filtered_oembed_result' ), 11, 3 ); \add_filter( 'oembed_request_post_id', array( self::class, 'register_fallback_hook' ) ); } /** * Get an ActivityPub embed HTML for a URL. * * @param string $url The URL to get the embed for. * @param boolean $inline_css Whether to inline CSS. Default true. * * @return string|false The embed HTML or false if not found. */ public static function get_html( $url, $inline_css = true ) { // Try to get ActivityPub representation. $object = Http::get_remote_object( $url ); if ( is_wp_error( $object ) ) { return false; } return self::get_html_for_object( $object, $inline_css ); } /** * Get an ActivityPub embed HTML for an ActivityPub object. * * @param array $activity_object The ActivityPub object to build the embed for. * @param boolean $inline_css Whether to inline CSS. Default true. * * @return string The embed HTML. */ public static function get_html_for_object( $activity_object, $inline_css = true ) { $author_name = $activity_object['attributedTo'] ?? ''; $avatar_url = $activity_object['icon']['url'] ?? ''; $author_url = $author_name; // If we don't have an avatar URL, but we have an author URL, try to fetch it. if ( ! $avatar_url && $author_url ) { $author = Http::get_remote_object( $author_url ); if ( ! is_wp_error( $author ) ) { $avatar_url = $author['icon']['url'] ?? ''; $author_name = $author['name'] ?? $author_name; } } // Create Webfinger where not found. if ( empty( $author['webfinger'] ) ) { if ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) { // Construct webfinger-style identifier from username and domain. $domain = wp_parse_url( $author['url'], PHP_URL_HOST ); $author['webfinger'] = '@' . $author['preferredUsername'] . '@' . $domain; } else { // Fallback to URL. $author['webfinger'] = $author_url; } } $title = $activity_object['name'] ?? ''; $content = $activity_object['content'] ?? ''; $published = isset( $activity_object['published'] ) ? gmdate( get_option( 'date_format' ) . ', ' . get_option( 'time_format' ), strtotime( $activity_object['published'] ) ) : ''; $boosts = isset( $activity_object['shares']['totalItems'] ) ? (int) $activity_object['shares']['totalItems'] : null; $favorites = isset( $activity_object['likes']['totalItems'] ) ? (int) $activity_object['likes']['totalItems'] : null; $image = ''; if ( isset( $activity_object['image']['url'] ) ) { $image = $activity_object['image']['url']; } elseif ( isset( $activity_object['attachment'] ) ) { foreach ( $activity_object['attachment'] as $attachment ) { if ( isset( $attachment['type'] ) && in_array( $attachment['type'], array( 'Image', 'Document' ), true ) ) { $image = $attachment['url']; break; } } } ob_start(); load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/reply-embed.php', false, array( 'author_name' => $author_name, 'author_url' => $author_url, 'avatar_url' => $avatar_url, 'published' => $published, 'title' => $title, 'content' => $content, 'image' => $image, 'boosts' => $boosts, 'favorites' => $favorites, 'url' => $activity_object['id'], 'webfinger' => $author['webfinger'], ) ); if ( $inline_css ) { // Grab the CSS. $css = \file_get_contents( ACTIVITYPUB_PLUGIN_DIR . 'assets/css/activitypub-embed.css' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents // We embed CSS directly because this may be in an iframe. printf( '<style>%s</style>', $css ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } // A little light whitespace cleanup. return preg_replace( '/\s+/', ' ', ob_get_clean() ); } /** * Check if a real oEmbed result exists for the given URL. * * @param string $url The URL to check. * @param array $args Additional arguments passed to wp_oembed_get(). * @return bool True if a real oEmbed result exists, false otherwise. */ public static function has_real_oembed( $url, $args = array() ) { // Temporarily remove our filter to avoid infinite loops. \remove_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 ); // Try to get a "real" oEmbed result. If found, it'll be cached to avoid unnecessary HTTP requests in `wp_oembed_get`. $oembed_result = \wp_oembed_get( $url, $args ); // Add our filter back. \add_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 ); return false !== $oembed_result; } /** * Filter the oembed result to handle ActivityPub content when no oEmbed is found. * Implementation is a bit weird because there's no way to filter on a false result, we have to use `pre_oembed_result`. * * @param null|string $result The UNSANITIZED (and potentially unsafe) HTML that should be used to embed. * @param string $url The URL to the content that should be attempted to be embedded. * @param array $args Additional arguments passed to wp_oembed_get(). * @return null|string Return null to allow normal oEmbed processing, or string for ActivityPub embed. */ public static function maybe_use_activitypub_embed( $result, $url, $args ) { // If we already have a result, return it. if ( null !== $result ) { return $result; } // If we found a real oEmbed, return null to allow normal processing. if ( self::has_real_oembed( $url, $args ) ) { return null; } // No oEmbed found, try to get ActivityPub representation. $html = get_embed_html( $url ); // If we couldn't get an ActivityPub embed either, return null to allow normal processing. if ( ! $html ) { return null; } // Return the ActivityPub embed HTML. return $html; } /** * Handle cases where WordPress has filtered out the oEmbed result for security reasons, * but we can provide a safe ActivityPub-specific markup. * * This runs after wp_filter_oembed_result has potentially nullified the result. * * @param string|false $html The returned oEmbed HTML. * @param object $data A data object result from an oEmbed provider. * @param string $url The URL of the content to be embedded. * @return string|false The filtered oEmbed HTML or our ActivityPub embed. */ public static function handle_filtered_oembed_result( $html, $data, $url ) { // If we already have valid HTML, return it. if ( $html ) { return $html; } // If this isn't a rich or video type, we can't help. if ( ! isset( $data->type ) || ! \in_array( $data->type, array( 'rich', 'video' ), true ) ) { return $html; } // If there's no HTML in the data, we can't help. if ( empty( $data->html ) || ! \is_string( $data->html ) ) { return $html; } // Try to get ActivityPub representation. $activitypub_html = get_embed_html( $url ); if ( ! $activitypub_html ) { return $html; } // Return our safer ActivityPub embed HTML. return $activitypub_html; } /** * Register the fallback hook for oEmbed requests. * * Avoids filtering every single API request. * * @param int $post_id The post ID. * @return int The post ID. */ public static function register_fallback_hook( $post_id ) { \add_filter( 'rest_request_after_callbacks', array( self::class, 'oembed_fediverse_fallback' ), 10, 3 ); return $post_id; } /** * Fallback for oEmbed requests to the Fediverse. * * @param \WP_REST_Response|\WP_Error $response Result to send to the client. * @param array $handler Route handler used for the request. * @param \WP_REST_Request $request Request used to generate the response. * * @return \WP_REST_Response|\WP_Error The response to send to the client. */ public static function oembed_fediverse_fallback( $response, $handler, $request ) { if ( is_wp_error( $response ) && 'oembed_invalid_url' === $response->get_error_code() ) { $url = $request->get_param( 'url' ); $html = get_embed_html( $url ); if ( $html ) { $args = $request->get_params(); $data = (object) array( 'provider_name' => 'Embed Handler', 'html' => $html, 'scripts' => array(), ); /** This filter is documented in wp-includes/class-wp-oembed.php */ $data->html = apply_filters( 'oembed_result', $data->html, $url, $args ); /** This filter is documented in wp-includes/class-wp-oembed-controller.php */ $ttl = apply_filters( 'rest_oembed_ttl', DAY_IN_SECONDS, $url, $args ); set_transient( 'oembed_' . md5( serialize( $args ) ), $data, $ttl ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize $response = new \WP_REST_Response( $data ); } } return $response; } }