PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-embed.php

class-embed.php in ActivityPub trunk, at includes/class-embed.php

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