PluginProbe
ActivityPub / 9.2.2
ActivityPub v9.2.2
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-tombstone.php

class-tombstone.php in ActivityPub 9.2.2, at includes/class-tombstone.php

447 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Tombstone class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Activity\Base_Object;
11
12 /**
13 * ActivityPub Tombstone Class.
14 *
15 * Handles detection and management of tombstoned (deleted) ActivityPub resources.
16 * A tombstone in ActivityPub represents a deleted object that was previously available.
17 * This class provides methods to detect tombstones across various data formats including
18 * URLs, ActivityPub objects, arrays, and WordPress error responses.
19 *
20 * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
21 */
22 class Tombstone {
23 /**
24 * HTTP status codes that indicate a tombstoned resource.
25 *
26 * - 404: Not Found - Resource no longer exists
27 * - 410: Gone - Resource was intentionally removed
28 *
29 * @var int[] Array of HTTP status codes indicating tombstones.
30 */
31 private static $codes = array( 404, 410 );
32
33 /**
34 * The custom post type used to store local tombstones.
35 *
36 * @var string
37 */
38 const POST_TYPE = 'ap_tombstone';
39
40 /**
41 * Check if a tombstone exists for the given resource.
42 *
43 * This is the main entry point for tombstone detection. It accepts various
44 * data types and routes them to the appropriate checking method:
45 * - URLs (string): Checks remote or local tombstone status
46 * - WP_Error objects: Checks for tombstone-indicating HTTP status codes
47 * - Arrays: Checks for ActivityPub Tombstone type
48 * - Objects: Checks for ActivityPub Tombstone type or Base_Object instances
49 *
50 * @param string|\WP_Error|array|object $various The resource data to check for tombstone status.
51 * Can be a URL, error object, ActivityPub array, or object.
52 *
53 * @return bool True if the resource is tombstoned, false otherwise.
54 */
55 public static function exists( $various ) {
56 if ( \is_wp_error( $various ) ) {
57 return self::exists_in_error( $various );
58 }
59
60 if ( \is_string( $various ) ) {
61 if ( is_same_domain( $various ) ) {
62 return self::exists_local( $various );
63 }
64 return self::exists_remote( $various );
65 }
66
67 if ( \is_array( $various ) ) {
68 return self::check_array( $various );
69 }
70
71 if ( \is_object( $various ) ) {
72 return self::check_object( $various );
73 }
74
75 return false;
76 }
77
78 /**
79 * Check if a remote URL is tombstoned.
80 *
81 * Makes an HTTP request to the remote URL with ActivityPub headers
82 * and checks for tombstone indicators:
83 * - HTTP 404/410 status codes
84 * - ActivityPub Tombstone object type in response body
85 *
86 * @param string $url The remote URL to check for tombstone status.
87 *
88 * @return bool True if the remote URL is tombstoned, false otherwise.
89 */
90 public static function exists_remote( $url ) {
91 /**
92 * Fires before checking if the URL is a tombstone.
93 *
94 * @param string $url The URL to check.
95 */
96 \do_action( 'activitypub_pre_http_is_tombstone', $url );
97
98 $response = Http::get( $url );
99
100 if ( ! \is_wp_error( $response ) ) {
101 $data = \wp_remote_retrieve_body( $response );
102 $data = \json_decode( $data, true );
103
104 return self::check_array( $data );
105 }
106
107 if ( \in_array( (int) $response->get_error_code(), self::$codes, true ) ) {
108 return true;
109 }
110
111 return false;
112 }
113
114 /**
115 * Check if a local URL is tombstoned.
116 *
117 * Matches by the MD5 hash of the normalized URL stored in `post_name`.
118 * Falls back to the legacy `activitypub_tombstone_urls` option for
119 * tombstones that have not yet been migrated.
120 *
121 * @param string $url The local URL to check for tombstone status.
122 *
123 * @return bool True if the local URL is tombstoned, false otherwise.
124 */
125 public static function exists_local( $url ) {
126 if ( ! \is_string( $url ) || '' === $url ) {
127 return false;
128 }
129
130 $normalized = normalize_url( $url );
131
132 if ( ! empty( self::find_post_ids_by_url( $normalized ) ) ) {
133 return true;
134 }
135
136 /*
137 * Fallback to the legacy option during migration. Once the option is
138 * deleted (migration complete), get_option returns false and the
139 * is_array() guard short-circuits immediately.
140 */
141 $legacy = \get_option( 'activitypub_tombstone_urls', false );
142 if ( \is_array( $legacy ) && \in_array( $normalized, $legacy, true ) ) {
143 return true;
144 }
145
146 return false;
147 }
148
149 /**
150 * Check if a WP_Error object indicates a tombstoned resource.
151 *
152 * Examines the error data for HTTP status codes that indicate tombstones.
153 * This is typically used when HTTP requests return error responses.
154 *
155 * @param \WP_Error $wp_error The WordPress error object to examine.
156 *
157 * @return bool True if the error indicates a tombstoned resource, false otherwise.
158 */
159 public static function exists_in_error( $wp_error ) {
160 if ( ! \is_wp_error( $wp_error ) ) {
161 return false;
162 }
163
164 $data = $wp_error->get_error_data();
165 if ( isset( $data['status'] ) && \in_array( (int) $data['status'], self::$codes, true ) ) {
166 return true;
167 }
168
169 return false;
170 }
171
172 /**
173 * Check if an array represents an ActivityPub Tombstone object.
174 *
175 * Examines the array for the ActivityPub 'type' property set to 'Tombstone'.
176 * This follows the ActivityStreams specification for tombstone objects.
177 *
178 * @param array|mixed $data The array data to check. Non-arrays return false.
179 *
180 * @return bool True if the array represents a Tombstone object, false otherwise.
181 */
182 private static function check_array( $data ) {
183 if ( ! \is_array( $data ) ) {
184 return false;
185 }
186
187 if ( isset( $data['type'] ) && 'Tombstone' === $data['type'] ) {
188 return true;
189 }
190
191 return false;
192 }
193
194 /**
195 * Check if an object represents an ActivityPub Tombstone.
196 *
197 * Checks for tombstone indicators in objects:
198 * - Standard objects: 'type' property set to 'Tombstone'
199 * - Base_Object instances: Uses get_type() method to check for 'Tombstone'
200 *
201 * @param object|mixed $data The object data to check. Non-objects return false.
202 *
203 * @return bool True if the object represents a Tombstone, false otherwise.
204 */
205 private static function check_object( $data ) {
206 if ( ! \is_object( $data ) ) {
207 return false;
208 }
209
210 if ( isset( $data->type ) && 'Tombstone' === $data->type ) {
211 return true;
212 }
213
214 if ( $data instanceof Base_Object && 'Tombstone' === $data->get_type() ) {
215 return true;
216 }
217
218 return false;
219 }
220
221 /**
222 * Look up tombstone post IDs by canonical URL.
223 *
224 * The MD5 of the normalized URL is unique per URL, so a successful
225 * `bury()` produces exactly one row and the canonical lookup is enough.
226 *
227 * @since 8.3.0
228 *
229 * @param string $normalized The normalized URL (scheme stripped).
230 * @return int[] Post IDs (zero or one entry under normal operation).
231 */
232 private static function find_post_ids_by_url( $normalized ) {
233 global $wpdb;
234
235 /*
236 * `bury()` is idempotent on the MD5 slug, so a successful insert
237 * produces exactly one row per URL. `LIMIT 1` matches that invariant
238 * and keeps the query cheap on the hot `exists_local()` path.
239 */
240 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
241 $ids = $wpdb->get_col(
242 $wpdb->prepare(
243 "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_name = %s LIMIT 1",
244 self::POST_TYPE,
245 \md5( $normalized )
246 )
247 );
248
249 return \array_map( 'intval', $ids );
250 }
251
252 /**
253 * Add one or more URLs to the local tombstone registry.
254 *
255 * "Buries" URLs by adding them to the local tombstone URL registry.
256 * URLs are normalized before storage; duplicate calls for the same URL
257 * are a no-op because the `post_name` slug is the MD5 of the
258 * normalized URL.
259 *
260 * @param string ...$urls The URLs to add to the tombstone registry.
261 */
262 public static function bury( ...$urls ) {
263 foreach ( $urls as $url ) {
264 if ( ! \filter_var( $url, \FILTER_VALIDATE_URL ) ) {
265 continue;
266 }
267
268 $normalized = normalize_url( $url );
269
270 if ( ! empty( self::find_post_ids_by_url( $normalized ) ) ) {
271 continue;
272 }
273
274 /*
275 * Store the original URL in `guid` so it is human-readable and
276 * survives `esc_url()` without scheme mangling. The hash slug
277 * in `post_name` is what we actually key lookups on.
278 */
279 $post_id = \wp_insert_post(
280 array(
281 'post_type' => self::POST_TYPE,
282 'post_status' => 'publish',
283 'post_name' => \md5( $normalized ),
284 'guid' => $url,
285 'post_author' => 0,
286 ),
287 true
288 );
289
290 if ( \is_wp_error( $post_id ) || ! $post_id ) {
291 /**
292 * Fires when `bury()` fails to write a tombstone row.
293 *
294 * The URL is silently not tombstoned in this case — the
295 * request path will respond as it would for any other
296 * non-existent post. Useful as a monitoring hook.
297 *
298 * @since 8.3.0
299 *
300 * @param string $normalized The normalized URL that failed to bury.
301 * @param \WP_Error|int|null $post_id The `wp_insert_post()` return value.
302 */
303 \do_action( 'activitypub_tombstone_bury_failed', $normalized, $post_id );
304 }
305 }
306 }
307
308 /**
309 * Remove one or more URLs from the local tombstone registry.
310 *
311 * Removes URLs from the local tombstone URL registry.
312 * URLs are normalized before comparison to ensure consistent matching.
313 * This marks the URLs as no longer tombstoned for future local checks.
314 *
315 * @param string ...$urls The URLs to remove from the tombstone registry.
316 */
317 public static function remove( ...$urls ) {
318 $normalized_urls = array();
319 foreach ( $urls as $url ) {
320 if ( \filter_var( $url, \FILTER_VALIDATE_URL ) ) {
321 $normalized_urls[] = normalize_url( $url );
322 }
323 }
324
325 if ( empty( $normalized_urls ) ) {
326 return;
327 }
328
329 $normalized_urls = \array_values( \array_unique( $normalized_urls ) );
330
331 foreach ( $normalized_urls as $normalized ) {
332 foreach ( self::find_post_ids_by_url( $normalized ) as $post_id ) {
333 \wp_delete_post( $post_id, true );
334 }
335 }
336
337 $legacy = \get_option( 'activitypub_tombstone_urls', false );
338 if ( ! \is_array( $legacy ) ) {
339 return;
340 }
341
342 $filtered = \array_values( \array_diff( $legacy, $normalized_urls ) );
343 if ( \count( $filtered ) === \count( $legacy ) ) {
344 return;
345 }
346
347 if ( empty( $filtered ) ) {
348 \delete_option( 'activitypub_tombstone_urls' );
349 } else {
350 \update_option( 'activitypub_tombstone_urls', $filtered );
351 }
352 }
353
354 /**
355 * Delete every tombstone post and the legacy option.
356 *
357 * Used during plugin uninstall to clean up all local tombstones.
358 *
359 * @since 8.3.0
360 *
361 * @return int The number of tombstone posts deleted.
362 */
363 public static function delete_all() {
364 global $wpdb;
365
366 $post_ids = \array_map(
367 'intval',
368 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
369 $wpdb->get_col(
370 $wpdb->prepare(
371 "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s",
372 self::POST_TYPE
373 )
374 )
375 );
376
377 $deleted = 0;
378 foreach ( $post_ids as $post_id ) {
379 if ( \wp_delete_post( $post_id, true ) ) {
380 ++$deleted;
381 }
382 }
383
384 \delete_option( 'activitypub_tombstone_urls' );
385
386 return $deleted;
387 }
388
389 /**
390 * Delete tombstones older than the retention window.
391 *
392 * Processes up to `$batch_size` tombstones per call. Retention is
393 * non-urgent: large backlogs drain across multiple daily runs of the
394 * `activitypub_tombstone_purge` cron event.
395 *
396 * @since 8.3.0
397 *
398 * @param int $batch_size Max number of tombstones to delete per call.
399 * @return int The number of tombstones deleted.
400 */
401 public static function purge( $batch_size = 200 ) {
402 /**
403 * Filters the retention window for local tombstones, in days.
404 *
405 * Set to 0 or a negative value to disable automatic purge.
406 *
407 * @since 8.3.0
408 *
409 * @param int $days Retention window in days. Default 90.
410 */
411 $days = (int) \apply_filters( 'activitypub_tombstone_retention_days', 90 );
412
413 if ( $days <= 0 ) {
414 return 0;
415 }
416
417 $cutoff = \gmdate( 'Y-m-d H:i:s', \time() - $days * DAY_IN_SECONDS );
418
419 $ids = \get_posts(
420 array(
421 'post_type' => self::POST_TYPE,
422 'post_status' => 'publish',
423 'posts_per_page' => (int) $batch_size,
424 'fields' => 'ids',
425 'orderby' => 'date',
426 'order' => 'ASC',
427 'no_found_rows' => true,
428 'date_query' => array(
429 array(
430 'column' => 'post_date_gmt',
431 'before' => $cutoff,
432 ),
433 ),
434 )
435 );
436
437 $deleted = 0;
438 foreach ( $ids as $id ) {
439 if ( \wp_delete_post( (int) $id, true ) ) {
440 ++$deleted;
441 }
442 }
443
444 return $deleted;
445 }
446 }
447