PluginProbe
ActivityPub / 8.0.1
ActivityPub v8.0.1
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 8.0.1, at includes/class-embed.php

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