PluginProbe
ActivityPub / 9.2.2
ActivityPub v9.2.2
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-query.php

class-query.php in ActivityPub 9.2.2, at includes/class-query.php

521 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Query class.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Activity\Extended_Object\Feature_Authorization;
11 use Activitypub\Activity\Extended_Object\Quote_Authorization;
12 use Activitypub\Collection\Actors;
13 use Activitypub\Collection\Outbox;
14 use Activitypub\Handler\Feature_Request;
15 use Activitypub\Transformer\Factory;
16
17 /**
18 * Singleton class to handle and store the ActivityPub query.
19 */
20 class Query {
21
22 /**
23 * The singleton instance.
24 *
25 * @var Query
26 */
27 private static $instance;
28
29 /**
30 * The ActivityPub object.
31 *
32 * @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object
33 *
34 * @var object
35 */
36 private $activitypub_object;
37
38 /**
39 * The ActivityPub object ID.
40 *
41 * @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-id
42 *
43 * @var string
44 */
45 private $activitypub_object_id;
46
47 /**
48 * Whether the current request is an ActivityPub request.
49 *
50 * @var bool
51 */
52 private $is_activitypub_request;
53
54 /**
55 * Whether the current request is from the old host.
56 *
57 * @var bool
58 */
59 private $is_old_host_request;
60
61 /**
62 * The constructor.
63 */
64 private function __construct() {
65 // Do nothing.
66 }
67
68 /**
69 * The destructor.
70 */
71 public function __destruct() {
72 self::$instance = null;
73 }
74
75 /**
76 * Get the singleton instance.
77 *
78 * @return Query The singleton instance.
79 */
80 public static function get_instance() {
81 if ( ! isset( self::$instance ) ) {
82 self::$instance = new self();
83 }
84
85 return self::$instance;
86 }
87
88 /**
89 * Get the ActivityPub object.
90 *
91 * @return object The ActivityPub object.
92 */
93 public function get_activitypub_object() {
94 if ( $this->activitypub_object ) {
95 return $this->activitypub_object;
96 }
97
98 if ( $this->prepare_activitypub_data() ) {
99 return $this->activitypub_object;
100 }
101
102 $queried_object = $this->get_queried_object();
103 $transformer = Factory::get_transformer( $queried_object );
104
105 if ( $transformer && ! \is_wp_error( $transformer ) ) {
106 $this->activitypub_object = $transformer->to_object();
107 }
108
109 return $this->activitypub_object;
110 }
111
112 /**
113 * Get the ActivityPub object ID.
114 *
115 * @return string The ActivityPub object ID.
116 */
117 public function get_activitypub_object_id() {
118 if ( $this->activitypub_object_id ) {
119 return $this->activitypub_object_id;
120 }
121
122 if ( $this->prepare_activitypub_data() ) {
123 return $this->activitypub_object_id;
124 }
125
126 $queried_object = $this->get_queried_object();
127 $transformer = Factory::get_transformer( $queried_object );
128
129 if ( $transformer && ! \is_wp_error( $transformer ) ) {
130 $this->activitypub_object_id = $transformer->to_id();
131 }
132
133 return $this->activitypub_object_id;
134 }
135
136 /**
137 * Prepare and set both ActivityPub object and ID for Outbox activities and virtual objects.
138 *
139 * @return bool True if an object was found and set, false otherwise.
140 */
141 private function prepare_activitypub_data() {
142 $queried_object = $this->get_queried_object();
143
144 if ( \get_query_var( 'stamp' ) ) {
145 if ( $queried_object instanceof \WP_Post ) {
146 return $this->maybe_get_stamp();
147 }
148
149 // Note: the blog actor's `actor` query var is '0', which is falsy but valid.
150 if ( $queried_object instanceof \WP_User || '' !== \get_query_var( 'actor' ) ) {
151 return $this->maybe_get_actor_stamp();
152 }
153 }
154
155 // Check for Outbox Activity.
156 if (
157 $queried_object instanceof \WP_Post &&
158 Outbox::POST_TYPE === $queried_object->post_type
159 ) {
160 $activitypub_object = Outbox::maybe_get_activity( $queried_object );
161
162 // Check if the Outbox Activity is public.
163 if ( ! \is_wp_error( $activitypub_object ) ) {
164 $this->activitypub_object = $activitypub_object;
165 $this->activitypub_object_id = $this->activitypub_object->get_id();
166 return true;
167 }
168 }
169
170 if ( ! $queried_object ) {
171 // If the object is not a valid ActivityPub object, try to get a virtual object.
172 $activitypub_object = $this->maybe_get_virtual_object();
173
174 if ( $activitypub_object ) {
175 $this->activitypub_object = $activitypub_object;
176 $this->activitypub_object_id = $this->activitypub_object->get_id();
177 return true;
178 }
179 }
180
181 return false;
182 }
183
184 /**
185 * Get the queried object.
186 *
187 * This adds support for Comments by `?c=123` IDs and Users by `?author=123` and `@username` IDs.
188 *
189 * @return \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null The queried object.
190 */
191 public function get_queried_object() {
192 $queried_object = \get_queried_object();
193
194 // Check Comment by ID.
195 if ( ! $queried_object ) {
196 $comment_id = \get_query_var( 'c' );
197 if ( $comment_id ) {
198 $queried_object = \get_comment( $comment_id );
199 }
200 }
201
202 // Check Post by ID (works for custom post types).
203 if ( ! $queried_object ) {
204 $post_id = \get_query_var( 'p' );
205 if ( $post_id ) {
206 $queried_object = \get_post( $post_id );
207 }
208 }
209
210 // Check Term by ID.
211 if ( ! $queried_object ) {
212 $term_id = \get_query_var( 'term_id' );
213 if ( $term_id ) {
214 $queried_object = \get_term( $term_id );
215 }
216 }
217
218 // Try to get Author by ID.
219 if ( ! $queried_object ) {
220 $url = $this->get_request_url();
221 $author_id = url_to_authorid( $url );
222 if ( $author_id ) {
223 $queried_object = \get_user_by( 'id', $author_id );
224 }
225 }
226
227 /**
228 * Filters the queried object.
229 *
230 * @param \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null $queried_object The queried object.
231 */
232 return \apply_filters( 'activitypub_queried_object', $queried_object );
233 }
234
235 /**
236 * Get the virtual object.
237 *
238 * Virtual objects are objects that are not stored in the database, but are created on the fly.
239 * The plugin currently supports one virtual object: The Blog-Actor.
240 *
241 * @see \Activitypub\Model\Blog
242 *
243 * @return object|null The virtual object.
244 */
245 protected function maybe_get_virtual_object() {
246 $url = $this->get_request_url();
247
248 if ( ! $url ) {
249 return null;
250 }
251
252 $author_id = url_to_authorid( $url );
253
254 if ( ! \is_numeric( $author_id ) ) {
255 $author_id = $url;
256 }
257
258 $user = Actors::get_by_various( $author_id );
259
260 if ( \is_wp_error( $user ) || ! $user ) {
261 return null;
262 }
263
264 return $user;
265 }
266
267 /**
268 * Get the request URL.
269 *
270 * @return string|null The request URL.
271 */
272 public function get_request_url() {
273 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
274 return null;
275 }
276
277 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
278 $url = \wp_unslash( $_SERVER['REQUEST_URI'] );
279 $url = \WP_Http::make_absolute_url( $url, \home_url() );
280 $url = \sanitize_url( $url );
281
282 return $url;
283 }
284
285 /**
286 * Check if the current request is an ActivityPub request.
287 *
288 * @return bool True if the request is an ActivityPub request, false otherwise.
289 */
290 public function is_activitypub_request() {
291 if ( ! isset( $this->is_activitypub_request ) ) {
292 global $wp_query;
293
294 $this->is_activitypub_request = false;
295
296 // One can trigger an ActivityPub request by adding `?activitypub` to the URL.
297 if ( isset( $wp_query->query_vars['activitypub'] ) || isset( $_GET['activitypub'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
298 \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
299 $this->is_activitypub_request = true;
300
301 // The other (more common) option to make an ActivityPub request is to send an Accept header.
302 } elseif ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
303 /*
304 * The Accept-header decision is delegated to accept_prefers_activitypub() so the plugin and the
305 * Surge cache drop-in classify byte-for-byte identically. Both must hand it the same raw
306 * header, and they reach that raw form differently on purpose: this runs after
307 * wp_magic_quotes() has addslashed $_SERVER, so it wp_unslash()es to recover the original
308 * bytes; the drop-in runs before wp_magic_quotes() and passes its already-raw value
309 * untouched. Do NOT sanitize it (the drop-in can't, its sanitizers aren't loaded yet) and
310 * the helper must not stripslashes() either (that would corrupt the drop-in's genuine
311 * bytes). It is only used to pick a content type, never stored or echoed.
312 *
313 * The request is ActivityPub when the highest-priority (by `q`, then order) media type is
314 * an ActivityPub type (`application/activity+json`, or `application/ld+json` with the AS2
315 * profile). A browser (`text/html` at q=1) gets the normal page; a client that prefers
316 * ActivityPub but also accepts HTML as a low-`q` fallback (Mastodon) gets ActivityPub.
317 */
318 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Classified only; wp_unslash() recovers the raw bytes the pre-plugin cache path sees, and it must not be sanitized.
319 if ( accept_prefers_activitypub( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) ) {
320 \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
321 $this->is_activitypub_request = true;
322 }
323 }
324 }
325
326 /**
327 * Filters whether the current request is an ActivityPub request.
328 *
329 * @param bool $is_activitypub_request True if the request is an ActivityPub request, false otherwise.
330 */
331 return \apply_filters( 'activitypub_is_activitypub_request', $this->is_activitypub_request );
332 }
333
334 /**
335 * Check if content negotiation is allowed for a request.
336 *
337 * @return bool True if content negotiation is allowed, false otherwise.
338 */
339 public function should_negotiate_content() {
340 $return = false;
341 $always_negotiate = array( 'p', 'c', 'author', 'actor', 'stamp', 'preview', 'activitypub' );
342 $url = \wp_parse_url( $this->get_request_url(), PHP_URL_QUERY );
343 $query = array();
344 \wp_parse_str( $url, $query );
345
346 // Check if any of the query params are in the `$always_negotiate` array.
347 if ( \array_intersect( \array_keys( $query ), $always_negotiate ) ) {
348 $return = true;
349 }
350
351 if ( \get_option( 'activitypub_content_negotiation', '1' ) ) {
352 $return = true;
353 }
354
355 if ( \is_author() && \get_user_option( 'activitypub_use_permalink_as_id', \get_queried_object_id() ) ) {
356 $return = true;
357 }
358
359 /**
360 * Filters whether content negotiation should be forced.
361 *
362 * @param bool $return Whether content negotiation should be forced.
363 */
364 return \apply_filters( 'activitypub_should_negotiate_content', $return );
365 }
366
367 /**
368 * Check if the current request is from the old host.
369 *
370 * @return bool True if the request is from the old host, false otherwise.
371 */
372 public function is_old_host_request() {
373 if ( isset( $this->is_old_host_request ) ) {
374 return $this->is_old_host_request;
375 }
376
377 $old_host = \get_option( 'activitypub_old_host' );
378
379 if ( ! $old_host ) {
380 $this->is_old_host_request = false;
381 return false;
382 }
383
384 $request_host = isset( $_SERVER['HTTP_HOST'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
385 $referer_host = isset( $_SERVER['HTTP_REFERER'] ) ? \wp_parse_url( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : '';
386
387 // Check if the domain matches either the request domain or referer.
388 $check = $old_host === $request_host || $old_host === $referer_host;
389 $this->is_old_host_request = $check;
390
391 return $check;
392 }
393
394 /**
395 * Fake an old host request.
396 *
397 * @param bool $state Optional. The state to set. Default true.
398 */
399 public function set_old_host_request( $state = true ) {
400 $this->is_old_host_request = $state;
401 }
402
403 /**
404 * Maybe get a QuoteAuthorization object from a stamp.
405 *
406 * @return bool True if the object was prepared, false otherwise.
407 */
408 private function maybe_get_stamp() {
409 require_once ABSPATH . 'wp-admin/includes/post.php';
410
411 $stamp = \get_query_var( 'stamp' );
412 $meta = \get_post_meta_by_id( (int) $stamp );
413
414 if ( ! $meta ) {
415 return false;
416 }
417
418 $post = $this->get_queried_object();
419
420 /*
421 * Only quote-authorization meta may be reflected as a stamp, and only for the queried
422 * post. Checking the post id alone would still let an unauthenticated request read any
423 * of that post's meta rows (e.g. _edit_lock or private custom fields) by guessing a
424 * meta_id, so the meta key is verified too.
425 */
426 if ( '_activitypub_quoted_by' !== $meta->meta_key || (int) $meta->post_id !== $post->ID ) {
427 return false;
428 }
429
430 $user_uri = get_user_id( $post->post_author );
431
432 if ( ! $user_uri ) {
433 return false;
434 }
435
436 $stamp_uri = \add_query_arg(
437 array(
438 'p' => $post->ID,
439 'stamp' => $meta->meta_id,
440 ),
441 \home_url( '/' )
442 );
443
444 $activitypub_object = new Quote_Authorization();
445 $activitypub_object->set_id( $stamp_uri );
446 $activitypub_object->set_attributed_to( $user_uri );
447 $activitypub_object->set_interacting_object( $meta->meta_value );
448 $activitypub_object->set_interaction_target( get_post_id( $post->ID ) );
449
450 $this->activitypub_object = $activitypub_object;
451 $this->activitypub_object_id = $activitypub_object->get_id();
452
453 return true;
454 }
455
456 /**
457 * Maybe get a FeatureAuthorization object from an actor-scoped stamp.
458 *
459 * Resolves URLs of the form `?actor=USER_ID&stamp=STAMP_ID` against the
460 * actor's stamp store, see {@see Feature_Request::get_stamp()}. Ownership
461 * is enforced by resolving the stamp scoped to the queried actor, which
462 * includes the blog actor (`actor=0`).
463 *
464 * @return bool True if a FeatureAuthorization was prepared, false otherwise.
465 */
466 private function maybe_get_actor_stamp() {
467 $stamp_id = (int) \get_query_var( 'stamp' );
468 $actor_var = \get_query_var( 'actor' );
469
470 if ( ! $stamp_id ) {
471 return false;
472 }
473
474 if ( '' === $actor_var ) {
475 $queried = $this->get_queried_object();
476 if ( ! $queried instanceof \WP_User ) {
477 return false;
478 }
479
480 $actor_id = (int) $queried->ID;
481 } else {
482 // Values like '0e1' or '1.5' pass is_numeric() but cast to 0/1 and alias
483 // an actor, so require a plain decimal integer before casting.
484 if ( ! \ctype_digit( (string) $actor_var ) ) {
485 return false;
486 }
487
488 $actor_id = (int) $actor_var;
489 }
490
491 $instrument = Feature_Request::get_stamp( $actor_id, $stamp_id );
492 if ( null === $instrument ) {
493 return false;
494 }
495
496 $actor = Actors::get_by_id( $actor_id );
497 if ( \is_wp_error( $actor ) ) {
498 return false;
499 }
500
501 $stamp_url = \add_query_arg(
502 array(
503 'actor' => $actor_id,
504 'stamp' => $stamp_id,
505 ),
506 \home_url( '/' )
507 );
508
509 $authorization = new Feature_Authorization();
510 $authorization->set_id( $stamp_url );
511 $authorization->set_attributed_to( $actor->get_id() );
512 $authorization->set_interacting_object( $instrument );
513 $authorization->set_interaction_target( $actor->get_id() );
514
515 $this->activitypub_object = $authorization;
516 $this->activitypub_object_id = $authorization->get_id();
517
518 return true;
519 }
520 }
521