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

class-quote-request.php in ActivityPub trunk, at includes/handler/class-quote-request.php

344 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 QuoteRequest activities.
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\Inbox;
14 use Activitypub\Collection\Remote_Actors;
15
16 use function Activitypub\add_to_outbox;
17 use function Activitypub\is_same_host;
18 use function Activitypub\object_to_uri;
19 use function Activitypub\user_can_activitypub;
20
21 /**
22 * Handler for QuoteRequest activities.
23 *
24 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
25 */
26 class Quote_Request {
27 /**
28 * Initialize the class, registering WordPress hooks.
29 */
30 public static function init() {
31 \add_action( 'activitypub_inbox_quote_request', array( self::class, 'handle_quote_request' ), 10, 2 );
32 \add_action( 'activitypub_rest_inbox_disallowed', array( self::class, 'handle_blocked_request' ), 10, 3 );
33 \add_action( 'delete_comment', array( self::class, 'handle_quote_delete' ), 10, 2 );
34
35 \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
36 }
37
38 /**
39 * Handle QuoteRequest activities.
40 *
41 * @param array $activity The activity object.
42 * @param int|int[] $user_ids The user ID(s).
43 */
44 public static function handle_quote_request( $activity, $user_ids ) {
45 $state = true;
46 $post_id = \url_to_postid( object_to_uri( $activity['object'] ) );
47 $post = $post_id ? \get_post( $post_id ) : null;
48
49 if ( ! $post ) {
50 $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
51 self::queue_reject( $activity, $user_id );
52 return;
53 }
54
55 // Use the post author as the responding actor — they own the quoted content.
56 $user_id = (int) $post->post_author;
57 $content_policy = \get_post_meta( $post_id, 'activitypub_interaction_policy_quote', true );
58
59 // Fall back to global default if not set.
60 if ( ! $content_policy ) {
61 $content_policy = \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE );
62 }
63
64 switch ( $content_policy ) {
65 case ACTIVITYPUB_INTERACTION_POLICY_ME:
66 self::queue_reject( $activity, $user_id );
67 $state = false;
68 break;
69 case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS:
70 $follower = Remote_Actors::get_by_uri( object_to_uri( $activity['actor'] ) );
71 if ( ! \is_wp_error( $follower ) && Followers::follows( $follower->ID, $user_id ) ) {
72 self::queue_accept( $activity, $user_id, $post_id );
73 } else {
74 self::queue_reject( $activity, $user_id );
75 $state = false;
76 }
77 break;
78 case ACTIVITYPUB_INTERACTION_POLICY_ANYONE:
79 default:
80 self::queue_accept( $activity, $user_id, $post_id );
81 break;
82 }
83
84 /**
85 * Fires after an ActivityPub QuoteRequest activity has been handled.
86 *
87 * @param array $activity The ActivityPub activity data.
88 * @param int[] $user_ids The local user IDs.
89 * @param bool $success True on success, false otherwise.
90 * @param string $content_policy The content policy for the quoted post.
91 */
92 \do_action( 'activitypub_handled_quote_request', $activity, (array) $user_ids, $state, $content_policy );
93 }
94
95 /**
96 * ActivityPub inbox disallowed activity.
97 *
98 * @param array $activity The activity array.
99 * @param int|int[]|null $user_ids The user ID(s).
100 * @param string $type The type of the activity.
101 */
102 public static function handle_blocked_request( $activity, $user_ids, $type ) {
103 if ( ! \in_array( \strtolower( $type ), array( 'quoterequest', 'quote_request' ), true ) ) {
104 return;
105 }
106
107 // Extract the user ID (quote requests are always for a single user).
108 $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
109
110 self::queue_reject( $activity, $user_id );
111 }
112
113 /**
114 * Handle deletion of a quote comment.
115 *
116 * When a local quote comment is deleted, send a Reject activity to revoke
117 * the previously accepted QuoteRequest.
118 *
119 * @param int $comment_id The comment ID being deleted.
120 * @param \WP_Comment|null $comment The comment object, or null if not available.
121 */
122 public static function handle_quote_delete( $comment_id, $comment ) {
123 // Try to get comment if not provided.
124 if ( ! $comment ) {
125 $comment = \get_comment( $comment_id );
126 }
127
128 // Only handle quote comments.
129 if ( ! $comment || 'quote' !== $comment->comment_type ) {
130 return;
131 }
132
133 // Get the post being quoted.
134 $post_id = $comment->comment_post_ID;
135 if ( ! $post_id ) {
136 return;
137 }
138
139 // Get the instrument URL (the quote post URL) from comment meta.
140 $instrument_url = \get_comment_meta( $comment_id, 'source_url', true );
141 if ( ! $instrument_url ) {
142 $instrument_url = \get_comment_meta( $comment_id, 'source_id', true );
143 }
144
145 if ( ! $instrument_url ) {
146 return;
147 }
148
149 // Get the post author (who accepted the quote).
150 $post = \get_post( $post_id );
151 if ( ! $post || ! $post->post_author ) {
152 return;
153 }
154
155 /*
156 * Try to retrieve the original QuoteRequest from the inbox.
157 * For QuoteRequest activities, the inbox stores the instrument URL
158 * in _activitypub_object_id, so we can query by that.
159 */
160 $activity_object = null;
161 $inbox_item = Inbox::get_by_type_and_object( 'QuoteRequest', $instrument_url );
162
163 if ( $inbox_item instanceof \WP_Post ) {
164 $activity_object = \json_decode( $inbox_item->post_content, true );
165 if ( JSON_ERROR_NONE !== \json_last_error() ) {
166 $activity_object = null;
167 }
168 }
169
170 // Fallback: If inbox item not found, reconstruct from available data.
171 if ( ! $activity_object ) {
172 $activity_object = array(
173 'type' => 'QuoteRequest',
174 'actor' => $comment->comment_author_url,
175 'object' => \get_permalink( $post_id ),
176 'instrument' => $instrument_url,
177 'published' => \gmdate( 'c' ),
178 );
179 }
180
181 // Remove from _activitypub_quoted_by meta.
182 \delete_post_meta( $post_id, '_activitypub_quoted_by', $instrument_url );
183
184 // Send Reject activity to revoke the quote permission.
185 self::queue_reject( $activity_object, $post->post_author );
186
187 /**
188 * Fires after a quote comment has been deleted and Reject activity sent.
189 *
190 * @param int $comment_id The deleted comment ID.
191 * @param int $post_id The post ID that was quoted.
192 * @param string $instrument_url The instrument URL (quote post).
193 * @param array $activity_object The QuoteRequest activity that was rejected.
194 */
195 \do_action( 'activitypub_quote_comment_deleted', $comment_id, $post_id, $instrument_url, $activity_object );
196 }
197
198 /**
199 * Send an Accept activity in response to the QuoteRequest.
200 *
201 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md#example-accept
202 *
203 * @param array $activity_object The activity object.
204 * @param int $user_id The user ID.
205 * @param int $post_id The post ID.
206 */
207 public static function queue_accept( $activity_object, $user_id, $post_id ) {
208 // Fall back to the blog actor if the user has ActivityPub disabled.
209 if ( ! user_can_activitypub( $user_id ) ) {
210 $user_id = Actors::BLOG_USER_ID;
211 }
212
213 $actor = Actors::get_by_id( $user_id );
214
215 if ( \is_wp_error( $actor ) ) {
216 return;
217 }
218
219 $activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
220
221 $post_meta = \get_post_meta( $post_id, '_activitypub_quoted_by', false );
222 if ( \in_array( $activity_object['instrument'], $post_meta, true ) ) {
223 global $wpdb;
224
225 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
226 $meta_id = $wpdb->get_var(
227 $wpdb->prepare(
228 "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_value = %s LIMIT 1",
229 $post_id,
230 '_activitypub_quoted_by',
231 $activity_object['instrument']
232 )
233 );
234 } else {
235 $meta_id = \add_post_meta( $post_id, '_activitypub_quoted_by', $activity_object['instrument'] );
236 }
237
238 // Only send minimal data.
239 $activity_object = \array_intersect_key(
240 $activity_object,
241 array(
242 'id' => 1,
243 'type' => 1,
244 'actor' => 1,
245 'object' => 1,
246 'instrument' => 1,
247 )
248 );
249
250 $url = \add_query_arg(
251 array(
252 'p' => $post_id,
253 'stamp' => $meta_id,
254 ),
255 \home_url( '/' )
256 );
257
258 $activity = new Activity();
259 $activity->set_type( 'Accept' );
260 $activity->set_actor( $actor->get_id() );
261 $activity->set_object( $activity_object );
262 $activity->set_result( $url );
263 $activity->add_to( object_to_uri( $activity_object['actor'] ) );
264
265 add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
266 }
267
268 /**
269 * Send a Reject activity in response to the QuoteRequest.
270 *
271 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md#example-reject
272 *
273 * @param array $activity_object The activity object.
274 * @param int $user_id The user ID.
275 */
276 public static function queue_reject( $activity_object, $user_id ) {
277 // Fall back to the blog actor if the user has ActivityPub disabled.
278 if ( ! user_can_activitypub( $user_id ) ) {
279 $user_id = Actors::BLOG_USER_ID;
280 }
281
282 $actor = Actors::get_by_id( $user_id );
283
284 if ( \is_wp_error( $actor ) ) {
285 return;
286 }
287
288 $activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
289
290 // Only send minimal data.
291 $activity_object = \array_intersect_key(
292 $activity_object,
293 array(
294 'id' => 1,
295 'type' => 1,
296 'actor' => 1,
297 'object' => 1,
298 'instrument' => 1,
299 )
300 );
301
302 $activity = new Activity();
303 $activity->set_type( 'Reject' );
304 $activity->set_actor( $actor->get_id() );
305 $activity->set_object( $activity_object );
306 $activity->add_to( object_to_uri( $activity_object['actor'] ) );
307
308 add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
309 }
310
311 /**
312 * Validate the object.
313 *
314 * @param bool $valid The validation state.
315 * @param string $param The object parameter.
316 * @param \WP_REST_Request $request The request object.
317 *
318 * @return bool The validation state: true if valid, false if not.
319 */
320 public static function validate_object( $valid, $param, $request ) {
321 $activity = $request->get_json_params();
322
323 if ( empty( $activity['type'] ) ) {
324 return false;
325 }
326
327 if ( 'QuoteRequest' !== $activity['type'] ) {
328 return $valid;
329 }
330
331 if ( ! isset( $activity['actor'], $activity['object'], $activity['instrument'] ) ) {
332 return false;
333 }
334
335 // The instrument is the quoting object, authored by the actor, so it must live on the
336 // actor's host. Otherwise a remote server could bind a third-party reference to a local post.
337 if ( ! is_same_host( $activity['actor'], $activity['instrument'] ) ) {
338 return false;
339 }
340
341 return $valid;
342 }
343 }
344