| 1 |
<?php |
| 2 |
/** |
| 3 |
* ActivityPub Embed Handler. |
| 4 |
* |
| 5 |
* @package Activitypub |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Activitypub; |
| 9 |
|
| 10 |
/** |
| 11 |
* Class to handle embedding ActivityPub content. |
| 12 |
*/ |
| 13 |
class Embed { |
| 14 |
|
| 15 |
/** |
| 16 |
* Initialize the embed handler. |
| 17 |
*/ |
| 18 |
public static function init() { |
| 19 |
\add_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 ); |
| 20 |
\add_filter( 'oembed_dataparse', array( self::class, 'handle_filtered_oembed_result' ), 11, 3 ); |
| 21 |
\add_filter( 'oembed_request_post_id', array( self::class, 'register_fallback_hook' ) ); |
| 22 |
} |
| 23 |
|
| 24 |
/** |
| 25 |
* Get an ActivityPub embed HTML for a URL. |
| 26 |
* |
| 27 |
* @param string $url The URL to get the embed for. |
| 28 |
* @param boolean $inline_css Whether to inline CSS. Default true. |
| 29 |
* |
| 30 |
* @return string|false The embed HTML or false if not found. |
| 31 |
*/ |
| 32 |
public static function get_html( $url, $inline_css = true ) { |
| 33 |
// Try to get ActivityPub representation. |
| 34 |
$object = Http::get_remote_object( $url ); |
| 35 |
|
| 36 |
if ( \is_wp_error( $object ) || ! is_activity_object( $object ) ) { |
| 37 |
return false; |
| 38 |
} |
| 39 |
|
| 40 |
return self::get_html_for_object( $object, $inline_css ); |
| 41 |
} |
| 42 |
|
| 43 |
/** |
| 44 |
* Get an ActivityPub embed HTML for an ActivityPub object. |
| 45 |
* |
| 46 |
* @param array $activity_object The ActivityPub object to build the embed for. |
| 47 |
* @param boolean $inline_css Whether to inline CSS. Default true. |
| 48 |
* |
| 49 |
* @return string The embed HTML. |
| 50 |
*/ |
| 51 |
public static function get_html_for_object( $activity_object, $inline_css = true ) { |
| 52 |
$author_name = $activity_object['attributedTo'] ?? ''; |
| 53 |
$avatar_url = $activity_object['icon']['url'] ?? ''; |
| 54 |
$author_url = $author_name; |
| 55 |
|
| 56 |
// If we don't have an avatar URL, but we have an author URL, try to fetch it. |
| 57 |
if ( ! $avatar_url && $author_url ) { |
| 58 |
$author = Http::get_remote_object( $author_url ); |
| 59 |
if ( ! is_wp_error( $author ) ) { |
| 60 |
$avatar_url = $author['icon']['url'] ?? ''; |
| 61 |
$author_name = empty( $author['name'] ) ? $author_name : $author['name']; |
| 62 |
} |
| 63 |
} |
| 64 |
|
| 65 |
// Create Webfinger where not found. |
| 66 |
if ( empty( $author['webfinger'] ) ) { |
| 67 |
if ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) { |
| 68 |
// Construct webfinger-style identifier from username and domain. |
| 69 |
$domain = \wp_parse_url( object_to_uri( $author['url'] ), PHP_URL_HOST ); |
| 70 |
$author['webfinger'] = '@' . $author['preferredUsername'] . '@' . $domain; |
| 71 |
} else { |
| 72 |
// Fallback to URL. |
| 73 |
$author['webfinger'] = $author_url; |
| 74 |
} |
| 75 |
} |
| 76 |
|
| 77 |
$title = $activity_object['name'] ?? ''; |
| 78 |
$content = $activity_object['content'] ?? ''; |
| 79 |
$published = isset( $activity_object['published'] ) ? gmdate( get_option( 'date_format' ) . ', ' . get_option( 'time_format' ), strtotime( $activity_object['published'] ) ) : ''; |
| 80 |
$boosts = isset( $activity_object['shares']['totalItems'] ) ? (int) $activity_object['shares']['totalItems'] : null; |
| 81 |
$favorites = isset( $activity_object['likes']['totalItems'] ) ? (int) $activity_object['likes']['totalItems'] : null; |
| 82 |
|
| 83 |
$audio = null; |
| 84 |
$images = array(); |
| 85 |
$video = null; |
| 86 |
if ( isset( $activity_object['image']['url'] ) ) { |
| 87 |
$images = array( |
| 88 |
array( |
| 89 |
'type' => 'Image', |
| 90 |
'url' => $activity_object['image']['url'], |
| 91 |
'name' => $activity_object['image']['name'] ?? '', |
| 92 |
), |
| 93 |
); |
| 94 |
} elseif ( isset( $activity_object['attachment'] ) ) { |
| 95 |
foreach ( $activity_object['attachment'] as $attachment ) { |
| 96 |
$type = isset( $attachment['mediaType'] ) ? strtok( $attachment['mediaType'], '/' ) : strtolower( $attachment['type'] ); |
| 97 |
|
| 98 |
switch ( $type ) { |
| 99 |
case 'image': |
| 100 |
$images[] = $attachment; |
| 101 |
break; |
| 102 |
case 'video': |
| 103 |
$video = $attachment; |
| 104 |
break 2; |
| 105 |
case 'audio': |
| 106 |
$audio = $attachment; |
| 107 |
break 2; |
| 108 |
} |
| 109 |
} |
| 110 |
$images = \array_slice( $images, 0, 4 ); |
| 111 |
} |
| 112 |
|
| 113 |
ob_start(); |
| 114 |
load_template( |
| 115 |
ACTIVITYPUB_PLUGIN_DIR . 'templates/embed.php', |
| 116 |
false, |
| 117 |
array( |
| 118 |
'audio' => $audio, |
| 119 |
'author_name' => $author_name, |
| 120 |
'author_url' => $author_url, |
| 121 |
'avatar_url' => $avatar_url, |
| 122 |
'boosts' => $boosts, |
| 123 |
'content' => $content, |
| 124 |
'favorites' => $favorites, |
| 125 |
'images' => $images, |
| 126 |
'published' => $published, |
| 127 |
'title' => $title, |
| 128 |
'url' => $activity_object['id'], |
| 129 |
'video' => $video, |
| 130 |
'webfinger' => $author['webfinger'], |
| 131 |
) |
| 132 |
); |
| 133 |
|
| 134 |
if ( $inline_css ) { |
| 135 |
// Grab the CSS. |
| 136 |
$css = \file_get_contents( ACTIVITYPUB_PLUGIN_DIR . 'assets/css/activitypub-embed.css' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 137 |
// We embed CSS directly because this may be in an iframe. |
| 138 |
printf( '<style>%s</style>', $css ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 139 |
} |
| 140 |
|
| 141 |
// A little light whitespace cleanup. |
| 142 |
return preg_replace( '/\s+/', ' ', ob_get_clean() ); |
| 143 |
} |
| 144 |
|
| 145 |
/** |
| 146 |
* Check if a real oEmbed result exists for the given URL. |
| 147 |
* |
| 148 |
* @param string $url The URL to check. |
| 149 |
* @param array $args Additional arguments passed to wp_oembed_get(). |
| 150 |
* @return bool True if a real oEmbed result exists, false otherwise. |
| 151 |
*/ |
| 152 |
public static function has_real_oembed( $url, $args = array() ) { |
| 153 |
// Temporarily remove our filter to avoid infinite loops. |
| 154 |
\remove_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ) ); |
| 155 |
|
| 156 |
// Try to get a "real" oEmbed result. If found, it'll be cached to avoid unnecessary HTTP requests in `wp_oembed_get`. |
| 157 |
$oembed_result = \wp_oembed_get( $url, $args ); |
| 158 |
|
| 159 |
// Add our filter back. |
| 160 |
\add_filter( 'pre_oembed_result', array( self::class, 'maybe_use_activitypub_embed' ), 10, 3 ); |
| 161 |
|
| 162 |
return false !== $oembed_result; |
| 163 |
} |
| 164 |
|
| 165 |
/** |
| 166 |
* Filter the oembed result to handle ActivityPub content when no oEmbed is found. |
| 167 |
* Implementation is a bit weird because there's no way to filter on a false result, we have to use `pre_oembed_result`. |
| 168 |
* |
| 169 |
* @param null|string $result The UNSANITIZED (and potentially unsafe) HTML that should be used to embed. |
| 170 |
* @param string $url The URL to the content that should be attempted to be embedded. |
| 171 |
* @param array $args Additional arguments passed to wp_oembed_get(). |
| 172 |
* @return null|string Return null to allow normal oEmbed processing, or string for ActivityPub embed. |
| 173 |
*/ |
| 174 |
public static function maybe_use_activitypub_embed( $result, $url, $args ) { |
| 175 |
// If we already have a result, return it. |
| 176 |
if ( null !== $result ) { |
| 177 |
return $result; |
| 178 |
} |
| 179 |
|
| 180 |
// If we found a real oEmbed, return null to allow normal processing. |
| 181 |
if ( self::has_real_oembed( $url, $args ) ) { |
| 182 |
return null; |
| 183 |
} |
| 184 |
|
| 185 |
// No oEmbed found, try to get ActivityPub representation. |
| 186 |
$html = get_embed_html( $url ); |
| 187 |
|
| 188 |
// If we couldn't get an ActivityPub embed either, return null to allow normal processing. |
| 189 |
if ( ! $html ) { |
| 190 |
return null; |
| 191 |
} |
| 192 |
|
| 193 |
// Return the ActivityPub embed HTML. |
| 194 |
return $html; |
| 195 |
} |
| 196 |
|
| 197 |
/** |
| 198 |
* Handle cases where WordPress has filtered out the oEmbed result for security reasons, |
| 199 |
* but we can provide a safe ActivityPub-specific markup. |
| 200 |
* |
| 201 |
* This runs after wp_filter_oembed_result has potentially nullified the result. |
| 202 |
* |
| 203 |
* @param string|false $html The returned oEmbed HTML. |
| 204 |
* @param object $data A data object result from an oEmbed provider. |
| 205 |
* @param string $url The URL of the content to be embedded. |
| 206 |
* @return string|false The filtered oEmbed HTML or our ActivityPub embed. |
| 207 |
*/ |
| 208 |
public static function handle_filtered_oembed_result( $html, $data, $url ) { |
| 209 |
// If we already have valid HTML, return it. |
| 210 |
if ( $html ) { |
| 211 |
return $html; |
| 212 |
} |
| 213 |
|
| 214 |
// If this isn't a rich or video type, we can't help. |
| 215 |
if ( ! isset( $data->type ) || ! \in_array( $data->type, array( 'rich', 'video' ), true ) ) { |
| 216 |
return $html; |
| 217 |
} |
| 218 |
|
| 219 |
// If there's no HTML in the data, we can't help. |
| 220 |
if ( empty( $data->html ) || ! \is_string( $data->html ) ) { |
| 221 |
return $html; |
| 222 |
} |
| 223 |
|
| 224 |
// Try to get ActivityPub representation. |
| 225 |
$activitypub_html = self::get_html( $url ); |
| 226 |
if ( ! $activitypub_html ) { |
| 227 |
return $html; |
| 228 |
} |
| 229 |
|
| 230 |
// Return our safer ActivityPub embed HTML. |
| 231 |
return $activitypub_html; |
| 232 |
} |
| 233 |
|
| 234 |
/** |
| 235 |
* Register the fallback hook for oEmbed requests. |
| 236 |
* |
| 237 |
* Avoids filtering every single API request. |
| 238 |
* |
| 239 |
* @param int $post_id The post ID. |
| 240 |
* @return int The post ID. |
| 241 |
*/ |
| 242 |
public static function register_fallback_hook( $post_id ) { |
| 243 |
\add_filter( 'rest_request_after_callbacks', array( self::class, 'oembed_fediverse_fallback' ), 10, 3 ); |
| 244 |
|
| 245 |
return $post_id; |
| 246 |
} |
| 247 |
|
| 248 |
/** |
| 249 |
* Fallback for oEmbed requests to the Fediverse. |
| 250 |
* |
| 251 |
* @param \WP_REST_Response|\WP_Error $response Result to send to the client. |
| 252 |
* @param array $handler Route handler used for the request. |
| 253 |
* @param \WP_REST_Request $request Request used to generate the response. |
| 254 |
* |
| 255 |
* @return \WP_REST_Response|\WP_Error The response to send to the client. |
| 256 |
*/ |
| 257 |
public static function oembed_fediverse_fallback( $response, $handler, $request ) { |
| 258 |
if ( '/oembed/1.0/proxy' !== $request->get_route() ) { |
| 259 |
return $response; |
| 260 |
} |
| 261 |
|
| 262 |
if ( ( is_wp_error( $response ) && 'oembed_invalid_url' === $response->get_error_code() ) || empty( $response->html ) ) { |
| 263 |
$url = $request->get_param( 'url' ); |
| 264 |
$html = self::get_html( $url ); |
| 265 |
|
| 266 |
if ( $html ) { |
| 267 |
$args = $request->get_params(); |
| 268 |
$data = (object) array( |
| 269 |
'provider_name' => 'ActivityPub oEmbed', |
| 270 |
'html' => $html, |
| 271 |
'scripts' => array(), |
| 272 |
); |
| 273 |
|
| 274 |
/** This filter is documented in wp-includes/class-wp-oembed.php */ |
| 275 |
$data->html = apply_filters( 'oembed_result', $data->html, $url, $args ); |
| 276 |
|
| 277 |
/** This filter is documented in wp-includes/class-wp-oembed-controller.php */ |
| 278 |
$ttl = apply_filters( 'rest_oembed_ttl', DAY_IN_SECONDS, $url, $args ); |
| 279 |
|
| 280 |
set_transient( 'oembed_' . md5( serialize( $args ) ), $data, $ttl ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize |
| 281 |
|
| 282 |
$response = new \WP_REST_Response( $data ); |
| 283 |
} |
| 284 |
} elseif ( ! empty( $request->get_param( 'activitypub' ) ) ) { |
| 285 |
/* |
| 286 |
* If the 'activitypub' parameter is present, perform an additional validation step: |
| 287 |
* Ensure the provided URL resolves to a valid ActivityPub object. |
| 288 |
* |
| 289 |
* This differs from the standard oEmbed flow, which does not explicitly validate |
| 290 |
* the URL as an ActivityPub object unless the initial oEmbed lookup fails. |
| 291 |
* This block is triggered for requests from the Federated Reply block, where we |
| 292 |
* want to inform users whether post authors will be notified of the reply. |
| 293 |
*/ |
| 294 |
$object = Http::get_remote_object( $request->get_param( 'url' ) ); |
| 295 |
|
| 296 |
if ( \is_wp_error( $object ) || ! is_activity_object( $object ) ) { |
| 297 |
$response = new \WP_Error( 'oembed_invalid_url', \get_status_header_desc( 404 ), array( 'status' => 404 ) ); |
| 298 |
} |
| 299 |
} |
| 300 |
|
| 301 |
return $response; |
| 302 |
} |
| 303 |
} |
| 304 |
|