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-query.php

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

528 lines 14.9 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 /*
211 * Check Term by ID, unless the request names an author. Other plugins set `term_id` on
212 * requests that are not about a term at all, Polylang puts its language term on every
213 * request, and a term here would answer `?author=0` with an OrderedCollection where
214 * Mastodon expected the blog actor. Leaving the object unset lets the author resolution
215 * below, and the blog-actor handling in get_activitypub_object_id(), run as usual. An
216 * absent `author` reads as an empty string, and `?author=0` as the string "0".
217 */
218 if ( ! $queried_object && '' === \get_query_var( 'author', '' ) ) {
219 $term_id = \get_query_var( 'term_id' );
220 if ( $term_id ) {
221 $queried_object = \get_term( $term_id );
222 }
223 }
224
225 // Try to get Author by ID.
226 if ( ! $queried_object ) {
227 $url = $this->get_request_url();
228 $author_id = url_to_authorid( $url );
229 if ( $author_id ) {
230 $queried_object = \get_user_by( 'id', $author_id );
231 }
232 }
233
234 /**
235 * Filters the queried object.
236 *
237 * @param \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null $queried_object The queried object.
238 */
239 return \apply_filters( 'activitypub_queried_object', $queried_object );
240 }
241
242 /**
243 * Get the virtual object.
244 *
245 * Virtual objects are objects that are not stored in the database, but are created on the fly.
246 * The plugin currently supports one virtual object: The Blog-Actor.
247 *
248 * @see \Activitypub\Model\Blog
249 *
250 * @return object|null The virtual object.
251 */
252 protected function maybe_get_virtual_object() {
253 $url = $this->get_request_url();
254
255 if ( ! $url ) {
256 return null;
257 }
258
259 $author_id = url_to_authorid( $url );
260
261 if ( ! \is_numeric( $author_id ) ) {
262 $author_id = $url;
263 }
264
265 $user = Actors::get_by_various( $author_id );
266
267 if ( \is_wp_error( $user ) || ! $user ) {
268 return null;
269 }
270
271 return $user;
272 }
273
274 /**
275 * Get the request URL.
276 *
277 * @return string|null The request URL.
278 */
279 public function get_request_url() {
280 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
281 return null;
282 }
283
284 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
285 $url = \wp_unslash( $_SERVER['REQUEST_URI'] );
286 $url = \WP_Http::make_absolute_url( $url, \home_url() );
287 $url = \sanitize_url( $url );
288
289 return $url;
290 }
291
292 /**
293 * Check if the current request is an ActivityPub request.
294 *
295 * @return bool True if the request is an ActivityPub request, false otherwise.
296 */
297 public function is_activitypub_request() {
298 if ( ! isset( $this->is_activitypub_request ) ) {
299 global $wp_query;
300
301 $this->is_activitypub_request = false;
302
303 // One can trigger an ActivityPub request by adding `?activitypub` to the URL.
304 if ( isset( $wp_query->query_vars['activitypub'] ) || isset( $_GET['activitypub'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
305 \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
306 $this->is_activitypub_request = true;
307
308 // The other (more common) option to make an ActivityPub request is to send an Accept header.
309 } elseif ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
310 /*
311 * The Accept-header decision is delegated to accept_prefers_activitypub() so the plugin and the
312 * Surge cache drop-in classify byte-for-byte identically. Both must hand it the same raw
313 * header, and they reach that raw form differently on purpose: this runs after
314 * wp_magic_quotes() has addslashed $_SERVER, so it wp_unslash()es to recover the original
315 * bytes; the drop-in runs before wp_magic_quotes() and passes its already-raw value
316 * untouched. Do NOT sanitize it (the drop-in can't, its sanitizers aren't loaded yet) and
317 * the helper must not stripslashes() either (that would corrupt the drop-in's genuine
318 * bytes). It is only used to pick a content type, never stored or echoed.
319 *
320 * The request is ActivityPub when the highest-priority (by `q`, then order) media type is
321 * an ActivityPub type (`application/activity+json`, or `application/ld+json` with the AS2
322 * profile). A browser (`text/html` at q=1) gets the normal page; a client that prefers
323 * ActivityPub but also accepts HTML as a low-`q` fallback (Mastodon) gets ActivityPub.
324 */
325 // 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.
326 if ( accept_prefers_activitypub( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) ) {
327 \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
328 $this->is_activitypub_request = true;
329 }
330 }
331 }
332
333 /**
334 * Filters whether the current request is an ActivityPub request.
335 *
336 * @param bool $is_activitypub_request True if the request is an ActivityPub request, false otherwise.
337 */
338 return \apply_filters( 'activitypub_is_activitypub_request', $this->is_activitypub_request );
339 }
340
341 /**
342 * Check if content negotiation is allowed for a request.
343 *
344 * @return bool True if content negotiation is allowed, false otherwise.
345 */
346 public function should_negotiate_content() {
347 $return = false;
348 $always_negotiate = array( 'p', 'c', 'author', 'actor', 'stamp', 'preview', 'activitypub' );
349 $url = \wp_parse_url( $this->get_request_url(), PHP_URL_QUERY );
350 $query = array();
351 \wp_parse_str( $url, $query );
352
353 // Check if any of the query params are in the `$always_negotiate` array.
354 if ( \array_intersect( \array_keys( $query ), $always_negotiate ) ) {
355 $return = true;
356 }
357
358 if ( \get_option( 'activitypub_content_negotiation', '1' ) ) {
359 $return = true;
360 }
361
362 if ( \is_author() && \get_user_option( 'activitypub_use_permalink_as_id', \get_queried_object_id() ) ) {
363 $return = true;
364 }
365
366 /**
367 * Filters whether content negotiation should be forced.
368 *
369 * @param bool $return Whether content negotiation should be forced.
370 */
371 return \apply_filters( 'activitypub_should_negotiate_content', $return );
372 }
373
374 /**
375 * Check if the current request is from the old host.
376 *
377 * @return bool True if the request is from the old host, false otherwise.
378 */
379 public function is_old_host_request() {
380 if ( isset( $this->is_old_host_request ) ) {
381 return $this->is_old_host_request;
382 }
383
384 $old_host = \get_option( 'activitypub_old_host' );
385
386 if ( ! $old_host ) {
387 $this->is_old_host_request = false;
388 return false;
389 }
390
391 $request_host = isset( $_SERVER['HTTP_HOST'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
392 $referer_host = isset( $_SERVER['HTTP_REFERER'] ) ? \wp_parse_url( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : '';
393
394 // Check if the domain matches either the request domain or referer.
395 $check = $old_host === $request_host || $old_host === $referer_host;
396 $this->is_old_host_request = $check;
397
398 return $check;
399 }
400
401 /**
402 * Fake an old host request.
403 *
404 * @param bool $state Optional. The state to set. Default true.
405 */
406 public function set_old_host_request( $state = true ) {
407 $this->is_old_host_request = $state;
408 }
409
410 /**
411 * Maybe get a QuoteAuthorization object from a stamp.
412 *
413 * @return bool True if the object was prepared, false otherwise.
414 */
415 private function maybe_get_stamp() {
416 require_once ABSPATH . 'wp-admin/includes/post.php';
417
418 $stamp = \get_query_var( 'stamp' );
419 $meta = \get_post_meta_by_id( (int) $stamp );
420
421 if ( ! $meta ) {
422 return false;
423 }
424
425 $post = $this->get_queried_object();
426
427 /*
428 * Only quote-authorization meta may be reflected as a stamp, and only for the queried
429 * post. Checking the post id alone would still let an unauthenticated request read any
430 * of that post's meta rows (e.g. _edit_lock or private custom fields) by guessing a
431 * meta_id, so the meta key is verified too.
432 */
433 if ( '_activitypub_quoted_by' !== $meta->meta_key || (int) $meta->post_id !== $post->ID ) {
434 return false;
435 }
436
437 $user_uri = get_user_id( $post->post_author );
438
439 if ( ! $user_uri ) {
440 return false;
441 }
442
443 $stamp_uri = \add_query_arg(
444 array(
445 'p' => $post->ID,
446 'stamp' => $meta->meta_id,
447 ),
448 \home_url( '/' )
449 );
450
451 $activitypub_object = new Quote_Authorization();
452 $activitypub_object->set_id( $stamp_uri );
453 $activitypub_object->set_attributed_to( $user_uri );
454 $activitypub_object->set_interacting_object( $meta->meta_value );
455 $activitypub_object->set_interaction_target( get_post_id( $post->ID ) );
456
457 $this->activitypub_object = $activitypub_object;
458 $this->activitypub_object_id = $activitypub_object->get_id();
459
460 return true;
461 }
462
463 /**
464 * Maybe get a FeatureAuthorization object from an actor-scoped stamp.
465 *
466 * Resolves URLs of the form `?actor=USER_ID&stamp=STAMP_ID` against the
467 * actor's stamp store, see {@see Feature_Request::get_stamp()}. Ownership
468 * is enforced by resolving the stamp scoped to the queried actor, which
469 * includes the blog actor (`actor=0`).
470 *
471 * @return bool True if a FeatureAuthorization was prepared, false otherwise.
472 */
473 private function maybe_get_actor_stamp() {
474 $stamp_id = (int) \get_query_var( 'stamp' );
475 $actor_var = \get_query_var( 'actor' );
476
477 if ( ! $stamp_id ) {
478 return false;
479 }
480
481 if ( '' === $actor_var ) {
482 $queried = $this->get_queried_object();
483 if ( ! $queried instanceof \WP_User ) {
484 return false;
485 }
486
487 $actor_id = (int) $queried->ID;
488 } else {
489 // Values like '0e1' or '1.5' pass is_numeric() but cast to 0/1 and alias
490 // an actor, so require a plain decimal integer before casting.
491 if ( ! \ctype_digit( (string) $actor_var ) ) {
492 return false;
493 }
494
495 $actor_id = (int) $actor_var;
496 }
497
498 $instrument = Feature_Request::get_stamp( $actor_id, $stamp_id );
499 if ( null === $instrument ) {
500 return false;
501 }
502
503 $actor = Actors::get_by_id( $actor_id );
504 if ( \is_wp_error( $actor ) ) {
505 return false;
506 }
507
508 $stamp_url = \add_query_arg(
509 array(
510 'actor' => $actor_id,
511 'stamp' => $stamp_id,
512 ),
513 \home_url( '/' )
514 );
515
516 $authorization = new Feature_Authorization();
517 $authorization->set_id( $stamp_url );
518 $authorization->set_attributed_to( $actor->get_id() );
519 $authorization->set_interacting_object( $instrument );
520 $authorization->set_interaction_target( $actor->get_id() );
521
522 $this->activitypub_object = $authorization;
523 $this->activitypub_object_id = $authorization->get_id();
524
525 return true;
526 }
527 }
528