Skip to content

Commit fd037a6

Browse files
authored
Implement a proper Tombstone handling (#2066)
1 parent 0ea2bcb commit fd037a6

19 files changed

+637
-139
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Adds support for sending Delete activities when a user is removed.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Improved handling of deleted content with a new unified system for better tracking and compatibility.

includes/class-activitypub.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ public static function render_activitypub_template( $template ) {
110110
return $template;
111111
}
112112

113+
if ( Tombstone::exists_local( Query::get_instance()->get_request_url() ) ) {
114+
\status_header( 410 );
115+
return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php';
116+
}
117+
113118
$activitypub_template = false;
114119
$activitypub_object = Query::get_instance()->get_activitypub_object();
115120

includes/class-dispatcher.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ public static function process_outbox( $id ) {
6060
return;
6161
}
6262

63+
$type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
6364
$actor = Outbox::get_actor( $outbox_item );
64-
if ( \is_wp_error( $actor ) ) {
65+
if ( \is_wp_error( $actor ) && 'Delete' !== $type ) {
6566
// If the actor is not found, publish the post and don't try again.
6667
\wp_publish_post( $outbox_item );
6768
return;
@@ -99,8 +100,7 @@ public static function send_to_followers( $outbox_item_id, $batch_size = ACTIVIT
99100
$outbox_item = \get_post( $outbox_item_id );
100101
$json = Outbox::get_activity( $outbox_item_id )->to_json();
101102
$inboxes = Followers::get_inboxes_for_activity( $json, $outbox_item->post_author, $batch_size, $offset );
102-
103-
$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
103+
$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
104104

105105
// Retry failed inboxes.
106106
if ( ! empty( $retries ) ) {

includes/class-http.php

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public static function post( $url, $body, $user_id ) {
5252
'Date' => \gmdate( 'D, d M Y H:i:s T' ),
5353
),
5454
'body' => $body,
55-
'key_id' => Actors::get_by_id( $user_id )->get_id() . '#main-key',
55+
'key_id' => \json_decode( $body )->actor . '#main-key',
5656
'private_key' => Actors::get_private_key( $user_id ),
5757
);
5858

@@ -182,27 +182,9 @@ public static function get( $url, $cached = false ) {
182182
* @return bool True if the URL is a tombstone.
183183
*/
184184
public static function is_tombstone( $url ) {
185-
/**
186-
* Fires before checking if the URL is a tombstone.
187-
*
188-
* @param string $url The URL to check.
189-
*/
190-
\do_action( 'activitypub_pre_http_is_tombstone', $url );
191-
192-
$response = \wp_safe_remote_get( $url, array( 'headers' => array( 'Accept' => 'application/activity+json' ) ) );
193-
$code = \wp_remote_retrieve_response_code( $response );
194-
195-
if ( in_array( (int) $code, array( 404, 410 ), true ) ) {
196-
return true;
197-
}
198-
199-
$data = \wp_remote_retrieve_body( $response );
200-
$data = \json_decode( $data, true );
201-
if ( $data && isset( $data['type'] ) && 'Tombstone' === $data['type'] ) {
202-
return true;
203-
}
185+
_deprecated_function( __METHOD__, 'unreleased', 'Activitypub\Tombstone::exists_remote' );
204186

205-
return false;
187+
return Tombstone::exists_remote( $url );
206188
}
207189

208190
/**

includes/class-query.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ protected function maybe_get_virtual_object() {
248248
*
249249
* @return string|null The request URL.
250250
*/
251-
protected function get_request_url() {
251+
public function get_request_url() {
252252
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
253253
return null;
254254
}

includes/class-scheduler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ public static function cleanup_remote_actors() {
194194
foreach ( $actors as $actor ) {
195195
$meta = get_remote_metadata_by_actor( $actor->guid, false );
196196

197-
if ( is_tombstone( $meta ) ) {
197+
if ( Tombstone::exists( $meta ) ) {
198198
\wp_delete_post( $actor->ID );
199199
} elseif ( empty( $meta ) || ! is_array( $meta ) || \is_wp_error( $meta ) ) {
200200
if ( Actors::count_errors( $actor->ID ) >= 5 ) {

includes/class-tombstone.php

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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+
* Check if a tombstone exists for the given resource.
35+
*
36+
* This is the main entry point for tombstone detection. It accepts various
37+
* data types and routes them to the appropriate checking method:
38+
* - URLs (string): Checks remote or local tombstone status
39+
* - WP_Error objects: Checks for tombstone-indicating HTTP status codes
40+
* - Arrays: Checks for ActivityPub Tombstone type
41+
* - Objects: Checks for ActivityPub Tombstone type or Base_Object instances
42+
*
43+
* @param string|\WP_Error|array|object $various The resource data to check for tombstone status.
44+
* Can be a URL, error object, ActivityPub array, or object.
45+
*
46+
* @return bool True if the resource is tombstoned, false otherwise.
47+
*/
48+
public static function exists( $various ) {
49+
if ( \is_wp_error( $various ) ) {
50+
return self::exists_in_error( $various );
51+
}
52+
53+
if ( \is_string( $various ) ) {
54+
if ( is_same_domain( $various ) ) {
55+
return self::exists_local( $various );
56+
}
57+
return self::exists_remote( $various );
58+
}
59+
60+
if ( \is_array( $various ) ) {
61+
return self::check_array( $various );
62+
}
63+
64+
if ( \is_object( $various ) ) {
65+
return self::check_object( $various );
66+
}
67+
68+
return false;
69+
}
70+
71+
/**
72+
* Check if a remote URL is tombstoned.
73+
*
74+
* Makes an HTTP request to the remote URL with ActivityPub headers
75+
* and checks for tombstone indicators:
76+
* - HTTP 404/410 status codes
77+
* - ActivityPub Tombstone object type in response body
78+
*
79+
* @param string $url The remote URL to check for tombstone status.
80+
*
81+
* @return bool True if the remote URL is tombstoned, false otherwise.
82+
*/
83+
public static function exists_remote( $url ) {
84+
/**
85+
* Fires before checking if the URL is a tombstone.
86+
*
87+
* @param string $url The URL to check.
88+
*/
89+
\do_action( 'activitypub_pre_http_is_tombstone', $url );
90+
91+
$response = \wp_safe_remote_get( $url, array( 'headers' => array( 'Accept' => 'application/activity+json' ) ) );
92+
$code = \wp_remote_retrieve_response_code( $response );
93+
94+
if ( in_array( (int) $code, self::$codes, true ) ) {
95+
return true;
96+
}
97+
98+
$data = \wp_remote_retrieve_body( $response );
99+
$data = \json_decode( $data, true );
100+
101+
return self::check_array( $data );
102+
}
103+
104+
/**
105+
* Check if a local URL is tombstoned.
106+
*
107+
* Checks against the local tombstone URL registry stored in WordPress options.
108+
* Local URLs are normalized before comparison to ensure consistent matching.
109+
*
110+
* @param string $url The local URL to check for tombstone status.
111+
*
112+
* @return bool True if the local URL is in the tombstone registry, false otherwise.
113+
*/
114+
public static function exists_local( $url ) {
115+
$urls = get_option( 'activitypub_tombstone_urls', array() );
116+
117+
return in_array( normalize_url( $url ), $urls, true );
118+
}
119+
120+
/**
121+
* Check if a WP_Error object indicates a tombstoned resource.
122+
*
123+
* Examines the error data for HTTP status codes that indicate tombstones.
124+
* This is typically used when HTTP requests return error responses.
125+
*
126+
* @param \WP_Error $wp_error The WordPress error object to examine.
127+
*
128+
* @return bool True if the error indicates a tombstoned resource, false otherwise.
129+
*/
130+
public static function exists_in_error( $wp_error ) {
131+
if ( ! \is_wp_error( $wp_error ) ) {
132+
return false;
133+
}
134+
135+
$data = $wp_error->get_error_data();
136+
if ( isset( $data['status'] ) && in_array( (int) $data['status'], self::$codes, true ) ) {
137+
return true;
138+
}
139+
140+
return false;
141+
}
142+
143+
/**
144+
* Check if an array represents an ActivityPub Tombstone object.
145+
*
146+
* Examines the array for the ActivityPub 'type' property set to 'Tombstone'.
147+
* This follows the ActivityStreams specification for tombstone objects.
148+
*
149+
* @param array|mixed $data The array data to check. Non-arrays return false.
150+
*
151+
* @return bool True if the array represents a Tombstone object, false otherwise.
152+
*/
153+
private static function check_array( $data ) {
154+
if ( ! \is_array( $data ) ) {
155+
return false;
156+
}
157+
158+
if ( isset( $data['type'] ) && 'Tombstone' === $data['type'] ) {
159+
return true;
160+
}
161+
162+
return false;
163+
}
164+
165+
/**
166+
* Check if an object represents an ActivityPub Tombstone.
167+
*
168+
* Checks for tombstone indicators in objects:
169+
* - Standard objects: 'type' property set to 'Tombstone'
170+
* - Base_Object instances: Uses get_type() method to check for 'Tombstone'
171+
*
172+
* @param object|mixed $data The object data to check. Non-objects return false.
173+
*
174+
* @return bool True if the object represents a Tombstone, false otherwise.
175+
*/
176+
private static function check_object( $data ) {
177+
if ( ! \is_object( $data ) ) {
178+
return false;
179+
}
180+
181+
if ( isset( $data->type ) && 'Tombstone' === $data->type ) {
182+
return true;
183+
}
184+
185+
if ( $data instanceof Base_Object && 'Tombstone' === $data->get_type() ) {
186+
return true;
187+
}
188+
189+
return false;
190+
}
191+
192+
/**
193+
* Add a URL to the local tombstone registry.
194+
*
195+
* "Buries" a URL by adding it to the local tombstone URL registry.
196+
* The URL is normalized before storage and duplicates are automatically removed.
197+
* This marks the URL as tombstoned for future local checks.
198+
*
199+
* @param string $url The URL to add to the tombstone registry.
200+
*
201+
* @return void
202+
*/
203+
public static function bury( $url ) {
204+
$urls = \get_option( 'activitypub_tombstone_urls', array() );
205+
$urls[] = normalize_url( $url );
206+
$urls = \array_unique( $urls );
207+
208+
\update_option( 'activitypub_tombstone_urls', $urls );
209+
}
210+
}

includes/collection/class-actors.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ class Actors {
4949
*/
5050
const POST_TYPE = 'ap_actor';
5151

52+
/**
53+
* Cache key for the followers inbox.
54+
*
55+
* @var string
56+
*/
57+
const CACHE_KEY_INBOXES = 'actor_inboxes';
58+
5259
/**
5360
* Get the Actor by ID.
5461
*
@@ -421,6 +428,32 @@ public static function get_all() {
421428
return $return;
422429
}
423430

431+
/**
432+
* Returns all Inboxes for all known remote Actors.
433+
*
434+
* @return array The list of Inboxes.
435+
*/
436+
public static function get_inboxes() {
437+
$inboxes = \wp_cache_get( self::CACHE_KEY_INBOXES, 'activitypub' );
438+
439+
if ( $inboxes ) {
440+
return $inboxes;
441+
}
442+
443+
global $wpdb;
444+
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
445+
$results = $wpdb->get_col(
446+
"SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
447+
WHERE meta_key = '_activitypub_inbox'
448+
AND meta_value IS NOT NULL"
449+
);
450+
451+
$inboxes = \array_filter( $results );
452+
\wp_cache_set( self::CACHE_KEY_INBOXES, $inboxes, 'activitypub' );
453+
454+
return $inboxes;
455+
}
456+
424457
/**
425458
* Returns the actor type based on the user ID.
426459
*

0 commit comments

Comments
 (0)