Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3c068c9
Refactor tombstone handling into Tombstone class
pfefferle Aug 8, 2025
7193a64
Refactor Tombstone type check logic
pfefferle Aug 8, 2025
48f196f
Add tests for new Tombstone check methods
pfefferle Aug 8, 2025
f2417fa
Merge branch 'trunk' into add/tombstone
pfefferle Aug 8, 2025
37ae91c
Add changelog
matticbot Aug 8, 2025
7ea9925
Apply suggestion from @Copilot
pfefferle Aug 8, 2025
f6722aa
Apply suggestion from @Copilot
pfefferle Aug 8, 2025
3831be1
Apply suggestion from @Copilot
pfefferle Aug 8, 2025
c8b85ee
Apply suggestion from @Copilot
pfefferle Aug 8, 2025
b550579
Update WP_Error type hint in docblock
obenland Aug 19, 2025
2ed8bc5
Merge branch 'trunk' into add/tombstone
pfefferle Aug 24, 2025
56c8d14
Update templates/tombstone-json.php
pfefferle Aug 24, 2025
823c8ca
Update templates/tombstone-json.php
pfefferle Aug 24, 2025
05315c1
change to `is_gone`
pfefferle Aug 24, 2025
57718e4
Check for `get_error_data`
pfefferle Aug 24, 2025
097e649
Merge branch 'trunk' into add/tombstone
pfefferle Aug 25, 2025
546cfab
Send `Delete` Activity on User-Delete (#2035)
pfefferle Aug 25, 2025
e328903
Merge branch 'trunk' into add/tombstone
pfefferle Aug 25, 2025
28804b2
Remove redundant error code check in Tombstone class
pfefferle Aug 26, 2025
1d00beb
Refactor Tombstone API to use 'exists' naming
pfefferle Aug 26, 2025
576cf22
Update deprecated function reference in is_tombstone
pfefferle Aug 26, 2025
04273e4
Improve and expand docblocks in Tombstone class
pfefferle Aug 26, 2025
82e70ca
Merge branch 'trunk' into add/tombstone
pfefferle Aug 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/2035-from-description
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Adds support for sending Delete activities when a user is removed.
4 changes: 4 additions & 0 deletions .github/changelog/2066-from-description
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Improved handling of deleted content with a new unified system for better tracking and compatibility.
5 changes: 5 additions & 0 deletions includes/class-activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ public static function render_activitypub_template( $template ) {
return $template;
}

if ( Tombstone::exists_local( Query::get_instance()->get_request_url() ) ) {
\status_header( 410 );
return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php';
}

$activitypub_template = false;
$activitypub_object = Query::get_instance()->get_activitypub_object();

Expand Down
6 changes: 3 additions & 3 deletions includes/class-dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ public static function process_outbox( $id ) {
return;
}

$type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
$actor = Outbox::get_actor( $outbox_item );
if ( \is_wp_error( $actor ) ) {
if ( \is_wp_error( $actor ) && 'Delete' !== $type ) {
// If the actor is not found, publish the post and don't try again.
\wp_publish_post( $outbox_item );
return;
Expand Down Expand Up @@ -99,8 +100,7 @@ public static function send_to_followers( $outbox_item_id, $batch_size = ACTIVIT
$outbox_item = \get_post( $outbox_item_id );
$json = Outbox::get_activity( $outbox_item_id )->to_json();
$inboxes = Followers::get_inboxes_for_activity( $json, $outbox_item->post_author, $batch_size, $offset );

$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
$retries = self::send_to_inboxes( $inboxes, $outbox_item_id );

// Retry failed inboxes.
if ( ! empty( $retries ) ) {
Expand Down
24 changes: 3 additions & 21 deletions includes/class-http.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public static function post( $url, $body, $user_id ) {
'Date' => \gmdate( 'D, d M Y H:i:s T' ),
),
'body' => $body,
'key_id' => Actors::get_by_id( $user_id )->get_id() . '#main-key',
'key_id' => \json_decode( $body )->actor . '#main-key',
'private_key' => Actors::get_private_key( $user_id ),
);

Expand Down Expand Up @@ -182,27 +182,9 @@ public static function get( $url, $cached = false ) {
* @return bool True if the URL is a tombstone.
*/
public static function is_tombstone( $url ) {
/**
* Fires before checking if the URL is a tombstone.
*
* @param string $url The URL to check.
*/
\do_action( 'activitypub_pre_http_is_tombstone', $url );

$response = \wp_safe_remote_get( $url, array( 'headers' => array( 'Accept' => 'application/activity+json' ) ) );
$code = \wp_remote_retrieve_response_code( $response );

if ( in_array( (int) $code, array( 404, 410 ), true ) ) {
return true;
}

$data = \wp_remote_retrieve_body( $response );
$data = \json_decode( $data, true );
if ( $data && isset( $data['type'] ) && 'Tombstone' === $data['type'] ) {
return true;
}
_deprecated_function( __METHOD__, 'unreleased', 'Activitypub\Tombstone::exists_remote' );

return false;
return Tombstone::exists_remote( $url );
}

/**
Expand Down
2 changes: 1 addition & 1 deletion includes/class-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ protected function maybe_get_virtual_object() {
*
* @return string|null The request URL.
*/
protected function get_request_url() {
public function get_request_url() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion includes/class-scheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ public static function cleanup_remote_actors() {
foreach ( $actors as $actor ) {
$meta = get_remote_metadata_by_actor( $actor->guid, false );

if ( is_tombstone( $meta ) ) {
if ( Tombstone::exists( $meta ) ) {
\wp_delete_post( $actor->ID );
} elseif ( empty( $meta ) || ! is_array( $meta ) || \is_wp_error( $meta ) ) {
if ( Actors::count_errors( $actor->ID ) >= 5 ) {
Expand Down
210 changes: 210 additions & 0 deletions includes/class-tombstone.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php
/**
* Tombstone class file.
*
* @package Activitypub
*/

namespace Activitypub;

use Activitypub\Activity\Base_Object;

/**
* ActivityPub Tombstone Class.
*
* Handles detection and management of tombstoned (deleted) ActivityPub resources.
* A tombstone in ActivityPub represents a deleted object that was previously available.
* This class provides methods to detect tombstones across various data formats including
* URLs, ActivityPub objects, arrays, and WordPress error responses.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
*/
class Tombstone {
/**
* HTTP status codes that indicate a tombstoned resource.
*
* - 404: Not Found - Resource no longer exists
* - 410: Gone - Resource was intentionally removed
*
* @var int[] Array of HTTP status codes indicating tombstones.
*/
private static $codes = array( 404, 410 );

/**
* Check if a tombstone exists for the given resource.
*
* This is the main entry point for tombstone detection. It accepts various
* data types and routes them to the appropriate checking method:
* - URLs (string): Checks remote or local tombstone status
* - WP_Error objects: Checks for tombstone-indicating HTTP status codes
* - Arrays: Checks for ActivityPub Tombstone type
* - Objects: Checks for ActivityPub Tombstone type or Base_Object instances
*
* @param string|\WP_Error|array|object $various The resource data to check for tombstone status.
* Can be a URL, error object, ActivityPub array, or object.
*
* @return bool True if the resource is tombstoned, false otherwise.
*/
public static function exists( $various ) {
if ( \is_wp_error( $various ) ) {
return self::exists_in_error( $various );
}

if ( \is_string( $various ) ) {
if ( is_same_domain( $various ) ) {
return self::exists_local( $various );
}
return self::exists_remote( $various );
}

if ( \is_array( $various ) ) {
return self::check_array( $various );
}

if ( \is_object( $various ) ) {
return self::check_object( $various );
}

return false;
}

/**
* Check if a remote URL is tombstoned.
*
* Makes an HTTP request to the remote URL with ActivityPub headers
* and checks for tombstone indicators:
* - HTTP 404/410 status codes
* - ActivityPub Tombstone object type in response body
*
* @param string $url The remote URL to check for tombstone status.
*
* @return bool True if the remote URL is tombstoned, false otherwise.
*/
public static function exists_remote( $url ) {
/**
* Fires before checking if the URL is a tombstone.
*
* @param string $url The URL to check.
*/
\do_action( 'activitypub_pre_http_is_tombstone', $url );

$response = \wp_safe_remote_get( $url, array( 'headers' => array( 'Accept' => 'application/activity+json' ) ) );
$code = \wp_remote_retrieve_response_code( $response );

if ( in_array( (int) $code, self::$codes, true ) ) {
return true;
}

$data = \wp_remote_retrieve_body( $response );
$data = \json_decode( $data, true );

return self::check_array( $data );
}

/**
* Check if a local URL is tombstoned.
*
* Checks against the local tombstone URL registry stored in WordPress options.
* Local URLs are normalized before comparison to ensure consistent matching.
*
* @param string $url The local URL to check for tombstone status.
*
* @return bool True if the local URL is in the tombstone registry, false otherwise.
*/
public static function exists_local( $url ) {
$urls = get_option( 'activitypub_tombstone_urls', array() );

return in_array( normalize_url( $url ), $urls, true );
}

/**
* Check if a WP_Error object indicates a tombstoned resource.
*
* Examines the error data for HTTP status codes that indicate tombstones.
* This is typically used when HTTP requests return error responses.
*
* @param \WP_Error $wp_error The WordPress error object to examine.
*
* @return bool True if the error indicates a tombstoned resource, false otherwise.
*/
public static function exists_in_error( $wp_error ) {
if ( ! \is_wp_error( $wp_error ) ) {
return false;
}

$data = $wp_error->get_error_data();
if ( isset( $data['status'] ) && in_array( (int) $data['status'], self::$codes, true ) ) {
return true;
}

return false;
}

/**
* Check if an array represents an ActivityPub Tombstone object.
*
* Examines the array for the ActivityPub 'type' property set to 'Tombstone'.
* This follows the ActivityStreams specification for tombstone objects.
*
* @param array|mixed $data The array data to check. Non-arrays return false.
*
* @return bool True if the array represents a Tombstone object, false otherwise.
*/
private static function check_array( $data ) {
if ( ! \is_array( $data ) ) {
return false;
}

if ( isset( $data['type'] ) && 'Tombstone' === $data['type'] ) {
return true;
}

return false;
}

/**
* Check if an object represents an ActivityPub Tombstone.
*
* Checks for tombstone indicators in objects:
* - Standard objects: 'type' property set to 'Tombstone'
* - Base_Object instances: Uses get_type() method to check for 'Tombstone'
*
* @param object|mixed $data The object data to check. Non-objects return false.
*
* @return bool True if the object represents a Tombstone, false otherwise.
*/
private static function check_object( $data ) {
if ( ! \is_object( $data ) ) {
return false;
}

if ( isset( $data->type ) && 'Tombstone' === $data->type ) {
return true;
}

if ( $data instanceof Base_Object && 'Tombstone' === $data->get_type() ) {
return true;
}

return false;
}

/**
* Add a URL to the local tombstone registry.
*
* "Buries" a URL by adding it to the local tombstone URL registry.
* The URL is normalized before storage and duplicates are automatically removed.
* This marks the URL as tombstoned for future local checks.
*
* @param string $url The URL to add to the tombstone registry.
*
* @return void
*/
public static function bury( $url ) {
$urls = \get_option( 'activitypub_tombstone_urls', array() );
$urls[] = normalize_url( $url );
$urls = \array_unique( $urls );

\update_option( 'activitypub_tombstone_urls', $urls );
}
}
33 changes: 33 additions & 0 deletions includes/collection/class-actors.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ class Actors {
*/
const POST_TYPE = 'ap_actor';

/**
* Cache key for the followers inbox.
*
* @var string
*/
const CACHE_KEY_INBOXES = 'actor_inboxes';

/**
* Get the Actor by ID.
*
Expand Down Expand Up @@ -421,6 +428,32 @@ public static function get_all() {
return $return;
}

/**
* Returns all Inboxes for all known remote Actors.
*
* @return array The list of Inboxes.
*/
public static function get_inboxes() {
$inboxes = \wp_cache_get( self::CACHE_KEY_INBOXES, 'activitypub' );

if ( $inboxes ) {
return $inboxes;
}

global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_col(
"SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
WHERE meta_key = '_activitypub_inbox'
AND meta_value IS NOT NULL"
);

$inboxes = \array_filter( $results );
\wp_cache_set( self::CACHE_KEY_INBOXES, $inboxes, 'activitypub' );

return $inboxes;
}

/**
* Returns the actor type based on the user ID.
*
Expand Down
Loading
Loading