PluginProbe
ActivityPub / 9.0.1
ActivityPub v9.0.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 9.0.1, at includes/functions-post.php

440 lines 13.8 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 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 post is never in the federated
182 * 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 enclosures of a post.
259 *
260 * @param int $post_id The post ID.
261 *
262 * @return array The enclosures.
263 */
264 function get_enclosures( $post_id ) {
265 $enclosures = get_post_meta( $post_id, 'enclosure', false );
266
267 if ( ! $enclosures ) {
268 return array();
269 }
270
271 $enclosures = array_map(
272 static function ( $enclosure ) {
273 // Check if the enclosure is a string.
274 if ( ! $enclosure || ! is_string( $enclosure ) ) {
275 return false;
276 }
277
278 $attributes = explode( "\n", $enclosure );
279
280 if ( ! isset( $attributes[0] ) || ! \wp_http_validate_url( $attributes[0] ) ) {
281 return false;
282 }
283
284 return array(
285 'url' => $attributes[0],
286 'length' => $attributes[1] ?? null,
287 'mediaType' => $attributes[2] ?? 'application/octet-stream',
288 );
289 },
290 $enclosures
291 );
292
293 return array_filter( $enclosures );
294 }
295
296 /**
297 * Generates a summary of a post.
298 *
299 * This function generates a summary based on the post's excerpt or content.
300 *
301 * @param int|\WP_Post $post The post ID or post object.
302 * @param integer $length The maximum length of the summary.
303 * Default is 500. It will be ignored if the post excerpt
304 * and the content above the <!--more--> tag.
305 *
306 * @return string The generated post summary.
307 */
308 function generate_post_summary( $post, $length = 500 ) {
309 $post = get_post( $post );
310
311 if ( ! $post ) {
312 return '';
313 }
314
315 /**
316 * Filters the excerpt more value.
317 *
318 * @param string $excerpt_more The excerpt more.
319 */
320 $excerpt_more = \apply_filters( 'activitypub_excerpt_more', '[…]' );
321 $length = $length - \mb_strlen( $excerpt_more, 'UTF-8' );
322
323 $content = \sanitize_post_field( 'post_excerpt', $post->post_excerpt, $post->ID );
324
325 if ( $content ) {
326 // Ignore length if excerpt is set.
327 $length = null;
328 } else {
329 $content = \sanitize_post_field( 'post_content', $post->post_content, $post->ID );
330 $content_parts = \get_extended( $content );
331
332 // Check for the <!--more--> tag.
333 if (
334 ! empty( $content_parts['extended'] ) &&
335 ! empty( $content_parts['main'] )
336 ) {
337 $content = \trim( $content_parts['main'] ) . ' ' . $excerpt_more;
338 $length = null;
339 }
340 }
341
342 $content = \strip_shortcodes( $content );
343 $content = \wp_strip_all_tags( $content );
344 $content = \html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
345 $content = \trim( $content );
346 $content = \preg_replace( '/\R+/mu', "\n\n", $content );
347 $content = \preg_replace( '/[\r\t]/u', '', $content );
348
349 if ( $length && \mb_strlen( $content, 'UTF-8' ) > $length ) {
350 $content = \wordwrap( $content, $length, '</activitypub-summary>' );
351 $content = \explode( '</activitypub-summary>', $content, 2 );
352 $content = $content[0] . ' ' . $excerpt_more;
353 }
354
355 /*
356 There is no proper support for HTML in ActivityPub summaries yet.
357 // This filter is documented in wp-includes/post-template.php.
358 return \apply_filters( 'the_excerpt', $content );
359 */
360 return $content;
361 }
362
363 /**
364 * Get the content warning of a post.
365 *
366 * @param int|\WP_Post $post_id The post ID or post object.
367 *
368 * @return string|false The content warning or false if not found.
369 */
370 function get_content_warning( $post_id ) {
371 $post = get_post( $post_id );
372 if ( ! $post ) {
373 return false;
374 }
375
376 $warning = get_post_meta( $post->ID, 'activitypub_content_warning', true );
377 if ( empty( $warning ) ) {
378 return false;
379 }
380
381 return $warning;
382 }
383
384 /**
385 * Get the ActivityPub ID of a Post by the WordPress Post ID.
386 *
387 * @param int $id The WordPress Post ID.
388 *
389 * @return string The ActivityPub ID (a URL) of the Post.
390 */
391 function get_post_id( $id ) {
392 $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 );
393 $post_id = (int) $id;
394
395 if ( $post_id > $last_legacy_id ) {
396 // Generate URI based on post ID.
397 return \add_query_arg( 'p', $post_id, \home_url( '/' ) );
398 }
399
400 return \get_permalink( $post_id );
401 }
402
403 /**
404 * Get the visibility of a post.
405 *
406 * @param int $post_id The post ID.
407 *
408 * @return string|false The visibility of the post or false if not found.
409 */
410 function get_content_visibility( $post_id ) {
411 $post = get_post( $post_id );
412 if ( ! $post ) {
413 return false;
414 }
415
416 $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
417 $_visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
418 $options = array(
419 ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC,
420 ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE,
421 ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL,
422 );
423
424 if ( in_array( $visibility, $options, true ) ) {
425 $_visibility = $visibility;
426 }
427
428 /**
429 * Filters the visibility of a post.
430 *
431 * @param string $_visibility The visibility of the post. Possible values are:
432 * - ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC
433 * - ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC
434 * - ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE
435 * - ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL
436 * @param \WP_Post $post The post object.
437 */
438 return \apply_filters( 'activitypub_content_visibility', $_visibility, $post );
439 }
440