447 lines
12 KiB
PHP
447 lines
12 KiB
PHP
<?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 );
|
|
|
|
/**
|
|
* The custom post type used to store local tombstones.
|
|
*
|
|
* @var string
|
|
*/
|
|
const POST_TYPE = 'ap_tombstone';
|
|
|
|
/**
|
|
* 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 = Http::get( $url );
|
|
|
|
if ( ! \is_wp_error( $response ) ) {
|
|
$data = \wp_remote_retrieve_body( $response );
|
|
$data = \json_decode( $data, true );
|
|
|
|
return self::check_array( $data );
|
|
}
|
|
|
|
if ( in_array( (int) $response->get_error_code(), self::$codes, true ) ) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if a local URL is tombstoned.
|
|
*
|
|
* Matches by the MD5 hash of the normalized URL stored in `post_name`.
|
|
* Falls back to the legacy `activitypub_tombstone_urls` option for
|
|
* tombstones that have not yet been migrated.
|
|
*
|
|
* @param string $url The local URL to check for tombstone status.
|
|
*
|
|
* @return bool True if the local URL is tombstoned, false otherwise.
|
|
*/
|
|
public static function exists_local( $url ) {
|
|
if ( ! \is_string( $url ) || '' === $url ) {
|
|
return false;
|
|
}
|
|
|
|
$normalized = normalize_url( $url );
|
|
|
|
if ( ! empty( self::find_post_ids_by_url( $normalized ) ) ) {
|
|
return true;
|
|
}
|
|
|
|
/*
|
|
* Fallback to the legacy option during migration. Once the option is
|
|
* deleted (migration complete), get_option returns false and the
|
|
* is_array() guard short-circuits immediately.
|
|
*/
|
|
$legacy = \get_option( 'activitypub_tombstone_urls', false );
|
|
if ( \is_array( $legacy ) && \in_array( $normalized, $legacy, true ) ) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Look up tombstone post IDs by canonical URL.
|
|
*
|
|
* The MD5 of the normalized URL is unique per URL, so a successful
|
|
* `bury()` produces exactly one row and the canonical lookup is enough.
|
|
*
|
|
* @since 8.3.0
|
|
*
|
|
* @param string $normalized The normalized URL (scheme stripped).
|
|
* @return int[] Post IDs (zero or one entry under normal operation).
|
|
*/
|
|
private static function find_post_ids_by_url( $normalized ) {
|
|
global $wpdb;
|
|
|
|
/*
|
|
* `bury()` is idempotent on the MD5 slug, so a successful insert
|
|
* produces exactly one row per URL. `LIMIT 1` matches that invariant
|
|
* and keeps the query cheap on the hot `exists_local()` path.
|
|
*/
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
$ids = $wpdb->get_col(
|
|
$wpdb->prepare(
|
|
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_name = %s LIMIT 1",
|
|
self::POST_TYPE,
|
|
\md5( $normalized )
|
|
)
|
|
);
|
|
|
|
return \array_map( 'intval', $ids );
|
|
}
|
|
|
|
/**
|
|
* Add one or more URLs to the local tombstone registry.
|
|
*
|
|
* "Buries" URLs by adding them to the local tombstone URL registry.
|
|
* URLs are normalized before storage; duplicate calls for the same URL
|
|
* are a no-op because the `post_name` slug is the MD5 of the
|
|
* normalized URL.
|
|
*
|
|
* @param string ...$urls The URLs to add to the tombstone registry.
|
|
*/
|
|
public static function bury( ...$urls ) {
|
|
foreach ( $urls as $url ) {
|
|
if ( ! \filter_var( $url, \FILTER_VALIDATE_URL ) ) {
|
|
continue;
|
|
}
|
|
|
|
$normalized = normalize_url( $url );
|
|
|
|
if ( ! empty( self::find_post_ids_by_url( $normalized ) ) ) {
|
|
continue;
|
|
}
|
|
|
|
/*
|
|
* Store the original URL in `guid` so it is human-readable and
|
|
* survives `esc_url()` without scheme mangling. The hash slug
|
|
* in `post_name` is what we actually key lookups on.
|
|
*/
|
|
$post_id = \wp_insert_post(
|
|
array(
|
|
'post_type' => self::POST_TYPE,
|
|
'post_status' => 'publish',
|
|
'post_name' => \md5( $normalized ),
|
|
'guid' => $url,
|
|
'post_author' => 0,
|
|
),
|
|
true
|
|
);
|
|
|
|
if ( \is_wp_error( $post_id ) || ! $post_id ) {
|
|
/**
|
|
* Fires when `bury()` fails to write a tombstone row.
|
|
*
|
|
* The URL is silently not tombstoned in this case — the
|
|
* request path will respond as it would for any other
|
|
* non-existent post. Useful as a monitoring hook.
|
|
*
|
|
* @since 8.3.0
|
|
*
|
|
* @param string $normalized The normalized URL that failed to bury.
|
|
* @param \WP_Error|int|null $post_id The `wp_insert_post()` return value.
|
|
*/
|
|
\do_action( 'activitypub_tombstone_bury_failed', $normalized, $post_id );
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove one or more URLs from the local tombstone registry.
|
|
*
|
|
* Removes URLs from the local tombstone URL registry.
|
|
* URLs are normalized before comparison to ensure consistent matching.
|
|
* This marks the URLs as no longer tombstoned for future local checks.
|
|
*
|
|
* @param string ...$urls The URLs to remove from the tombstone registry.
|
|
*/
|
|
public static function remove( ...$urls ) {
|
|
$normalized_urls = array();
|
|
foreach ( $urls as $url ) {
|
|
if ( \filter_var( $url, \FILTER_VALIDATE_URL ) ) {
|
|
$normalized_urls[] = normalize_url( $url );
|
|
}
|
|
}
|
|
|
|
if ( empty( $normalized_urls ) ) {
|
|
return;
|
|
}
|
|
|
|
$normalized_urls = \array_values( \array_unique( $normalized_urls ) );
|
|
|
|
foreach ( $normalized_urls as $normalized ) {
|
|
foreach ( self::find_post_ids_by_url( $normalized ) as $post_id ) {
|
|
\wp_delete_post( $post_id, true );
|
|
}
|
|
}
|
|
|
|
$legacy = \get_option( 'activitypub_tombstone_urls', false );
|
|
if ( ! \is_array( $legacy ) ) {
|
|
return;
|
|
}
|
|
|
|
$filtered = \array_values( \array_diff( $legacy, $normalized_urls ) );
|
|
if ( \count( $filtered ) === \count( $legacy ) ) {
|
|
return;
|
|
}
|
|
|
|
if ( empty( $filtered ) ) {
|
|
\delete_option( 'activitypub_tombstone_urls' );
|
|
} else {
|
|
\update_option( 'activitypub_tombstone_urls', $filtered );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete every tombstone post and the legacy option.
|
|
*
|
|
* Used during plugin uninstall to clean up all local tombstones.
|
|
*
|
|
* @since 8.3.0
|
|
*
|
|
* @return int The number of tombstone posts deleted.
|
|
*/
|
|
public static function delete_all() {
|
|
global $wpdb;
|
|
|
|
$post_ids = \array_map(
|
|
'intval',
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
$wpdb->get_col(
|
|
$wpdb->prepare(
|
|
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s",
|
|
self::POST_TYPE
|
|
)
|
|
)
|
|
);
|
|
|
|
$deleted = 0;
|
|
foreach ( $post_ids as $post_id ) {
|
|
if ( \wp_delete_post( $post_id, true ) ) {
|
|
++$deleted;
|
|
}
|
|
}
|
|
|
|
\delete_option( 'activitypub_tombstone_urls' );
|
|
|
|
return $deleted;
|
|
}
|
|
|
|
/**
|
|
* Delete tombstones older than the retention window.
|
|
*
|
|
* Processes up to `$batch_size` tombstones per call. Retention is
|
|
* non-urgent: large backlogs drain across multiple daily runs of the
|
|
* `activitypub_tombstone_purge` cron event.
|
|
*
|
|
* @since 8.3.0
|
|
*
|
|
* @param int $batch_size Max number of tombstones to delete per call.
|
|
* @return int The number of tombstones deleted.
|
|
*/
|
|
public static function purge( $batch_size = 200 ) {
|
|
/**
|
|
* Filters the retention window for local tombstones, in days.
|
|
*
|
|
* Set to 0 or a negative value to disable automatic purge.
|
|
*
|
|
* @since 8.3.0
|
|
*
|
|
* @param int $days Retention window in days. Default 90.
|
|
*/
|
|
$days = (int) \apply_filters( 'activitypub_tombstone_retention_days', 90 );
|
|
|
|
if ( $days <= 0 ) {
|
|
return 0;
|
|
}
|
|
|
|
$cutoff = \gmdate( 'Y-m-d H:i:s', \time() - $days * DAY_IN_SECONDS );
|
|
|
|
$ids = \get_posts(
|
|
array(
|
|
'post_type' => self::POST_TYPE,
|
|
'post_status' => 'publish',
|
|
'posts_per_page' => (int) $batch_size,
|
|
'fields' => 'ids',
|
|
'orderby' => 'date',
|
|
'order' => 'ASC',
|
|
'no_found_rows' => true,
|
|
'date_query' => array(
|
|
array(
|
|
'column' => 'post_date_gmt',
|
|
'before' => $cutoff,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
$deleted = 0;
|
|
foreach ( $ids as $id ) {
|
|
if ( \wp_delete_post( (int) $id, true ) ) {
|
|
++$deleted;
|
|
}
|
|
}
|
|
|
|
return $deleted;
|
|
}
|
|
}
|