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 / handler / class-feature-request.php

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

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