PluginProbe
ActivityPub / 8.2.1
ActivityPub v8.2.1
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / functions-post.php

functions-post.php in ActivityPub 8.2.1, at includes/functions-post.php

404 lines 12.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Post functions.
4 *
5 * Functions for working with posts in ActivityPub context.
6 *
7 * @package Activitypub
8 */
9
10 namespace Activitypub;
11
12 use Activitypub\Collection\Remote_Posts;
13
14 /**
15 * Check whether ActivityPub processing should be skipped for this post.
16 *
17 * Pipeline-level gate. Used by schedulers, transformers, and the outbox to
18 * decide whether a post participates in federation processing at all.
19 *
20 * Intentionally returns `false` for posts that are undergoing a federation
21 * lifecycle transition — e.g., a previously federated post whose visibility
22 * was changed to private, or a previously deleted post that was restored —
23 * so that the Delete or Create activity can still be emitted to notify
24 * remote servers.
25 *
26 * DO NOT use this as a content-exposure gate for REST metadata, block
27 * rendering, content-negotiated frontend JSON, or any other surface that
28 * reveals a post's current content or existence to unauthenticated readers.
29 * Use {@see is_post_publicly_queryable()} for those: it answers the simpler
30 * "is this post currently public?" question with no lifecycle escape hatch.
31 *
32 * @see is_post_publicly_queryable() For the current-visibility gate used by
33 * content-exposure surfaces.
34 *
35 * @param mixed $post The post object or ID.
36 *
37 * @return boolean True if ActivityPub processing should be skipped for this post, false otherwise.
38 */
39 function is_post_disabled( $post ) {
40 // Refuse empty input so `get_post()` doesn't silently resolve to the global $post.
41 if ( empty( $post ) ) {
42 return true;
43 }
44
45 $post = \get_post( $post );
46
47 if ( ! $post ) {
48 return true;
49 }
50
51 $disabled = ! is_post_publicly_queryable( $post );
52
53 /*
54 * Lifecycle-transition override.
55 *
56 * A previously federated post that has since been moved to any non-
57 * publicly-queryable state (local/private visibility, non-public
58 * status, password-protected, or whose post type no longer supports
59 * federation) still needs the pipeline to run so it can emit a Delete
60 * activity. A post that was deleted but later restored needs the
61 * pipeline to emit Create. In both cases we flip the gate back open
62 * even though the post is not currently publicly queryable.
63 */
64 $object_state = get_wp_object_state( $post );
65
66 if (
67 ACTIVITYPUB_OBJECT_STATE_DELETED === $object_state ||
68 ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_state && $disabled )
69 ) {
70 $disabled = false;
71 }
72
73 /**
74 * Allow plugins to disable posts for ActivityPub.
75 *
76 * @param boolean $disabled True if the post is disabled, false otherwise.
77 * @param \WP_Post $post The post object.
78 */
79 return \apply_filters( 'activitypub_is_post_disabled', $disabled, $post );
80 }
81
82 /**
83 * Check whether a post's current content is publicly queryable via ActivityPub.
84 *
85 * Content-exposure gate. Use wherever a post's current content, metadata, or
86 * mere existence could leak to an unauthenticated request. Unlike
87 * {@see is_post_disabled()}, this function ignores the federation lifecycle
88 * state: a post that was federated publicly and has since been made private,
89 * local, trashed, or password-protected returns `false` here, even while its
90 * Delete activity is still pending in the outbox.
91 *
92 * Use for: per-post REST metadata routes (reactions, replies, context,
93 * remote-reply), block server-side render callbacks that expose post
94 * content, content-negotiated frontend JSON. Do NOT use for federation
95 * pipeline decisions — that's what {@see is_post_disabled()} is for.
96 *
97 * A post is publicly queryable when it satisfies ALL of the following:
98 * - `post_status` is `publish` (or a well-defined equivalent: published
99 * attachments inheriting from a public parent, or a preview requested
100 * by a user with edit capability).
101 * - Its `activitypub_content_visibility` meta is neither `local` nor
102 * `private`.
103 * - The post type supports the `activitypub` feature.
104 * - No `post_password` is set.
105 *
106 * @since 8.1.0
107 *
108 * @see is_post_disabled() For the pipeline-level federation gate.
109 *
110 * @param mixed $post The post object or ID.
111 *
112 * @return boolean True if the post is currently publicly queryable, false otherwise.
113 */
114 function is_post_publicly_queryable( $post ) {
115 /*
116 * Refuse to resolve an empty/zero input through `get_post()`. A bare
117 * `get_post( null )` or `get_post( 0 )` falls back to the global
118 * `$post` during a WordPress loop, which would silently check the
119 * wrong post and potentially leak reactions/replies/metadata for a
120 * looped-over post instead of the one the caller intended.
121 */
122 if ( empty( $post ) ) {
123 return false;
124 }
125
126 $post = \get_post( $post );
127
128 if ( ! $post ) {
129 return false;
130 }
131
132 $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
133 $is_local_or_private = in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ), true );
134
135 /*
136 * An attachment (`inherit` status) inherits its parent's visibility.
137 * Recurse into the parent so the attachment also picks up the parent's
138 * content-visibility meta, password protection, and post-type support,
139 * not just its post_status. Unattached attachments are allowed through.
140 */
141 $is_attachment_public = 'inherit' === $post->post_status &&
142 'attachment' === $post->post_type &&
143 ( ! $post->post_parent || is_post_publicly_queryable( $post->post_parent ) );
144
145 // Drafts and pending posts are allowed during preview requests so the Fediverse Preview works.
146 $is_preview = in_array( $post->post_status, array( 'draft', 'pending' ), true ) &&
147 \get_query_var( 'preview' ) &&
148 \current_user_can( 'edit_post', $post->ID );
149
150 $is_public_status = 'publish' === $post->post_status || $is_attachment_public || $is_preview;
151
152 $queryable = $is_public_status &&
153 ! $is_local_or_private &&
154 \post_type_supports( $post->post_type, 'activitypub' ) &&
155 empty( $post->post_password );
156
157 /**
158 * Filter whether a post is publicly queryable via ActivityPub.
159 *
160 * @since 8.1.0
161 *
162 * @param boolean $queryable True if the post is publicly queryable, false otherwise.
163 * @param \WP_Post $post The post object.
164 */
165 return \apply_filters( 'activitypub_is_post_publicly_queryable', $queryable, $post );
166 }
167
168 /**
169 * Check if a post is an ActivityPub post.
170 *
171 * @param mixed $post The post object or ID.
172 *
173 * @return boolean True if the post is an ActivityPub post, false otherwise.
174 */
175 function is_ap_post( $post ) {
176 $post = \get_post( $post );
177
178 if ( ! $post ) {
179 return false;
180 }
181
182 // Check for ap_post post type.
183 return Remote_Posts::POST_TYPE === $post->post_type;
184 }
185
186 /**
187 * Get the description of a post type.
188 *
189 * Set some default descriptions for the default post types.
190 *
191 * @param \WP_Post_Type $post_type The post type object.
192 *
193 * @return string The description of the post type.
194 */
195 function get_post_type_description( $post_type ) {
196 switch ( $post_type->name ) {
197 case 'post':
198 case 'page':
199 $description = '';
200 break;
201 case 'attachment':
202 $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' );
203 break;
204 default:
205 $description = '';
206 if ( ! empty( $post_type->description ) ) {
207 $description = ' - ' . $post_type->description;
208 }
209 }
210
211 /**
212 * Allow plugins to get the description of a post type.
213 *
214 * @param string $description The description of the post type.
215 * @param string $post_type_name The post type name.
216 * @param \WP_Post_Type $post_type The post type object.
217 */
218 return apply_filters( 'activitypub_post_type_description', $description, $post_type->name, $post_type );
219 }
220
221 /**
222 * Get the enclosures of a post.
223 *
224 * @param int $post_id The post ID.
225 *
226 * @return array The enclosures.
227 */
228 function get_enclosures( $post_id ) {
229 $enclosures = get_post_meta( $post_id, 'enclosure', false );
230
231 if ( ! $enclosures ) {
232 return array();
233 }
234
235 $enclosures = array_map(
236 static function ( $enclosure ) {
237 // Check if the enclosure is a string.
238 if ( ! $enclosure || ! is_string( $enclosure ) ) {
239 return false;
240 }
241
242 $attributes = explode( "\n", $enclosure );
243
244 if ( ! isset( $attributes[0] ) || ! \wp_http_validate_url( $attributes[0] ) ) {
245 return false;
246 }
247
248 return array(
249 'url' => $attributes[0],
250 'length' => $attributes[1] ?? null,
251 'mediaType' => $attributes[2] ?? 'application/octet-stream',
252 );
253 },
254 $enclosures
255 );
256
257 return array_filter( $enclosures );
258 }
259
260 /**
261 * Generates a summary of a post.
262 *
263 * This function generates a summary based on the post's excerpt or content.
264 *
265 * @param int|\WP_Post $post The post ID or post object.
266 * @param integer $length The maximum length of the summary.
267 * Default is 500. It will be ignored if the post excerpt
268 * and the content above the <!--more--> tag.
269 *
270 * @return string The generated post summary.
271 */
272 function generate_post_summary( $post, $length = 500 ) {
273 $post = get_post( $post );
274
275 if ( ! $post ) {
276 return '';
277 }
278
279 /**
280 * Filters the excerpt more value.
281 *
282 * @param string $excerpt_more The excerpt more.
283 */
284 $excerpt_more = \apply_filters( 'activitypub_excerpt_more', '[…]' );
285 $length = $length - \mb_strlen( $excerpt_more, 'UTF-8' );
286
287 $content = \sanitize_post_field( 'post_excerpt', $post->post_excerpt, $post->ID );
288
289 if ( $content ) {
290 // Ignore length if excerpt is set.
291 $length = null;
292 } else {
293 $content = \sanitize_post_field( 'post_content', $post->post_content, $post->ID );
294 $content_parts = \get_extended( $content );
295
296 // Check for the <!--more--> tag.
297 if (
298 ! empty( $content_parts['extended'] ) &&
299 ! empty( $content_parts['main'] )
300 ) {
301 $content = \trim( $content_parts['main'] ) . ' ' . $excerpt_more;
302 $length = null;
303 }
304 }
305
306 $content = \strip_shortcodes( $content );
307 $content = \wp_strip_all_tags( $content );
308 $content = \html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
309 $content = \trim( $content );
310 $content = \preg_replace( '/\R+/mu', "\n\n", $content );
311 $content = \preg_replace( '/[\r\t]/u', '', $content );
312
313 if ( $length && \mb_strlen( $content, 'UTF-8' ) > $length ) {
314 $content = \wordwrap( $content, $length, '</activitypub-summary>' );
315 $content = \explode( '</activitypub-summary>', $content, 2 );
316 $content = $content[0] . ' ' . $excerpt_more;
317 }
318
319 /*
320 There is no proper support for HTML in ActivityPub summaries yet.
321 // This filter is documented in wp-includes/post-template.php.
322 return \apply_filters( 'the_excerpt', $content );
323 */
324 return $content;
325 }
326
327 /**
328 * Get the content warning of a post.
329 *
330 * @param int|\WP_Post $post_id The post ID or post object.
331 *
332 * @return string|false The content warning or false if not found.
333 */
334 function get_content_warning( $post_id ) {
335 $post = get_post( $post_id );
336 if ( ! $post ) {
337 return false;
338 }
339
340 $warning = get_post_meta( $post->ID, 'activitypub_content_warning', true );
341 if ( empty( $warning ) ) {
342 return false;
343 }
344
345 return $warning;
346 }
347
348 /**
349 * Get the ActivityPub ID of a Post by the WordPress Post ID.
350 *
351 * @param int $id The WordPress Post ID.
352 *
353 * @return string The ActivityPub ID (a URL) of the Post.
354 */
355 function get_post_id( $id ) {
356 $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 );
357 $post_id = (int) $id;
358
359 if ( $post_id > $last_legacy_id ) {
360 // Generate URI based on post ID.
361 return \add_query_arg( 'p', $post_id, \home_url( '/' ) );
362 }
363
364 return \get_permalink( $post_id );
365 }
366
367 /**
368 * Get the visibility of a post.
369 *
370 * @param int $post_id The post ID.
371 *
372 * @return string|false The visibility of the post or false if not found.
373 */
374 function get_content_visibility( $post_id ) {
375 $post = get_post( $post_id );
376 if ( ! $post ) {
377 return false;
378 }
379
380 $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
381 $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
382 $options = array(
383 ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC,
384 ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE,
385 ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL,
386 );
387
388 if ( in_array( $visibility, $options, true ) ) {
389 $_visibility = $visibility;
390 }
391
392 /**
393 * Filters the visibility of a post.
394 *
395 * @param string $_visibility The visibility of the post. Possible values are:
396 * - ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC
397 * - ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC
398 * - ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE
399 * - ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL
400 * @param \WP_Post $post The post object.
401 */
402 return \apply_filters( 'activitypub_content_visibility', $_visibility, $post );
403 }
404