post_type || 'publish' !== $post->post_status || ACTIVITYPUB_OBJECT_STATE_FEDERATED !== get_wp_object_state( $post ) ) { return; } \add_post_meta( $post_id, '_activitypub_canonical_url', \get_permalink( $post_id ), true ); } /** * Drop the federation state that an import brings along. * * An export carries the state of the site it came from. Kept, it makes an * imported post look federated, and a later save sends an Update or Delete. * * @since 9.4.1 * * @param null|bool $check Whether to allow adding the metadata. * @param int $post_id Post ID. * @param string $meta_key Metadata key. * * @return null|bool True to skip the metadata, the unchanged check otherwise. */ public static function skip_imported_federation_state( $check, $post_id, $meta_key ) { if ( is_importing() && \in_array( $meta_key, array( 'activitypub_status', 'activitypub_deleted_at' ), true ) ) { return true; } return $check; } /** * Triage post transitions and determine the appropriate Activity type. * * @param int $post_id Post ID. * @param \WP_Post $post Post object. * @param bool $update Whether this is an existing post being updated. * @param \WP_Post $post_before Post object before the update. * * @return void */ public static function triage( $post_id, $post, $update, $post_before ) { $is_queryable = is_post_publicly_queryable( $post ); if ( 'attachment' !== $post->post_type && 'publish' === $post->post_status && $is_queryable ) { \delete_post_meta( $post_id, '_activitypub_canonical_url' ); } if ( is_importing() ) { /* * Imported posts are old content. Mark them "Do not federate", so a later * save after the import (a link fixer, a bulk edit) does not send them to * followers as new posts. Only new, published posts: an update can hit a * post that was already federated, and a draft or scheduled post is new * content once it goes live. */ if ( ! $update && 'publish' === $post->post_status && \post_type_supports( $post->post_type, 'activitypub' ) ) { \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ); } return; } if ( is_post_disabled( $post ) ) { return; } $object_status = get_wp_object_state( $post ); // If the post is already soft-deleted and still non-public, do not create any more activities. if ( ACTIVITYPUB_OBJECT_STATE_DELETED === $object_status && ! $is_queryable ) { return; } // Bail on bulk edits, unless post author or post status changed. if ( isset( $_REQUEST['bulk_edit'] ) && ( ! isset( $_REQUEST['post_author'] ) || -1 === (int) $_REQUEST['post_author'] ) && -1 === (int) $_REQUEST['_status'] ) { // phpcs:ignore WordPress return; } $new_status = \get_post_status( $post ); $old_status = $post_before ? \get_post_status( $post_before ) : null; switch ( $new_status ) { case 'publish': if ( $update ) { $type = ( 'publish' === $old_status ) ? 'Update' : 'Create'; } else { $type = 'Create'; } break; case 'future': /* * A (re-)scheduled post is not a deletion: it becomes public again * when it publishes, at which point the publish transition federates * it. Treating `future` as a soft delete would fan out a Delete that * remotely tombstones the object id — e.g. when a content edit reverts * a future-dated published post back to `future` — after which the * post can never re-federate. Emit nothing instead. */ $type = false; break; case 'draft': case 'pending': case 'private': case 'trash': default: /* * Soft delete for federated posts (FEP-4f05). * * A previously-federated post transitioning to any non-public * status (built-in or custom) emits a Delete so federated * copies are torn down. Without this, draft/pending would * broadcast a placeholder Update, private/trash would silently * leave the federated copy stale, and a custom status would * fall through without notifying followers at all. */ $type = ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status && ! $is_queryable ? 'Delete' : false; } // Do not send Activities if `$type` is not set or unknown. if ( empty( $type ) ) { return; } /* * If the post was already federated and this is a Create, skip. * The outbox controller already added it to the outbox. */ if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status && 'Create' === $type ) { return; } // If the post was never federated before, it should be a Create activity. if ( empty( $object_status ) && 'Update' === $type ) { /* * An old post that was never federated stays local when something saves it * again, for example a link checker. It only federates when the save sends a * visibility, which the classic editor and the block editor sidebar do once * the author sees the setting. Both default such a post to "Do not federate". */ if ( \get_post_time( 'U', true, $post ) < \time() - MONTH_IN_SECONDS ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing $visibility_sent = isset( $_POST['activitypub_content_visibility'] ); if ( ! $visibility_sent ) { $body = \json_decode( (string) \WP_REST_Server::get_raw_data(), true ); $visibility_sent = isset( $body['meta']['activitypub_content_visibility'] ); } if ( ! $visibility_sent ) { return; } } $type = 'Create'; } /* * Resurrection: a soft-deleted post that is back in a publicly * queryable state must emit Create, not Update. Remote followers * either dropped the original Create on the Delete fan-out (so * they need to learn about the post again) or had it cancelled * before fanning out (so the supersession logic invalidates the * pending Delete and Create is the correct re-introduction). */ if ( ACTIVITYPUB_OBJECT_STATE_DELETED === $object_status && 'Update' === $type && $is_queryable ) { $type = 'Create'; } // If the post was federated before but is now non-public, it should be a Delete activity. if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status && ! $is_queryable ) { $type = 'Delete'; } add_to_outbox( $post, $type, (int) $post->post_author ); } /** * Schedules Activities for attachment transitions. * * @param int $post_id Attachment ID. * * @return void */ public static function transition_attachment_status( $post_id ) { if ( is_importing() ) { return; } if ( ! \post_type_supports( 'attachment', 'activitypub' ) ) { return; } if ( is_post_disabled( $post_id ) ) { return; } $post = \get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return; } switch ( \current_action() ) { case 'add_attachment': $type = 'Create'; break; case 'edit_attachment': $type = 'Update'; break; case 'delete_attachment': $type = 'Delete'; break; default: return; } add_to_outbox( $post, $type, (int) $post->post_author ); } /** * Schedule an Add activity when a post is added to the featured collection. * * @param int $post_id The post ID. * * @return void */ public static function schedule_featured_add( $post_id ) { self::schedule_featured_update( $post_id, 'Add' ); } /** * Schedule a Remove activity when a post is removed from the featured collection. * * @param int $post_id The post ID. * * @return void */ public static function schedule_featured_remove( $post_id ) { self::schedule_featured_update( $post_id, 'Remove' ); } /** * Schedule an Add or Remove activity for the featured collection. * * When a post's sticky status changes, this sends an Add or Remove activity * to notify followers about the change to the actor's featured collection. * * @see https://github.com/Automattic/wordpress-activitypub/issues/2795 * * @param int $post_id The post ID. * @param string $activity_type The activity type ('Add' or 'Remove'). * * @return void */ private static function schedule_featured_update( $post_id, $activity_type ) { if ( is_importing() ) { return; } $post = \get_post( $post_id ); if ( ! $post ) { return; } if ( is_post_disabled( $post ) ) { return; } $actor = Actors::get_by_id( (int) $post->post_author ); if ( ! $actor || \is_wp_error( $actor ) ) { return; } $activity = new Activity(); $activity->set_type( $activity_type ); $activity->set_actor( $actor->get_id() ); $activity->set_object( get_post_id( $post->ID ) ); $activity->set_target( $actor->get_featured() ); add_to_outbox( $activity, null, (int) $post->post_author ); } }