PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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 trunk, at includes/functions-post.php

470 lines 14.9 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 // Draft, pending, and scheduled posts are allowed during preview requests so the Fediverse Preview works.
146 $is_preview = \in_array( $post->post_status, array( 'draft', 'pending', 'future' ), 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 whether a post is federated.
170 *
171 * A post is federated when it has been sent to the Fediverse (its federation
172 * state is "federated") AND it is still publicly queryable, i.e. its post type
173 * is enabled for ActivityPub, it has a public status, it is not password-
174 * protected, and its content visibility allows it (see `is_post_publicly_queryable()`).
175 *
176 * Re-checking the live queryability alongside the stored state guards against a
177 * stale "federated" status left behind when a post is moved to a private status,
178 * switched to local visibility, or its post type loses ActivityPub support.
179 *
180 * The federation-state check also keeps `is_post_publicly_queryable()`'s
181 * preview allowance inert here: a draft/pending/scheduled post is never in the
182 * federated state, so the preview branch can never make this return true.
183 *
184 * @since 9.0.0
185 *
186 * @param mixed $post The post ID or object.
187 *
188 * @return boolean True if the post is federated, false otherwise.
189 */
190 function is_post_federated( $post ) {
191 if ( empty( $post ) ) {
192 return false;
193 }
194
195 $post = \get_post( $post );
196
197 if ( ! $post ) {
198 return false;
199 }
200
201 return ACTIVITYPUB_OBJECT_STATE_FEDERATED === get_wp_object_state( $post ) && is_post_publicly_queryable( $post );
202 }
203
204 /**
205 * Check if a post is an ActivityPub post.
206 *
207 * @param mixed $post The post object or ID.
208 *
209 * @return boolean True if the post is an ActivityPub post, false otherwise.
210 */
211 function is_ap_post( $post ) {
212 $post = \get_post( $post );
213
214 if ( ! $post ) {
215 return false;
216 }
217
218 // Check for ap_post post type.
219 return Remote_Posts::POST_TYPE === $post->post_type;
220 }
221
222 /**
223 * Get the description of a post type.
224 *
225 * Set some default descriptions for the default post types.
226 *
227 * @param \WP_Post_Type $post_type The post type object.
228 *
229 * @return string The description of the post type.
230 */
231 function get_post_type_description( $post_type ) {
232 switch ( $post_type->name ) {
233 case 'post':
234 case 'page':
235 $description = '';
236 break;
237 case 'attachment':
238 $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' );
239 break;
240 default:
241 $description = '';
242 if ( ! empty( $post_type->description ) ) {
243 $description = ' - ' . $post_type->description;
244 }
245 }
246
247 /**
248 * Allow plugins to get the description of a post type.
249 *
250 * @param string $description The description of the post type.
251 * @param string $post_type_name The post type name.
252 * @param \WP_Post_Type $post_type The post type object.
253 */
254 return \apply_filters( 'activitypub_post_type_description', $description, $post_type->name, $post_type );
255 }
256
257 /**
258 * Get the maximum number of media attachments a post may federate.
259 *
260 * A per-post limit wins over the site-wide setting, and the filter has the final say.
261 *
262 * @since 9.3.0
263 *
264 * @param int $post_id The post ID.
265 *
266 * @return int The maximum number of media attachments.
267 */
268 function get_max_attachments( $post_id ) {
269 $max_media = \get_post_meta( $post_id, 'activitypub_max_image_attachments', true );
270
271 if ( ! \is_numeric( $max_media ) ) {
272 $max_media = \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS );
273 }
274
275 /**
276 * Filters the maximum number of media attachments allowed in a post.
277 *
278 * Despite the name suggesting only images, this filter controls the maximum number
279 * of all media attachments (images, audio, and video) that can be included in an
280 * ActivityPub post. The name is maintained for backwards compatibility.
281 *
282 * @param int $max_media Maximum number of media attachments. Default ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS.
283 */
284 return (int) \apply_filters( 'activitypub_max_image_attachments', $max_media );
285 }
286
287 /**
288 * Get the enclosures of a post.
289 *
290 * @param int $post_id The post ID.
291 *
292 * @return array The enclosures.
293 */
294 function get_enclosures( $post_id ) {
295 $enclosures = \get_post_meta( $post_id, 'enclosure', false );
296
297 if ( ! $enclosures ) {
298 return array();
299 }
300
301 $enclosures = \array_map(
302 static function ( $enclosure ) {
303 // Check if the enclosure is a string.
304 if ( ! $enclosure || ! \is_string( $enclosure ) ) {
305 return false;
306 }
307
308 $attributes = \explode( "\n", $enclosure );
309
310 if ( ! isset( $attributes[0] ) || ! \wp_http_validate_url( $attributes[0] ) ) {
311 return false;
312 }
313
314 return array(
315 'url' => $attributes[0],
316 'length' => $attributes[1] ?? null,
317 'mediaType' => $attributes[2] ?? 'application/octet-stream',
318 );
319 },
320 $enclosures
321 );
322
323 return \array_filter( $enclosures );
324 }
325
326 /**
327 * Generates a summary of a post.
328 *
329 * This function generates a summary based on the post's excerpt or content.
330 *
331 * @param int|\WP_Post $post The post ID or post object.
332 * @param integer $length The maximum length of the summary.
333 * Default is 500. It will be ignored if the post excerpt
334 * and the content above the <!--more--> tag.
335 *
336 * @return string The generated post summary.
337 */
338 function generate_post_summary( $post, $length = 500 ) {
339 $post = \get_post( $post );
340
341 if ( ! $post ) {
342 return '';
343 }
344
345 /**
346 * Filters the excerpt more value.
347 *
348 * @param string $excerpt_more The excerpt more.
349 */
350 $excerpt_more = \apply_filters( 'activitypub_excerpt_more', '[…]' );
351 $length = $length - \mb_strlen( $excerpt_more, 'UTF-8' );
352
353 $content = \sanitize_post_field( 'post_excerpt', $post->post_excerpt, $post->ID );
354
355 if ( $content ) {
356 // Ignore length if excerpt is set.
357 $length = null;
358 } else {
359 $content = \sanitize_post_field( 'post_content', $post->post_content, $post->ID );
360 $content_parts = \get_extended( $content );
361
362 // Check for the <!--more--> tag.
363 if (
364 ! empty( $content_parts['extended'] ) &&
365 ! empty( $content_parts['main'] )
366 ) {
367 $content = \trim( $content_parts['main'] ) . ' ' . $excerpt_more;
368 $length = null;
369 }
370 }
371
372 $content = \strip_shortcodes( $content );
373 $content = \wp_strip_all_tags( $content );
374 $content = \html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
375 $content = \trim( $content );
376 $content = \preg_replace( '/\R+/mu', "\n\n", $content );
377 $content = \preg_replace( '/[\r\t]/u', '', $content );
378
379 if ( $length && \mb_strlen( $content, 'UTF-8' ) > $length ) {
380 $content = \wordwrap( $content, $length, '</activitypub-summary>' );
381 $content = \explode( '</activitypub-summary>', $content, 2 );
382 $content = $content[0] . ' ' . $excerpt_more;
383 }
384
385 /*
386 There is no proper support for HTML in ActivityPub summaries yet.
387 // This filter is documented in wp-includes/post-template.php.
388 return \apply_filters( 'the_excerpt', $content );
389 */
390 return $content;
391 }
392
393 /**
394 * Get the content warning of a post.
395 *
396 * @param int|\WP_Post $post_id The post ID or post object.
397 *
398 * @return string|false The content warning or false if not found.
399 */
400 function get_content_warning( $post_id ) {
401 $post = \get_post( $post_id );
402 if ( ! $post ) {
403 return false;
404 }
405
406 $warning = \get_post_meta( $post->ID, 'activitypub_content_warning', true );
407 if ( empty( $warning ) ) {
408 return false;
409 }
410
411 return $warning;
412 }
413
414 /**
415 * Get the ActivityPub ID of a Post by the WordPress Post ID.
416 *
417 * @param int $id The WordPress Post ID.
418 *
419 * @return string The ActivityPub ID (a URL) of the Post.
420 */
421 function get_post_id( $id ) {
422 $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 );
423 $post_id = (int) $id;
424
425 if ( $post_id > $last_legacy_id ) {
426 // Generate URI based on post ID.
427 return \add_query_arg( 'p', $post_id, \home_url( '/' ) );
428 }
429
430 return \get_permalink( $post_id );
431 }
432
433 /**
434 * Get the visibility of a post.
435 *
436 * @param int $post_id The post ID.
437 *
438 * @return string|false The visibility of the post or false if not found.
439 */
440 function get_content_visibility( $post_id ) {
441 $post = \get_post( $post_id );
442 if ( ! $post ) {
443 return false;
444 }
445
446 $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
447 $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
448 $options = array(
449 ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC,
450 ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE,
451 ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL,
452 );
453
454 if ( \in_array( $visibility, $options, true ) ) {
455 $_visibility = $visibility;
456 }
457
458 /**
459 * Filters the visibility of a post.
460 *
461 * @param string $_visibility The visibility of the post. Possible values are:
462 * - ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC
463 * - ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC
464 * - ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE
465 * - ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL
466 * @param \WP_Post $post The post object.
467 */
468 return \apply_filters( 'activitypub_content_visibility', $_visibility, $post );
469 }
470