PluginProbe
ActivityPub / 8.3.0
ActivityPub v8.3.0
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 / collection / class-posts.php

class-posts.php in ActivityPub 8.3.0, at includes/collection/class-posts.php

222 lines 6.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Posts collection file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Collection;
9
10 use Activitypub\Blocks;
11 use Activitypub\Hashtag;
12 use Activitypub\Link;
13
14 use function Activitypub\get_content_visibility;
15 use function Activitypub\user_can_act_as_blog;
16
17 /**
18 * Posts collection.
19 *
20 * Provides CRUD methods for local WordPress posts created
21 * via ActivityPub Client-to-Server (C2S) outbox.
22 *
23 * @see Remote_Posts for federated posts received via Server-to-Server (S2S).
24 */
25 class Posts {
26 /**
27 * Create a WordPress post from an ActivityPub activity.
28 *
29 * @since 8.1.0
30 *
31 * @param array $activity The activity data.
32 * @param int $user_id The local user ID.
33 * @param string|null $visibility Content visibility.
34 *
35 * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure.
36 */
37 public static function create( $activity, $user_id, $visibility = null ) {
38 // Resolve the post author. Blog actor falls back to the current user for a real byline.
39 $post_author = $user_id > 0 ? $user_id : \get_current_user_id();
40
41 /*
42 * Authorize the request:
43 * - Per-user path: require `publish_posts` on the URL-specified user.
44 * - Blog actor path (post_author falls back to current user): require
45 * the act-as-blog grant. `publish_posts` is implicit because the
46 * helper defaults to `manage_options` (administrators).
47 * - Cron/CLI path keeps `post_author = 0` and bypasses both checks.
48 */
49 if ( $post_author > 0 ) {
50 $authorized = $post_author === (int) $user_id
51 ? \user_can( $user_id, 'publish_posts' )
52 : user_can_act_as_blog();
53
54 if ( ! $authorized ) {
55 return new \WP_Error(
56 'activitypub_forbidden',
57 \__( 'You do not have permission to create posts.', 'activitypub' ),
58 array( 'status' => 403 )
59 );
60 }
61 }
62
63 $object = $activity['object'] ?? array();
64
65 $object_type = $object['type'] ?? '';
66 $content = \wp_kses_post( $object['content'] ?? '' );
67 $name = \sanitize_text_field( $object['name'] ?? '' );
68 $summary = \wp_kses_post( $object['summary'] ?? '' );
69 $plain_summary = \sanitize_text_field( $summary );
70
71 // A summary marked sensitive is a content warning (plain text); otherwise it's a regular excerpt.
72 // Route on the sanitized summary so whitespace-only values don't pollute either field.
73 $content_warning = ! empty( $object['sensitive'] ) && '' !== $plain_summary ? $plain_summary : '';
74 $post_excerpt = '' === $content_warning && '' !== $plain_summary ? $summary : '';
75
76 // Process content: autop, autolink, hashtags, and convert to blocks.
77 $content = self::prepare_content( $content );
78
79 // Use name as title for Articles, or generate from content for Notes.
80 $title = $name;
81 if ( empty( $title ) && ! empty( $content ) ) {
82 $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
83 }
84
85 // Determine visibility if not provided.
86 if ( null === $visibility ) {
87 $visibility = get_content_visibility( $activity );
88 }
89
90 $post_data = array(
91 'post_author' => $post_author,
92 'post_title' => $title,
93 'post_content' => $content,
94 'post_excerpt' => $post_excerpt,
95 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish',
96 'post_type' => 'post',
97 'meta_input' => array(
98 'activitypub_content_visibility' => $visibility,
99 'activitypub_content_warning' => $content_warning,
100 ),
101 );
102
103 $post_id = \wp_insert_post( $post_data, true );
104
105 if ( \is_wp_error( $post_id ) ) {
106 return $post_id;
107 }
108
109 // Set post format to 'status' for Notes so the transformer maps it back correctly.
110 if ( 'Note' === $object_type ) {
111 \set_post_format( $post_id, 'status' );
112 }
113
114 return \get_post( $post_id );
115 }
116
117 /**
118 * Update a WordPress post from an ActivityPub activity.
119 *
120 * @since 8.1.0
121 *
122 * @param \WP_Post $post The post to update.
123 * @param array $activity The activity data.
124 * @param string|null $visibility Content visibility.
125 *
126 * @return \WP_Post|\WP_Error The updated post on success, WP_Error on failure.
127 */
128 public static function update( $post, $activity, $visibility = null ) {
129 $object = $activity['object'] ?? array();
130
131 $content = \wp_kses_post( $object['content'] ?? '' );
132 $name = \sanitize_text_field( $object['name'] ?? '' );
133 $summary = \wp_kses_post( $object['summary'] ?? '' );
134 $plain_summary = \sanitize_text_field( $summary );
135
136 // A summary marked sensitive is a content warning (plain text); otherwise it's a regular excerpt.
137 // Route on the sanitized summary so whitespace-only values don't pollute either field.
138 $content_warning = ! empty( $object['sensitive'] ) && '' !== $plain_summary ? $plain_summary : '';
139 $post_excerpt = '' === $content_warning && '' !== $plain_summary ? $summary : '';
140
141 // Process content: autop, autolink, hashtags, and convert to blocks.
142 $content = self::prepare_content( $content );
143
144 // Use name as title for Articles, or generate from content for Notes.
145 $title = $name;
146 if ( empty( $title ) && ! empty( $content ) ) {
147 $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
148 }
149
150 // Determine visibility if not provided.
151 if ( null === $visibility ) {
152 $visibility = get_content_visibility( $activity );
153 }
154
155 $post_data = array(
156 'ID' => $post->ID,
157 'post_title' => $title,
158 'post_content' => $content,
159 'post_excerpt' => $post_excerpt,
160 'meta_input' => array(
161 'activitypub_content_visibility' => $visibility,
162 'activitypub_content_warning' => $content_warning,
163 ),
164 );
165
166 $post_id = \wp_update_post( $post_data, true );
167
168 if ( \is_wp_error( $post_id ) ) {
169 return $post_id;
170 }
171
172 return \get_post( $post_id );
173 }
174
175 /**
176 * Delete (trash) a WordPress post.
177 *
178 * @since 8.1.0
179 *
180 * @param int $post_id The post ID.
181 *
182 * @return \WP_Post|false|null Post data on success, false or null on failure.
183 */
184 public static function delete( $post_id ) {
185 return \wp_trash_post( $post_id );
186 }
187
188 /**
189 * Prepare content for storage as a WordPress post.
190 *
191 * Applies wpautop (for plain text), autolinks bare URLs,
192 * converts hashtags to links, and wraps in block markup.
193 *
194 * @since 8.1.0
195 *
196 * @param string $content The HTML or plain-text content.
197 *
198 * @return string The processed content with block markup.
199 */
200 public static function prepare_content( $content ) {
201 if ( empty( $content ) ) {
202 return '';
203 }
204
205 // Wrap plain text in paragraphs if it has no block-level HTML.
206 if ( ! \preg_match( '/<(p|h[1-6]|ul|ol|blockquote|figure|hr|img|div|pre|table)\b/i', $content ) ) {
207 $content = \wpautop( $content );
208 }
209
210 // Convert bare URLs to links.
211 $content = Link::the_content( $content );
212
213 // Convert #hashtags to links.
214 $content = Hashtag::the_content( $content );
215
216 // Convert HTML to block markup.
217 $content = Blocks::convert_from_html( $content );
218
219 return $content;
220 }
221 }
222