PluginProbe
ActivityPub / 7.1.0
ActivityPub v7.1.0
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 7.1.0, at includes/class-embed.php

285 lines 9.5 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 $avatar_url = $author['icon']['url'] ?? '';
61 $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( $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 = get_embed_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 ( is_wp_error( $response ) && 'oembed_invalid_url' === $response->get_error_code() ) {
259 $url = $request->get_param( 'url' );
260 $html = get_embed_html( $url );
261
262 if ( $html ) {
263 $args = $request->get_params();
264 $data = (object) array(
265 'provider_name' => 'Embed Handler',
266 'html' => $html,
267 'scripts' => array(),
268 );
269
270 /** This filter is documented in wp-includes/class-wp-oembed.php */
271 $data->html = apply_filters( 'oembed_result', $data->html, $url, $args );
272
273 /** This filter is documented in wp-includes/class-wp-oembed-controller.php */
274 $ttl = apply_filters( 'rest_oembed_ttl', DAY_IN_SECONDS, $url, $args );
275
276 set_transient( 'oembed_' . md5( serialize( $args ) ), $data, $ttl ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
277
278 $response = new \WP_REST_Response( $data );
279 }
280 }
281
282 return $response;
283 }
284 }
285