PluginProbe
ActivityPub / 9.2.0
ActivityPub v9.2.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-query.php

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

520 lines 14.2 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 is_json_only_accept() 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 only when *every* media type is JSON; a client that also
314 * accepts HTML (e.g. a browser sending `text/html, application/activity+json`) gets the
315 * normal HTML page.
316 */
317 // 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.
318 if ( is_json_only_accept( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) ) {
319 \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
320 $this->is_activitypub_request = true;
321 }
322 }
323 }
324
325 /**
326 * Filters whether the current request is an ActivityPub request.
327 *
328 * @param bool $is_activitypub_request True if the request is an ActivityPub request, false otherwise.
329 */
330 return \apply_filters( 'activitypub_is_activitypub_request', $this->is_activitypub_request );
331 }
332
333 /**
334 * Check if content negotiation is allowed for a request.
335 *
336 * @return bool True if content negotiation is allowed, false otherwise.
337 */
338 public function should_negotiate_content() {
339 $return = false;
340 $always_negotiate = array( 'p', 'c', 'author', 'actor', 'stamp', 'preview', 'activitypub' );
341 $url = \wp_parse_url( $this->get_request_url(), PHP_URL_QUERY );
342 $query = array();
343 \wp_parse_str( $url, $query );
344
345 // Check if any of the query params are in the `$always_negotiate` array.
346 if ( \array_intersect( \array_keys( $query ), $always_negotiate ) ) {
347 $return = true;
348 }
349
350 if ( \get_option( 'activitypub_content_negotiation', '1' ) ) {
351 $return = true;
352 }
353
354 if ( \is_author() && \get_user_option( 'activitypub_use_permalink_as_id', \get_queried_object_id() ) ) {
355 $return = true;
356 }
357
358 /**
359 * Filters whether content negotiation should be forced.
360 *
361 * @param bool $return Whether content negotiation should be forced.
362 */
363 return \apply_filters( 'activitypub_should_negotiate_content', $return );
364 }
365
366 /**
367 * Check if the current request is from the old host.
368 *
369 * @return bool True if the request is from the old host, false otherwise.
370 */
371 public function is_old_host_request() {
372 if ( isset( $this->is_old_host_request ) ) {
373 return $this->is_old_host_request;
374 }
375
376 $old_host = \get_option( 'activitypub_old_host' );
377
378 if ( ! $old_host ) {
379 $this->is_old_host_request = false;
380 return false;
381 }
382
383 $request_host = isset( $_SERVER['HTTP_HOST'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
384 $referer_host = isset( $_SERVER['HTTP_REFERER'] ) ? \wp_parse_url( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : '';
385
386 // Check if the domain matches either the request domain or referer.
387 $check = $old_host === $request_host || $old_host === $referer_host;
388 $this->is_old_host_request = $check;
389
390 return $check;
391 }
392
393 /**
394 * Fake an old host request.
395 *
396 * @param bool $state Optional. The state to set. Default true.
397 */
398 public function set_old_host_request( $state = true ) {
399 $this->is_old_host_request = $state;
400 }
401
402 /**
403 * Maybe get a QuoteAuthorization object from a stamp.
404 *
405 * @return bool True if the object was prepared, false otherwise.
406 */
407 private function maybe_get_stamp() {
408 require_once ABSPATH . 'wp-admin/includes/post.php';
409
410 $stamp = \get_query_var( 'stamp' );
411 $meta = \get_post_meta_by_id( (int) $stamp );
412
413 if ( ! $meta ) {
414 return false;
415 }
416
417 $post = $this->get_queried_object();
418
419 /*
420 * Only quote-authorization meta may be reflected as a stamp, and only for the queried
421 * post. Checking the post id alone would still let an unauthenticated request read any
422 * of that post's meta rows (e.g. _edit_lock or private custom fields) by guessing a
423 * meta_id, so the meta key is verified too.
424 */
425 if ( '_activitypub_quoted_by' !== $meta->meta_key || (int) $meta->post_id !== $post->ID ) {
426 return false;
427 }
428
429 $user_uri = get_user_id( $post->post_author );
430
431 if ( ! $user_uri ) {
432 return false;
433 }
434
435 $stamp_uri = \add_query_arg(
436 array(
437 'p' => $post->ID,
438 'stamp' => $meta->meta_id,
439 ),
440 \home_url( '/' )
441 );
442
443 $activitypub_object = new Quote_Authorization();
444 $activitypub_object->set_id( $stamp_uri );
445 $activitypub_object->set_attributed_to( $user_uri );
446 $activitypub_object->set_interacting_object( $meta->meta_value );
447 $activitypub_object->set_interaction_target( get_post_id( $post->ID ) );
448
449 $this->activitypub_object = $activitypub_object;
450 $this->activitypub_object_id = $activitypub_object->get_id();
451
452 return true;
453 }
454
455 /**
456 * Maybe get a FeatureAuthorization object from an actor-scoped stamp.
457 *
458 * Resolves URLs of the form `?actor=USER_ID&stamp=STAMP_ID` against the
459 * actor's stamp store, see {@see Feature_Request::get_stamp()}. Ownership
460 * is enforced by resolving the stamp scoped to the queried actor, which
461 * includes the blog actor (`actor=0`).
462 *
463 * @return bool True if a FeatureAuthorization was prepared, false otherwise.
464 */
465 private function maybe_get_actor_stamp() {
466 $stamp_id = (int) \get_query_var( 'stamp' );
467 $actor_var = \get_query_var( 'actor' );
468
469 if ( ! $stamp_id ) {
470 return false;
471 }
472
473 if ( '' === $actor_var ) {
474 $queried = $this->get_queried_object();
475 if ( ! $queried instanceof \WP_User ) {
476 return false;
477 }
478
479 $actor_id = (int) $queried->ID;
480 } else {
481 // Values like '0e1' or '1.5' pass is_numeric() but cast to 0/1 and alias
482 // an actor, so require a plain decimal integer before casting.
483 if ( ! \ctype_digit( (string) $actor_var ) ) {
484 return false;
485 }
486
487 $actor_id = (int) $actor_var;
488 }
489
490 $instrument = Feature_Request::get_stamp( $actor_id, $stamp_id );
491 if ( null === $instrument ) {
492 return false;
493 }
494
495 $actor = Actors::get_by_id( $actor_id );
496 if ( \is_wp_error( $actor ) ) {
497 return false;
498 }
499
500 $stamp_url = \add_query_arg(
501 array(
502 'actor' => $actor_id,
503 'stamp' => $stamp_id,
504 ),
505 \home_url( '/' )
506 );
507
508 $authorization = new Feature_Authorization();
509 $authorization->set_id( $stamp_url );
510 $authorization->set_attributed_to( $actor->get_id() );
511 $authorization->set_interacting_object( $instrument );
512 $authorization->set_interaction_target( $actor->get_id() );
513
514 $this->activitypub_object = $authorization;
515 $this->activitypub_object_id = $authorization->get_id();
516
517 return true;
518 }
519 }
520