ID, 'activitypub_content_visibility', true ); $is_local_or_private = \in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ), true ); /* * An attachment (`inherit` status) inherits its parent's visibility. * Recurse into the parent so the attachment also picks up the parent's * content-visibility meta, password protection, and post-type support, * not just its post_status. Unattached attachments are allowed through. */ $is_attachment_public = 'inherit' === $post->post_status && 'attachment' === $post->post_type && ( ! $post->post_parent || is_post_publicly_queryable( $post->post_parent ) ); // Draft, pending, and scheduled posts are allowed during preview requests so the Fediverse Preview works. $is_preview = \in_array( $post->post_status, array( 'draft', 'pending', 'future' ), true ) && \get_query_var( 'preview' ) && \current_user_can( 'edit_post', $post->ID ); $is_public_status = 'publish' === $post->post_status || $is_attachment_public || $is_preview; $queryable = $is_public_status && ! $is_local_or_private && \post_type_supports( $post->post_type, 'activitypub' ) && empty( $post->post_password ); /** * Filter whether a post is publicly queryable via ActivityPub. * * @since 8.1.0 * * @param boolean $queryable True if the post is publicly queryable, false otherwise. * @param \WP_Post $post The post object. */ return \apply_filters( 'activitypub_is_post_publicly_queryable', $queryable, $post ); } /** * Check whether a post is federated. * * A post is federated when it has been sent to the Fediverse (its federation * state is "federated") AND it is still publicly queryable, i.e. its post type * is enabled for ActivityPub, it has a public status, it is not password- * protected, and its content visibility allows it (see `is_post_publicly_queryable()`). * * Re-checking the live queryability alongside the stored state guards against a * stale "federated" status left behind when a post is moved to a private status, * switched to local visibility, or its post type loses ActivityPub support. * * The federation-state check also keeps `is_post_publicly_queryable()`'s * preview allowance inert here: a draft/pending/scheduled post is never in the * federated state, so the preview branch can never make this return true. * * @since 9.0.0 * * @param mixed $post The post ID or object. * * @return boolean True if the post is federated, false otherwise. */ function is_post_federated( $post ) { if ( empty( $post ) ) { return false; } $post = \get_post( $post ); if ( ! $post ) { return false; } return ACTIVITYPUB_OBJECT_STATE_FEDERATED === get_wp_object_state( $post ) && is_post_publicly_queryable( $post ); } /** * Check if a post is an ActivityPub post. * * @param mixed $post The post object or ID. * * @return boolean True if the post is an ActivityPub post, false otherwise. */ function is_ap_post( $post ) { $post = \get_post( $post ); if ( ! $post ) { return false; } // Check for ap_post post type. return Remote_Posts::POST_TYPE === $post->post_type; } /** * Get the description of a post type. * * Set some default descriptions for the default post types. * * @param \WP_Post_Type $post_type The post type object. * * @return string The description of the post type. */ function get_post_type_description( $post_type ) { switch ( $post_type->name ) { case 'post': case 'page': $description = ''; break; case 'attachment': $description = ' - ' . \__( 'Files uploaded to the media library (such as images, videos, documents, or other attachments). Note: This federates every file upload, not just published content.', 'activitypub' ); break; default: $description = ''; if ( ! empty( $post_type->description ) ) { $description = ' - ' . $post_type->description; } } /** * Allow plugins to get the description of a post type. * * @param string $description The description of the post type. * @param string $post_type_name The post type name. * @param \WP_Post_Type $post_type The post type object. */ return \apply_filters( 'activitypub_post_type_description', $description, $post_type->name, $post_type ); } /** * Get the maximum number of media attachments a post may federate. * * A per-post limit wins over the site-wide setting, and the filter has the final say. * * @since 9.3.0 * * @param int $post_id The post ID. * * @return int The maximum number of media attachments. */ function get_max_attachments( $post_id ) { $max_media = \get_post_meta( $post_id, 'activitypub_max_image_attachments', true ); if ( ! \is_numeric( $max_media ) ) { $max_media = \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ); } /** * Filters the maximum number of media attachments allowed in a post. * * Despite the name suggesting only images, this filter controls the maximum number * of all media attachments (images, audio, and video) that can be included in an * ActivityPub post. The name is maintained for backwards compatibility. * * @param int $max_media Maximum number of media attachments. Default ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS. */ return (int) \apply_filters( 'activitypub_max_image_attachments', $max_media ); } /** * Get the enclosures of a post. * * @param int $post_id The post ID. * * @return array The enclosures. */ function get_enclosures( $post_id ) { $enclosures = \get_post_meta( $post_id, 'enclosure', false ); if ( ! $enclosures ) { return array(); } $enclosures = \array_map( static function ( $enclosure ) { // Check if the enclosure is a string. if ( ! $enclosure || ! \is_string( $enclosure ) ) { return false; } $attributes = \explode( "\n", $enclosure ); if ( ! isset( $attributes[0] ) || ! \wp_http_validate_url( $attributes[0] ) ) { return false; } return array( 'url' => $attributes[0], 'length' => $attributes[1] ?? null, 'mediaType' => $attributes[2] ?? 'application/octet-stream', ); }, $enclosures ); /* * Re-indexed on the URL to drop duplicates: a file declared twice is still one file, and the * URL is the only identity an enclosure has unless it happens to be in the media library. */ return \array_values( \array_column( \array_filter( $enclosures ), null, 'url' ) ); } /** * Generates a summary of a post. * * This function generates a summary based on the post's excerpt or content. * * @param int|\WP_Post $post The post ID or post object. * @param integer $length The maximum length of the summary. * Default is 500. It is ignored for a manual excerpt. * * @return string The generated post summary. */ function generate_post_summary( $post, $length = 500 ) { $post = \get_post( $post ); if ( ! $post ) { return ''; } /** * Filters the excerpt more value. * * @param string $excerpt_more The excerpt more. */ $excerpt_more = \apply_filters( 'activitypub_excerpt_more', '[…]' ); $length = $length - \mb_strlen( $excerpt_more, 'UTF-8' ); /* * Let core build the excerpt. It runs the content filters, so the summary never shows what * they hide, and plugins can tell an excerpt from the full post. Core's word limit is lifted, * so the length below applies and no theme read-more link is added. */ $no_limit = static function () { return \PHP_INT_MAX - 1; // `wp_trim_words()` adds one. }; // Core drops these blocks from excerpts, but the text inside them belongs to the summary. $containers = static function ( $blocks ) { return \array_merge( $blocks, array( 'core/cover' ) ); }; // A Details block is rendered whole, a wrapper would lose its title. $details = static function ( $blocks ) { return \array_merge( $blocks, array( 'core/details' ) ); }; /* * Core ignores the more tag on the post's own permalink, where remote servers fetch the object * and the preview renders, so the summary would not match the one sent in the Create. Core gets * a copy cut at the tag instead. `get_post()` reloads a post that is not raw from the database, * which would drop the cut. */ $teaser = clone $post; $teaser->post_content = \get_extended( $post->post_content )['main']; $teaser->filter = 'raw'; $previous_post = $GLOBALS['post'] ?? null; $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited \setup_postdata( $post ); \add_filter( 'excerpt_length', $no_limit, \PHP_INT_MAX ); \add_filter( 'excerpt_allowed_wrapper_blocks', $containers ); \add_filter( 'excerpt_allowed_blocks', $details ); /* * The summary is plain text, so a linked mention would be stripped down to its `@user` label. * Without the link it keeps `@user@host`, which `Mention::filter_activity_object()` links. */ $mention = array( Mention::class, 'the_excerpt' ); $mention_priority = \has_filter( 'get_the_excerpt', $mention ); if ( false !== $mention_priority ) { \remove_filter( 'get_the_excerpt', $mention, $mention_priority ); } $content = \get_the_excerpt( $teaser ); if ( false !== $mention_priority ) { \add_filter( 'get_the_excerpt', $mention, $mention_priority ); } \remove_filter( 'excerpt_allowed_blocks', $details ); \remove_filter( 'excerpt_allowed_wrapper_blocks', $containers ); \remove_filter( 'excerpt_length', $no_limit, \PHP_INT_MAX ); // `wp_reset_postdata()` would restore the main query's post, not the one set before. $GLOBALS['post'] = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited if ( $previous_post ) { \setup_postdata( $previous_post ); } // Mastodon shows the summary as plain text. Core strips shortcodes only from generated excerpts. $content = \html_entity_decode( \wp_strip_all_tags( \strip_shortcodes( $content ) ), ENT_QUOTES, 'UTF-8' ); // A manual excerpt is used as written. Core treats one with only whitespace as empty. if ( '' === \trim( $post->post_excerpt ) && $length && \mb_strlen( $content, 'UTF-8' ) > $length ) { $content = \wordwrap( $content, $length, '' ); $content = \explode( '', $content, 2 ); $content = $content[0] . ' ' . $excerpt_more; } return $content; } /** * Get the content warning of a post. * * @param int|\WP_Post $post_id The post ID or post object. * * @return string|false The content warning or false if not found. */ function get_content_warning( $post_id ) { $post = \get_post( $post_id ); if ( ! $post ) { return false; } $warning = \get_post_meta( $post->ID, 'activitypub_content_warning', true ); if ( empty( $warning ) ) { return false; } return $warning; } /** * Get the ActivityPub ID of a Post by the WordPress Post ID. * * @param int $id The WordPress Post ID. * * @return string The ActivityPub ID (a URL) of the Post. */ function get_post_id( $id ) { $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 ); $post_id = (int) $id; if ( $post_id > $last_legacy_id ) { // Generate URI based on post ID. return \add_query_arg( 'p', $post_id, \home_url( '/' ) ); } $post = \get_post( $post_id ); return $post ? get_post_url( $post ) : \get_permalink( $post_id ); } /** * Get the post URL, preserving its published permalink after withdrawal. * * @since 9.4.0 * * @param \WP_Post $post The post. * * @return string The post URL. */ function get_post_url( $post ) { $canonical_url = \get_post_meta( $post->ID, '_activitypub_canonical_url', true ); if ( $canonical_url && 'attachment' !== $post->post_type ) { return \esc_url_raw( $canonical_url ); } switch ( \get_post_status( $post ) ) { case 'trash': $permalink = $canonical_url; break; case 'draft': // Get_sample_permalink is in wp-admin, not always loaded. if ( ! \function_exists( '\get_sample_permalink' ) ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } $sample = \get_sample_permalink( $post->ID ); $permalink = \str_replace( array( '%pagename%', '%postname%' ), $sample[1], $sample[0] ); break; default: $permalink = \get_permalink( $post ); break; } return \esc_url_raw( $permalink ); } /** * Get the visibility of a post. * * @param int|\WP_Post $post_id The post ID or object. * * @return string|false The visibility of the post or false if not found. */ function get_content_visibility( $post_id ) { $post = \get_post( $post_id ); if ( ! $post ) { return false; } $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; $options = array( ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ); if ( \in_array( $visibility, $options, true ) ) { $_visibility = $visibility; } /** * Filters the visibility of a post. * * @param string $_visibility The visibility of the post. Possible values are: * - ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC * - ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC * - ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE * - ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL * @param \WP_Post $post The post object. */ return \apply_filters( 'activitypub_content_visibility', $_visibility, $post ); } /** * Get the quote intent URI as a JavaScript URI. * * @since 9.4.0 * * @return string The quote intent URI. */ function get_quote_intent_js() { return \sprintf( 'javascript:(()=>{window.open(\'%s\'+encodeURIComponent(window.location.href));})();', get_quote_intent_url() ); } /** * Get the quote intent URI. * * @since 9.4.0 * * @return string The quote intent URI. */ function get_quote_intent_url() { /** * Filters the quote intent parameters. * * @since 9.4.0 * * @param array $params The quote intent parameters. */ $params = \apply_filters( 'activitypub_quote_intent_params', array() ); $params += array( 'quotation_of' => '' ); $query = \http_build_query( $params ); $path = 'post-new.php?' . $query; $url = \admin_url( $path ); /** * Filters the quote intent URL. * * @since 9.4.0 * * @param string $url The quote intent URL. */ $url = \apply_filters( 'activitypub_quote_intent_url', $url ); return \esc_url_raw( $url ); } /** * Get the post an interaction with a URL lands on. * * The URL is a local post, a stored remote post, or a comment, in which case the comment's post. * Storing an interaction and checking blocks on it both resolve it here, so they agree. * * @since 9.4.0 * * @param string $url The target URL. * @param int|null $parent_comment_id Optional. The comment the URL resolved to, see url_to_commentid(). * * @return int The post ID, or 0 when the target is unknown. */ function get_target_post_id( $url, $parent_comment_id = 0 ) { if ( ! $url ) { return 0; } // A comment's URL leads to its post, and core's url_to_postid() would read `/?c=5` as the front page. $parent_comment = $parent_comment_id ? \get_comment( $parent_comment_id ) : null; if ( $parent_comment instanceof \WP_Comment ) { return (int) $parent_comment->comment_post_ID; } $url = \esc_url_raw( $url ); $post_id = \url_to_postid( $url ); // url_to_postid() drops query arguments it does not know, so `/?author=5` reads as the static front page. $query = array(); \wp_parse_str( (string) \wp_parse_url( $url, PHP_URL_QUERY ), $query ); if ( $post_id && (int) \get_option( 'page_on_front' ) === $post_id && $query && ! \array_intersect( \array_keys( $query ), array( 'p', 'page_id' ) ) ) { $post_id = 0; } if ( ! $post_id ) { $remote_post = Remote_Posts::get_by_guid( $url ); if ( $remote_post instanceof \WP_Post ) { $post_id = $remote_post->ID; } } return (int) $post_id; }