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; } }