PluginProbe
ActivityPub / 9.3.1
ActivityPub v9.3.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 / handler / class-feature-request.php

class-feature-request.php in ActivityPub 9.3.1, at includes/handler/class-feature-request.php

358 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handler for FeatureRequest activities (FEP-7aa9).
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Handler;
9
10 use Activitypub\Activity\Activity;
11 use Activitypub\Collection\Actors;
12 use Activitypub\Collection\Followers;
13 use Activitypub\Collection\Remote_Actors;
14
15 use function Activitypub\add_to_outbox;
16 use function Activitypub\is_same_host;
17 use function Activitypub\object_to_uri;
18 use function Activitypub\user_can_activitypub;
19
20 /**
21 * Handler for FeatureRequest activities.
22 *
23 * @see https://w3id.org/fep/7aa9
24 */
25 class Feature_Request {
26
27 /**
28 * Option-name prefix for the blog actor's feature stamps.
29 *
30 * The blog actor has no users-table row (its ID is 0), so its stamps cannot
31 * live in user meta like the stamps of regular users do. Each stamp is stored
32 * in its own option row, keyed `{prefix}_{id}`, so a stamp ID can be claimed
33 * atomically with `add_option()` without a lock.
34 *
35 * @var string
36 */
37 const BLOG_STAMPS_OPTION = 'activitypub_blog_featured_by';
38
39 /**
40 * Initialize the class, registering WordPress hooks.
41 */
42 public static function init() {
43 \add_action( 'activitypub_inbox_feature_request', array( self::class, 'handle_feature_request' ), 10, 2 );
44 \add_action( 'activitypub_rest_inbox_disallowed', array( self::class, 'handle_blocked_request' ), 10, 3 );
45
46 \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
47 }
48
49 /**
50 * Handle FeatureRequest activities.
51 *
52 * @param array $activity The activity object.
53 * @param int|int[] $user_ids The user ID(s) targeted by the inbox dispatch.
54 */
55 public static function handle_feature_request( $activity, $user_ids ) {
56 $state = true;
57 $object_uri = object_to_uri( $activity['object'] );
58 $target = Actors::get_by_resource( $object_uri );
59
60 if ( \is_wp_error( $target ) ) {
61 $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
62 self::queue_reject( $activity, $user_id );
63 return;
64 }
65
66 $user_id = $target->get__id();
67
68 $policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME );
69
70 switch ( $policy ) {
71 case ACTIVITYPUB_INTERACTION_POLICY_ANYONE:
72 self::queue_accept( $activity, $user_id );
73 break;
74
75 case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS:
76 $follower = Remote_Actors::get_by_uri( object_to_uri( $activity['actor'] ) );
77 if ( ! \is_wp_error( $follower ) && Followers::follows( $follower->ID, $user_id ) ) {
78 self::queue_accept( $activity, $user_id );
79 } else {
80 self::queue_reject( $activity, $user_id );
81 $state = false;
82 }
83 break;
84
85 case ACTIVITYPUB_INTERACTION_POLICY_ME:
86 default:
87 self::queue_reject( $activity, $user_id );
88 $state = false;
89 break;
90 }
91
92 /**
93 * Fires after an ActivityPub FeatureRequest activity has been handled.
94 *
95 * @param array $activity The ActivityPub activity data.
96 * @param int[] $user_ids The local user IDs.
97 * @param bool $state True on accept, false otherwise.
98 * @param string $policy The active site policy.
99 */
100 \do_action( 'activitypub_handled_feature_request', $activity, (array) $user_ids, $state, $policy );
101 }
102
103 /**
104 * ActivityPub inbox disallowed activity.
105 *
106 * @param array $activity The activity array.
107 * @param int|int[]|null $user_ids The user ID(s).
108 * @param string $type The activity type.
109 */
110 public static function handle_blocked_request( $activity, $user_ids, $type ) {
111 if ( ! \in_array( \strtolower( $type ), array( 'featurerequest', 'feature_request' ), true ) ) {
112 return;
113 }
114
115 $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
116 self::queue_reject( $activity, $user_id );
117 }
118
119 /**
120 * Send an Accept activity in response to the FeatureRequest, issuing a stamp.
121 *
122 * Idempotent: a second call with the same instrument for the same actor reuses
123 * the existing stamp instead of minting a duplicate, see {@see add_stamp()}.
124 *
125 * @param array $activity_object The activity object.
126 * @param int $user_id The local user ID being featured (0 for the blog actor).
127 */
128 public static function queue_accept( $activity_object, $user_id ) {
129 if ( ! user_can_activitypub( $user_id ) ) {
130 $user_id = Actors::BLOG_USER_ID;
131 }
132
133 $actor = Actors::get_by_id( $user_id );
134 if ( \is_wp_error( $actor ) ) {
135 return;
136 }
137
138 $activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
139
140 $stamp_id = self::add_stamp( $user_id, $activity_object['instrument'] );
141 if ( ! $stamp_id ) {
142 // Without a stamp there is nothing to consent with — don't send a dangling Accept.
143 return;
144 }
145
146 // Send minimal activity object back.
147 $activity_object = \array_intersect_key(
148 $activity_object,
149 array(
150 'id' => 1,
151 'type' => 1,
152 'actor' => 1,
153 'object' => 1,
154 'instrument' => 1,
155 )
156 );
157
158 $stamp_url = \add_query_arg(
159 array(
160 'actor' => $user_id,
161 'stamp' => $stamp_id,
162 ),
163 \home_url( '/' )
164 );
165
166 $activity = new Activity();
167 $activity->set_type( 'Accept' );
168 $activity->set_actor( $actor->get_id() );
169 $activity->set_object( $activity_object );
170 $activity->set_result( $stamp_url );
171 $activity->add_to( object_to_uri( $activity_object['actor'] ) );
172
173 add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
174 }
175
176 /**
177 * Create (or reuse) a feature stamp for an actor.
178 *
179 * Stamps for users live in user meta, with the umeta_id doubling as the
180 * stamp ID. The blog actor has no users-table row, so each of its stamps is
181 * stored in its own option row instead.
182 *
183 * Idempotent: an existing stamp for the same instrument is reused.
184 *
185 * @since 9.0.1
186 *
187 * @param int $user_id The local actor ID (0 for the blog actor).
188 * @param string $instrument The instrument URI being stamped.
189 * @return int|false The stamp ID, or false on failure.
190 */
191 public static function add_stamp( $user_id, $instrument ) {
192 if ( Actors::BLOG_USER_ID === $user_id ) {
193 return self::add_blog_stamp( $instrument );
194 }
195
196 $existing = \get_user_meta( $user_id, '_activitypub_featured_by', false );
197 if ( \in_array( $instrument, (array) $existing, true ) ) {
198 global $wpdb;
199 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
200 return (int) $wpdb->get_var(
201 $wpdb->prepare(
202 "SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s AND meta_value = %s LIMIT 1",
203 $user_id,
204 '_activitypub_featured_by',
205 $instrument
206 )
207 );
208 }
209
210 return \add_user_meta( $user_id, '_activitypub_featured_by', $instrument );
211 }
212
213 /**
214 * Create (or reuse) a feature stamp for the blog actor.
215 *
216 * Each blog stamp lives in its own option row (`{prefix}_{id}`), so a slot is
217 * claimed atomically with `add_option()` — which only inserts when the row is
218 * absent (see Scheduler\Statistics::send_annual_email()). Concurrent inbox
219 * deliveries therefore can't mint the same ID or clobber each other's writes,
220 * and no lock is needed. Stamps are never deleted, so the slots stay gap-free
221 * and the walk stops at the first empty slot.
222 *
223 * @param string $instrument The instrument URI being stamped.
224 * @return int|false The stamp ID, or false on failure.
225 */
226 private static function add_blog_stamp( $instrument ) {
227 $stamp_id = 1;
228
229 /*
230 * Bounded far above any realistic blog stamp count, to guard against a
231 * pathological object-cache state where a just-claimed slot never becomes
232 * visible and the re-read would otherwise spin forever.
233 */
234 for ( $attempt = 0; $attempt < 10000; $attempt++ ) {
235 $key = self::BLOG_STAMPS_OPTION . '_' . $stamp_id;
236 $current = \get_option( $key );
237
238 // Idempotent: an existing stamp for the same instrument is reused.
239 if ( $current === $instrument ) {
240 return $stamp_id;
241 }
242
243 if ( false === $current ) {
244 if ( \add_option( $key, $instrument, '', false ) ) {
245 return $stamp_id;
246 }
247
248 // A concurrent accept claimed this slot first; re-read it (no
249 // increment) to see whether it took our instrument or another.
250 continue;
251 }
252
253 // Slot holds a different instrument; try the next one.
254 ++$stamp_id;
255 }
256
257 return false;
258 }
259
260 /**
261 * Resolve a stamp ID for an actor to the stamped instrument URI.
262 *
263 * @since 9.0.1
264 *
265 * @param int $user_id The local actor ID (0 for the blog actor).
266 * @param int $stamp_id The stamp ID.
267 * @return string|null The instrument URI, or null if the stamp does not exist for this actor.
268 */
269 public static function get_stamp( $user_id, $stamp_id ) {
270 if ( Actors::BLOG_USER_ID === $user_id ) {
271 $instrument = \get_option( self::BLOG_STAMPS_OPTION . '_' . (int) $stamp_id );
272
273 return false === $instrument ? null : $instrument;
274 }
275
276 $meta = \get_metadata_by_mid( 'user', $stamp_id );
277 if ( ! $meta || '_activitypub_featured_by' !== $meta->meta_key || (int) $meta->user_id !== $user_id ) {
278 return null;
279 }
280
281 return $meta->meta_value;
282 }
283
284 /**
285 * Send a Reject activity in response to the FeatureRequest.
286 *
287 * @param array $activity_object The activity object.
288 * @param int $user_id The user ID.
289 */
290 public static function queue_reject( $activity_object, $user_id ) {
291 if ( ! user_can_activitypub( $user_id ) ) {
292 $user_id = Actors::BLOG_USER_ID;
293 }
294
295 $actor = Actors::get_by_id( $user_id );
296 if ( \is_wp_error( $actor ) ) {
297 return;
298 }
299
300 if ( isset( $activity_object['instrument'] ) ) {
301 $activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
302 }
303
304 // Only send minimal data.
305 $activity_object = \array_intersect_key(
306 $activity_object,
307 array(
308 'id' => 1,
309 'type' => 1,
310 'actor' => 1,
311 'object' => 1,
312 'instrument' => 1,
313 )
314 );
315
316 $activity = new Activity();
317 $activity->set_type( 'Reject' );
318 $activity->set_actor( $actor->get_id() );
319 $activity->set_object( $activity_object );
320 $activity->add_to( object_to_uri( $activity_object['actor'] ) );
321
322 add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
323 }
324
325 /**
326 * Validate the object on incoming FeatureRequest activities.
327 *
328 * @param bool $valid The current validation state.
329 * @param string $param The object parameter name.
330 * @param \WP_REST_Request $request The request object.
331 *
332 * @return bool
333 */
334 public static function validate_object( $valid, $param, $request ) {
335 $activity = $request->get_json_params();
336
337 if ( empty( $activity['type'] ) ) {
338 return false;
339 }
340
341 if ( 'FeatureRequest' !== $activity['type'] ) {
342 return $valid;
343 }
344
345 if ( ! isset( $activity['actor'], $activity['object'], $activity['instrument'] ) ) {
346 return false;
347 }
348
349 // The instrument is the requester's own object, so it must live on the actor's host.
350 // Otherwise a remote server could stamp a local actor with a third-party reference.
351 if ( ! is_same_host( $activity['actor'], $activity['instrument'] ) ) {
352 return false;
353 }
354
355 return $valid;
356 }
357 }
358