PluginProbe
ActivityPub / 7.8.2
ActivityPub v7.8.2
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
← All changes | includes/class-attachments.php +517 -248 9.3.17.8.2 View file →
@@ -6,28 +6,81 @@
6 6 */
7 7
8 8 namespace Activitypub;
9 9
10 +use Activitypub\Collection\Posts;
11 +use Activitypub\Collection\Remote_Actors;
12 +
10 13 /**
11 14 * Attachments processor class.
12 - *
13 - * Handles importing media attachments into the WordPress Media Library.
14 - * Creates full WordPress attachment posts that are searchable and manageable.
15 - *
16 - * For lightweight file caching without Media Library overhead, use the
17 - * Cache\Media, Cache\Avatar, and Cache\Emoji classes instead.
18 - *
19 - * @since 1.0.0
20 15 */
21 16 class Attachments {
22 17 /**
23 - * Maximum width for imported images into Media Library.
18 + * Directory for storing ap_post media files.
24 19 *
20 + * @var string
21 + */
22 + public static $ap_posts_dir = '/activitypub/ap_posts/';
23 +
24 + /**
25 + * Directory for storing comment media files.
26 + *
27 + * @var string
28 + */
29 + public static $comments_dir = '/activitypub/comments/';
30 +
31 + /**
32 + * Directory for storing actor avatar files.
33 + *
34 + * @var string
35 + */
36 + public static $actors_dir = '/activitypub/actors/';
37 +
38 + /**
39 + * Maximum width for imported images.
40 + *
25 41 * @var int
26 42 */
27 43 const MAX_IMAGE_DIMENSION = 1200;
28 44
29 45 /**
46 + * Maximum width for actor avatars.
47 + *
48 + * @var int
49 + */
50 + const MAX_AVATAR_DIMENSION = 512;
51 +
52 + /**
53 + * Initialize the class and set up filters.
54 + */
55 + public static function init() {
56 + \add_action( 'before_delete_post', array( self::class, 'delete_ap_posts_directory' ) );
57 + \add_action( 'before_delete_post', array( self::class, 'delete_actors_directory' ) );
58 + }
59 +
60 + /**
61 + * Delete the activitypub files directory for a post.
62 + *
63 + * @param int $post_id The post ID.
64 + */
65 + public static function delete_ap_posts_directory( $post_id ) {
66 + if ( Posts::POST_TYPE !== \get_post_type( $post_id ) ) {
67 + return;
68 + }
69 +
70 + require_once ABSPATH . 'wp-admin/includes/file.php';
71 +
72 + \WP_Filesystem();
73 + global $wp_filesystem;
74 +
75 + $activitypub_dir = self::get_storage_paths( $post_id, 'post' )['basedir'];
76 +
77 + if ( $wp_filesystem->is_dir( $activitypub_dir ) ) {
78 + $wp_filesystem->delete( $activitypub_dir, true );
79 + }
80 + }
81 +
82 + /**
30 83 * Import attachments from an ActivityPub object and attach them to a post.
31 84 *
32 85 * Creates full WordPress attachment posts in the media library. Each attachment
33 86 * becomes a searchable, manageable attachment post that appears in the WordPress
@@ -48,9 +101,9 @@
48 101 public static function import( $attachments, $post_id, $author_id = 0 ) {
49 102 // First, import inline images from the post content.
50 103 $inline_mappings = self::import_inline_images( $post_id, $author_id );
51 104
52 - if ( empty( $attachments ) || ! \is_array( $attachments ) ) {
105 + if ( empty( $attachments ) || ! is_array( $attachments ) ) {
53 106 return array();
54 107 }
55 108
56 109 $attachment_ids = array();
@@ -81,8 +134,158 @@
81 134 return $attachment_ids;
82 135 }
83 136
84 137 /**
138 + * Import attachments as direct files for posts.
139 + *
140 + * Saves files directly to uploads/activitypub/ap_posts/{post_id}/ without creating
141 + * WordPress attachment posts. This lightweight approach is ideal for federated content
142 + * that doesn't require full WordPress media management.
143 + *
144 + * Files are stored in a dedicated directory structure and automatically cleaned up
145 + * when the parent post is deleted. Media URLs point directly to the stored files
146 + * rather than going through WordPress attachment APIs.
147 + *
148 + * Use this when:
149 + * - Processing ActivityPub Create/Update activities from the inbox.
150 + * - Handling federated content that won't be owned or edited by the user.
151 + * - You want lightweight storage without Media Library overhead.
152 + *
153 + * @param array $attachments Array of ActivityPub attachment objects.
154 + * @param int $post_id The post ID to attach files to.
155 + *
156 + * @return array[] Array of file data arrays.
157 + */
158 + public static function import_post_files( $attachments, $post_id ) {
159 + return self::import_files_for_object( $attachments, $post_id, 'post' );
160 + }
161 +
162 + /**
163 + * Import attachments as direct files for any object type.
164 + *
165 + * Saves files directly to uploads/activitypub/{type}/{id}/ without creating
166 + * WordPress attachment posts. This is the internal method that handles
167 + * the actual import logic for both posts and comments.
168 + *
169 + * @param array $attachments Array of ActivityPub attachment objects.
170 + * @param int $object_id The object ID (post or comment).
171 + * @param string $object_type The object type ('post' or 'comment').
172 + *
173 + * @return array[] Array of file data arrays.
174 + */
175 + private static function import_files_for_object( $attachments, $object_id, $object_type ) {
176 + // First, import inline images from the content.
177 + $inline_mappings = self::import_inline_files( $object_id, $object_type );
178 +
179 + if ( empty( $attachments ) || ! is_array( $attachments ) ) {
180 + return array();
181 + }
182 +
183 + $files = array();
184 + foreach ( $attachments as $attachment ) {
185 + $attachment_data = self::normalize_attachment( $attachment );
186 +
187 + if ( empty( $attachment_data['url'] ) ) {
188 + continue;
189 + }
190 +
191 + // Skip if this URL was already processed as an inline image.
192 + if ( isset( $inline_mappings[ $attachment_data['url'] ] ) ) {
193 + continue;
194 + }
195 +
196 + $file_data = self::save_file( $attachment_data, $object_id, $object_type );
197 +
198 + if ( ! \is_wp_error( $file_data ) ) {
199 + $files[] = $file_data;
200 + }
201 + }
202 +
203 + // Append media markup to content.
204 + if ( ! empty( $files ) ) {
205 + self::append_files_to_content( $object_id, $files, $object_type );
206 + }
207 +
208 + return $files;
209 + }
210 +
211 + /**
212 + * Get storage paths for an object based on its type.
213 + *
214 + * @param int $object_id The object ID (post or comment).
215 + * @param string $object_type The object type ('post' or 'comment').
216 + *
217 + * @return array {
218 + * Storage paths for the object.
219 + *
220 + * @type string $basedir Base directory path.
221 + * @type string $baseurl Base URL.
222 + * }
223 + */
224 + private static function get_storage_paths( $object_id, $object_type ) {
225 + $upload_dir = \wp_upload_dir();
226 +
227 + switch ( $object_type ) {
228 + case 'comment':
229 + $sub_dir = self::$comments_dir;
230 + break;
231 + case 'actor':
232 + $sub_dir = self::$actors_dir;
233 + break;
234 + default:
235 + $sub_dir = self::$ap_posts_dir;
236 + break;
237 + }
238 +
239 + return array(
240 + 'basedir' => $upload_dir['basedir'] . $sub_dir . $object_id,
241 + 'baseurl' => $upload_dir['baseurl'] . $sub_dir . $object_id,
242 + );
243 + }
244 +
245 + /**
246 + * Get content for an object based on its type.
247 + *
248 + * @param int $object_id The object ID (post or comment).
249 + * @param string $object_type The object type ('post' or 'comment').
250 + *
251 + * @return string The content string or empty if not found.
252 + */
253 + private static function get_object_content( $object_id, $object_type ) {
254 + if ( 'comment' === $object_type ) {
255 + $comment = \get_comment( $object_id );
256 + return $comment ? $comment->comment_content : '';
257 + }
258 +
259 + return \get_post_field( 'post_content', $object_id );
260 + }
261 +
262 + /**
263 + * Update content for an object based on its type.
264 + *
265 + * @param int $object_id The object ID (post or comment).
266 + * @param string $object_type The object type ('post' or 'comment').
267 + * @param string $content The new content.
268 + */
269 + private static function update_object_content( $object_id, $object_type, $content ) {
270 + if ( 'comment' === $object_type ) {
271 + \wp_update_comment(
272 + array(
273 + 'comment_ID' => $object_id,
274 + 'comment_content' => $content,
275 + )
276 + );
277 + } else {
278 + \wp_update_post(
279 + array(
280 + 'ID' => $object_id,
281 + 'post_content' => $content,
282 + )
283 + );
284 + }
285 + }
286 +
287 + /**
85 288 * Check if an attachment with the same source URL already exists for a post.
86 289 *
87 290 * @param string $source_url The source URL to check.
88 291 * @param int $post_id The post ID to check attachments for.
@@ -113,9 +316,9 @@
113 316 return array();
114 317 }
115 318
116 319 // Find all img tags in the content.
117 - \preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );
320 + preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );
118 321
119 322 if ( empty( $matches[1] ) ) {
120 323 return array();
121 324 }
@@ -159,8 +362,58 @@
159 362 return $url_mappings;
160 363 }
161 364
162 365 /**
366 + * Process inline images from content (for direct file storage).
367 + *
368 + * @param int $object_id The post or comment ID.
369 + * @param string $object_type The object type ('post' or 'comment').
370 + *
371 + * @return array Array of URL mappings (old URL => new URL).
372 + */
373 + private static function import_inline_files( $object_id, $object_type ) {
374 + $content = self::get_object_content( $object_id, $object_type );
375 + if ( ! $content ) {
376 + return array();
377 + }
378 +
379 + // Find all img tags in the content.
380 + preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $matches );
381 +
382 + if ( empty( $matches[1] ) ) {
383 + return array();
384 + }
385 +
386 + $url_mappings = array();
387 +
388 + foreach ( $matches[1] as $image_url ) {
389 + // Skip if already processed.
390 + if ( isset( $url_mappings[ $image_url ] ) ) {
391 + continue;
392 + }
393 +
394 + $file_data = self::save_file( array( 'url' => $image_url ), $object_id, $object_type );
395 +
396 + if ( \is_wp_error( $file_data ) ) {
397 + continue;
398 + }
399 +
400 + $new_url = $file_data['url'];
401 + if ( $new_url ) {
402 + $url_mappings[ $image_url ] = $new_url;
403 + $content = \str_replace( $image_url, $new_url, $content );
404 + }
405 + }
406 +
407 + // Update content if URLs were replaced.
408 + if ( ! empty( $url_mappings ) ) {
409 + self::update_object_content( $object_id, $object_type, $content );
410 + }
411 +
412 + return $url_mappings;
413 + }
414 +
415 + /**
163 416 * Normalize an ActivityPub attachment object to a standard format.
164 417 *
165 418 * @param mixed $attachment The attachment data (array or object).
166 419 *
@@ -171,9 +424,9 @@
171 424 if ( \is_object( $attachment ) ) {
172 425 $attachment = \get_object_vars( $attachment );
173 426 }
174 427
175 - if ( ! \is_array( $attachment ) || empty( $attachment['url'] ) ) {
428 + if ( ! is_array( $attachment ) || empty( $attachment['url'] ) ) {
176 429 return false;
177 430 }
178 431
179 432 return array(
@@ -200,38 +453,25 @@
200 453 require_once ABSPATH . 'wp-admin/includes/file.php';
201 454 require_once ABSPATH . 'wp-admin/includes/image.php';
202 455 }
203 456
204 - // Use WP_Filesystem_Direct explicitly to avoid FTP fallback from WP_Filesystem().
205 - require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
206 - require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
457 + // Initialize filesystem.
458 + \WP_Filesystem();
459 + global $wp_filesystem;
207 460
208 - $filesystem = new \WP_Filesystem_Direct( null );
461 + $is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] );
209 462
210 - $is_local = ! \preg_match( '#^https?://#i', $attachment_data['url'] );
211 -
212 463 if ( $is_local ) {
213 - // Validate local path is within allowed directories to prevent file disclosure.
214 - $allowed = self::is_allowed_local_path( $attachment_data['url'] );
215 - if ( ! $allowed ) {
216 - return new \WP_Error( 'invalid_path', \__( 'Local file path is not within allowed directories.', 'activitypub' ) );
217 - }
218 -
219 464 // Read local file from disk.
220 - if ( ! $filesystem->exists( $attachment_data['url'] ) ) {
465 + if ( ! $wp_filesystem->exists( $attachment_data['url'] ) ) {
221 466 /* translators: %s: file path */
222 - return new \WP_Error( 'file_not_found', \sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
467 + return new \WP_Error( 'file_not_found', sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
223 468 }
224 469
225 470 // Copy to temp file so media_handle_sideload doesn't move the original.
226 471 $tmp_file = \wp_tempnam( \basename( $attachment_data['url'] ) );
227 - $filesystem->copy( $attachment_data['url'], $tmp_file, true );
472 + $wp_filesystem->copy( $attachment_data['url'], $tmp_file, true );
228 473 } else {
229 - // Validate remote URL before downloading.
230 - if ( ! \wp_http_validate_url( $attachment_data['url'] ) ) {
231 - return new \WP_Error( 'invalid_url', \__( 'URL is not allowed.', 'activitypub' ) );
232 - }
233 -
234 474 // Download remote URL.
235 475 $tmp_file = \download_url( $attachment_data['url'] );
236 476
237 477 if ( \is_wp_error( $tmp_file ) ) {
@@ -245,9 +485,9 @@
245 485 // Rename temp file to have proper extension for optimize_image to detect mime type.
246 486 $original_ext = \pathinfo( $original_name, PATHINFO_EXTENSION );
247 487 if ( $original_ext ) {
248 488 $renamed_tmp = $tmp_file . '.' . $original_ext;
249 - if ( $filesystem->move( $tmp_file, $renamed_tmp, true ) ) {
489 + if ( $wp_filesystem->move( $tmp_file, $renamed_tmp, true ) ) {
250 490 $tmp_file = $renamed_tmp;
251 491 }
252 492 }
253 493
@@ -264,25 +504,13 @@
264 504 'name' => $original_name,
265 505 'tmp_name' => $tmp_file,
266 506 );
267 507
268 - // Remote JSON can hand us an array where a string was expected.
269 - $name = $attachment_data['name'] ?? '';
270 - $plain_name = \is_string( $name ) ? \wp_strip_all_tags( $name ) : '';
271 -
272 508 // Prepare attachment post data.
273 509 // Let WordPress auto-detect the mime type from the file.
274 510 $post_data = array(
275 -
276 - /*
277 - * `name` comes from the remote object or an uploaded archive, and callers can run
278 - * as a user with `unfiltered_html`, for which `kses_init()` installs no filters:
279 - * `media_handle_sideload()` would store whatever it was given. Attachment pages
280 - * are public, and `post_content` renders through `the_content` there, so it gets
281 - * the same treatment as remote post content.
282 - */
283 - 'post_title' => \wp_slash( $plain_name ),
284 - 'post_content' => \wp_slash( $plain_name ),
511 + 'post_title' => $attachment_data['name'] ?? '',
512 + 'post_content' => $attachment_data['name'] ?? '',
285 513 'post_author' => $author_id,
286 514 'meta_input' => array(
287 515 '_source_url' => $attachment_data['url'],
288 516 ),
@@ -288,18 +516,12 @@
288 516 ),
289 517 );
290 518
291 519 // Add alt text for images.
292 - if ( '' !== $plain_name ) {
520 + if ( ! empty( $attachment_data['name'] ) ) {
293 521 $original_mime = $attachment_data['mediaType'] ?? '';
294 - if ( 'image' === \strtok( $original_mime, '/' ) ) {
295 - /*
296 - * The same plain-text value as the title. Core does not sanitize this meta
297 - * on write -- it only strips tags in the media-modal AJAX handler --. `Transformer\Attachment` federates it straight back
298 - * out as the ActivityPub `name`, and consumers expect it to be plain text.
299 - */
300 - // Slashed like the columns above: update_metadata() unslashes what it is given.
301 - $post_data['meta_input']['_wp_attachment_image_alt'] = \wp_slash( $plain_name );
522 + if ( 'image' === strtok( $original_mime, '/' ) ) {
523 + $post_data['meta_input']['_wp_attachment_image_alt'] = $attachment_data['name'];
302 524 }
303 525 }
304 526
305 527 // Sideload the attachment into WordPress.
@@ -313,8 +535,98 @@
313 535 return $attachment_id;
314 536 }
315 537
316 538 /**
539 + * Save a file directly to uploads/activitypub/{type}/{id}/.
540 + *
541 + * For video and audio files, returns the remote URL directly without downloading
542 + * to avoid storage overhead for large media files.
543 + *
544 + * @param array $attachment_data The normalized attachment data.
545 + * @param int $object_id The post or comment ID to attach to.
546 + * @param string $object_type The object type ('post' or 'comment').
547 + * @param int $max_dimension Optional. Maximum image dimension in pixels. Default MAX_IMAGE_DIMENSION.
548 + *
549 + * @return array|\WP_Error {
550 + * Array of file data on success, WP_Error on failure.
551 + *
552 + * @type string $url Full URL to the saved file (or remote URL for video/audio).
553 + * @type string $mime_type MIME type of the file.
554 + * @type string $alt Alt text from attachment name field.
555 + * }
556 + */
557 + private static function save_file( $attachment_data, $object_id, $object_type, $max_dimension = self::MAX_IMAGE_DIMENSION ) {
558 + $mime_type = $attachment_data['mediaType'] ?? '';
559 +
560 + // Skip download for video and audio files - use remote URL directly.
561 + if ( str_starts_with( $mime_type, 'video/' ) || str_starts_with( $mime_type, 'audio/' ) ) {
562 + return array(
563 + 'url' => $attachment_data['url'],
564 + 'mime_type' => $attachment_data['mediaType'],
565 + 'alt' => $attachment_data['name'] ?? '',
566 + );
567 + }
568 +
569 + if ( ! \function_exists( 'download_url' ) ) {
570 + require_once ABSPATH . 'wp-admin/includes/file.php';
571 + }
572 +
573 + // Download remote URL.
574 + $tmp_file = \download_url( $attachment_data['url'] );
575 +
576 + if ( \is_wp_error( $tmp_file ) ) {
577 + return $tmp_file;
578 + }
579 +
580 + // Get storage paths for this object.
581 + $paths = self::get_storage_paths( $object_id, $object_type );
582 +
583 + // Create directory if it doesn't exist.
584 + \wp_mkdir_p( $paths['basedir'] );
585 +
586 + // Generate unique file name.
587 + $url_path = \wp_parse_url( $attachment_data['url'], PHP_URL_PATH );
588 + $file_name = \sanitize_file_name( \basename( $url_path ) );
589 + $file_path = $paths['basedir'] . '/' . $file_name;
590 +
591 + // Initialize filesystem if needed.
592 + \WP_Filesystem();
593 + global $wp_filesystem;
594 +
595 + // Make sure file name is unique.
596 + $counter = 1;
597 + while ( $wp_filesystem->exists( $file_path ) ) {
598 + $path_info = pathinfo( $file_name );
599 + $file_name = $path_info['filename'] . '-' . $counter;
600 + if ( ! empty( $path_info['extension'] ) ) {
601 + $file_name .= '.' . $path_info['extension'];
602 + }
603 + $file_path = $paths['basedir'] . '/' . $file_name;
604 + ++$counter;
605 + }
606 +
607 + // Move file to destination.
608 + if ( ! $wp_filesystem->move( $tmp_file, $file_path, true ) ) {
609 + \wp_delete_file( $tmp_file );
610 + return new \WP_Error( 'file_move_failed', \__( 'Failed to move file to destination.', 'activitypub' ) );
611 + }
612 +
613 + // Optimize images (resize and convert to WebP).
614 + $file_path = self::optimize_image( $file_path, $max_dimension );
615 + $file_name = \basename( $file_path );
616 +
617 + // Get mime type and validate file.
618 + $file_info = \wp_check_filetype_and_ext( $file_path, $file_name );
619 + $mime_type = $file_info['type'] ?? $attachment_data['mediaType'] ?? '';
620 +
621 + return array(
622 + 'url' => $paths['baseurl'] . '/' . $file_name,
623 + 'mime_type' => $mime_type,
624 + 'alt' => $attachment_data['name'] ?? '',
625 + );
626 + }
627 +
628 + /**
317 629 * Get a unique file path by appending a counter if the file already exists.
318 630 *
319 631 * @param string $file_path The desired file path.
320 632 *
@@ -339,61 +651,8 @@
339 651 return $new_path;
340 652 }
341 653
342 654 /**
343 - * Check if a local file path is within allowed directories.
344 - *
345 - * Prevents arbitrary file access by restricting local paths to known safe
346 - * directories like the uploads folder or WordPress temp directory.
347 - *
348 - * @param string $file_path The local file path to validate.
349 - *
350 - * @return bool True if the path is allowed, false otherwise.
351 - */
352 - private static function is_allowed_local_path( $file_path ) {
353 - // Normalize the path and resolve any relative components.
354 - $real_path = \realpath( $file_path );
355 - if ( false === $real_path ) {
356 - // If file doesn't exist yet, check the directory.
357 - $dir_path = \realpath( \dirname( $file_path ) );
358 - if ( false === $dir_path ) {
359 - return false;
360 - }
361 - $real_path = $dir_path . '/' . \basename( $file_path );
362 - }
363 -
364 - // Get allowed base directories.
365 - $upload_dir = \wp_upload_dir();
366 - $allowed_dirs = array(
367 - \realpath( $upload_dir['basedir'] ),
368 - \realpath( \get_temp_dir() ),
369 - \realpath( ABSPATH . 'wp-content' ),
370 - );
371 -
372 - /**
373 - * Filters the allowed directories for local file imports.
374 - *
375 - * @since 5.6.0
376 - *
377 - * @param string[] $allowed_dirs Array of allowed directory paths.
378 - * @param string $file_path The file path being validated.
379 - */
380 - $allowed_dirs = \apply_filters( 'activitypub_allowed_import_directories', $allowed_dirs, $file_path );
381 -
382 - // Remove any false values from realpath failures.
383 - $allowed_dirs = \array_filter( $allowed_dirs );
384 -
385 - // Check if the file is within any allowed directory.
386 - foreach ( $allowed_dirs as $allowed_dir ) {
387 - if ( \str_starts_with( $real_path, $allowed_dir ) ) {
388 - return true;
389 - }
390 - }
391 -
392 - return false;
393 - }
394 -
395 - /**
396 655 * Optimize an image file by resizing and converting to WebP.
397 656 *
398 657 * Uses WordPress image editor to resize large images and convert them
399 658 * to WebP format for better compression while maintaining quality.
@@ -476,9 +735,9 @@
476 735 return;
477 736 }
478 737
479 738 $media = self::generate_media_markup( $attachment_ids );
480 - $separator = empty( \trim( $post->post_content ) ) ? '' : "\n\n";
739 + $separator = empty( trim( $post->post_content ) ) ? '' : "\n\n";
481 740
482 741 \wp_update_post(
483 742 array(
484 743 'ID' => $post_id,
@@ -487,8 +746,27 @@
487 746 );
488 747 }
489 748
490 749 /**
750 + * Append file-based media to content.
751 + *
752 + * @param int $object_id The post or comment ID.
753 + * @param array[] $files Array of file data arrays.
754 + * @param string $object_type The object type ('post' or 'comment').
755 + */
756 + private static function append_files_to_content( $object_id, $files, $object_type ) {
757 + $content = self::get_object_content( $object_id, $object_type );
758 + if ( empty( $content ) ) {
759 + return;
760 + }
761 +
762 + $media = self::generate_files_markup( $files );
763 + $separator = empty( trim( $content ) ) ? '' : "\n\n";
764 +
765 + self::update_object_content( $object_id, $object_type, $content . $separator . $media );
766 + }
767 +
768 + /**
491 769 * Generate media markup for attachments.
492 770 *
493 771 * @param int[] $attachment_ids Array of attachment IDs.
494 772 *
@@ -515,13 +793,13 @@
515 793 return $custom_markup;
516 794 }
517 795
518 796 // Default to block markup.
519 - $type = \strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
797 + $type = strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
520 798
521 799 // Single video or audio file.
522 800 if ( 1 === \count( $attachment_ids ) && ( 'video' === $type || 'audio' === $type ) ) {
523 - return \sprintf(
801 + return sprintf(
524 802 '<!-- wp:%1$s {"id":"%2$s"} --><figure class="wp-block-%1$s"><%1$s controls src="%3$s"></%1$s></figure><!-- /wp:%1$s -->',
525 803 \esc_attr( $type ),
526 804 \esc_attr( $attachment_ids[0] ),
527 805 \esc_url( \wp_get_attachment_url( $attachment_ids[0] ) )
@@ -537,139 +815,10 @@
537 815 return self::get_gallery_block( $attachment_ids );
538 816 }
539 817
540 818 /**
541 - * Get standalone image block markup.
542 - *
543 - * @param int $attachment_id The attachment ID.
544 - *
545 - * @return string The image block markup.
546 - */
547 - private static function get_image_block( $attachment_id ) {
548 - $image_src = \wp_get_attachment_image_src( $attachment_id, 'large' );
549 - if ( ! $image_src ) {
550 - return '';
551 - }
552 -
553 - $alt = \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
554 - if ( ! $alt ) {
555 - $alt = \get_post_field( 'post_excerpt', $attachment_id );
556 - }
557 -
558 - $block = '<!-- wp:image {"id":' . \esc_attr( $attachment_id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
559 - $block .= '<figure class="wp-block-image size-large">';
560 - $block .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $attachment_id ) . '"/>';
561 - $block .= '</figure>' . "\n";
562 - $block .= '<!-- /wp:image -->';
563 -
564 - return $block;
565 - }
566 -
567 - /**
568 - * Get gallery block markup.
569 - *
570 - * @param int[] $attachment_ids The attachment IDs to use.
571 - *
572 - * @return string The gallery block markup.
573 - */
574 - private static function get_gallery_block( $attachment_ids ) {
575 - $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","sizeSlug":"large","imageCrop":true} -->' . "\n";
576 - $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
577 -
578 - foreach ( $attachment_ids as $id ) {
579 - $image_src = \wp_get_attachment_image_src( $id, 'large' );
580 - if ( ! $image_src ) {
581 - continue;
582 - }
583 -
584 - $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
585 - if ( ! $alt ) {
586 - $alt = \get_post_field( 'post_excerpt', $id );
587 - }
588 -
589 - $gallery .= "\n" . '<!-- wp:image {"id":' . \esc_attr( $id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
590 - $gallery .= '<figure class="wp-block-image size-large">';
591 - $gallery .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $id ) . '"/>';
592 - $gallery .= '</figure>';
593 - $gallery .= "\n<!-- /wp:image -->\n";
594 - }
595 -
596 - $gallery .= "</figure>\n";
597 - $gallery .= '<!-- /wp:gallery -->';
598 -
599 - return $gallery;
600 - }
601 -
602 - /**
603 - * Get content from an object based on its type.
604 - *
605 - * @param int $object_id The object ID (post or comment).
606 - * @param string $object_type The object type ('post' or 'comment').
607 - *
608 - * @return string The object content.
609 - */
610 - private static function get_object_content( $object_id, $object_type ) {
611 - if ( 'comment' === $object_type ) {
612 - $comment = \get_comment( $object_id );
613 - return $comment ? $comment->comment_content : '';
614 - }
615 -
616 - return \get_post_field( 'post_content', $object_id );
617 - }
618 -
619 - /**
620 - * Update content for an object based on its type.
621 - *
622 - * @param int $object_id The object ID (post or comment).
623 - * @param string $object_type The object type ('post' or 'comment').
624 - * @param string $content The new content.
625 - */
626 - private static function update_object_content( $object_id, $object_type, $content ) {
627 - if ( 'comment' === $object_type ) {
628 - \wp_update_comment(
629 - array(
630 - 'comment_ID' => $object_id,
631 - 'comment_content' => $content,
632 - )
633 - );
634 - } else {
635 - \wp_update_post(
636 - array(
637 - 'ID' => $object_id,
638 - 'post_content' => $content,
639 - )
640 - );
641 - }
642 - }
643 -
644 - /**
645 - * Append file-based media markup to an object's content.
646 - *
647 - * Used for cached remote media (via Cache classes) that doesn't go through
648 - * the Media Library. Works with posts and comments.
649 - *
650 - * @param int $object_id The object ID (post or comment).
651 - * @param array $files Array of file data arrays with 'url', 'mime_type', and 'alt' keys.
652 - * @param string $object_type The object type ('post' or 'comment').
653 - */
654 - public static function append_files_to_content( $object_id, $files, $object_type = 'post' ) {
655 - $content = self::get_object_content( $object_id, $object_type );
656 - if ( empty( $content ) ) {
657 - return;
658 - }
659 -
660 - $media = self::generate_files_markup( $files );
661 - $separator = empty( \trim( $content ) ) ? '' : "\n\n";
662 -
663 - self::update_object_content( $object_id, $object_type, $content . $separator . $media );
664 - }
665 -
666 - /**
667 819 * Generate media markup for file-based attachments.
668 820 *
669 - * Creates WordPress block markup from file data arrays. Used for cached
670 - * remote media that doesn't have WordPress attachment posts.
671 - *
672 821 * @param array[] $files {
673 822 * Array of file data arrays.
674 823 *
675 824 * @type string $url Full URL to the file.
@@ -678,9 +827,9 @@
678 827 * }
679 828 *
680 829 * @return string The generated markup.
681 830 */
682 - public static function generate_files_markup( $files ) {
831 + private static function generate_files_markup( $files ) {
683 832 if ( empty( $files ) ) {
684 833 return '';
685 834 }
686 835
@@ -700,13 +849,13 @@
700 849 return $custom_markup;
701 850 }
702 851
703 852 // Default to block markup.
704 - $type = \strtok( $files[0]['mime_type'], '/' );
853 + $type = strtok( $files[0]['mime_type'], '/' );
705 854
706 855 // Single video or audio file.
707 856 if ( 1 === \count( $files ) && ( 'video' === $type || 'audio' === $type ) ) {
708 - return \sprintf(
857 + return sprintf(
709 858 '<!-- wp:%1$s --><figure class="wp-block-%1$s"><%1$s controls src="%2$s"></%1$s></figure><!-- /wp:%1$s -->',
710 859 \esc_attr( $type ),
711 860 \esc_url( $files[0]['url'] )
712 861 );
@@ -733,12 +882,12 @@
733 882 * }
734 883 *
735 884 * @return string The image block markup.
736 885 */
737 - public static function get_files_image_block( $file ) {
886 + private static function get_files_image_block( $file ) {
738 887 $block = '<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
739 888 $block .= '<figure class="wp-block-image size-large">';
740 - $block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
889 + $block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ) . '"/>';
741 890 $block .= '</figure>' . "\n";
742 891 $block .= '<!-- /wp:image -->';
743 892
744 893 return $block;
@@ -744,8 +893,70 @@
744 893 return $block;
745 894 }
746 895
747 896 /**
897 + * Get standalone image block markup.
898 + *
899 + * @param int $attachment_id The attachment ID.
900 + *
901 + * @return string The image block markup.
902 + */
903 + private static function get_image_block( $attachment_id ) {
904 + $image_src = \wp_get_attachment_image_src( $attachment_id, 'large' );
905 + if ( ! $image_src ) {
906 + return '';
907 + }
908 +
909 + $alt = \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
910 + if ( ! $alt ) {
911 + $alt = \get_post_field( 'post_excerpt', $attachment_id );
912 + }
913 +
914 + $block = '<!-- wp:image {"id":' . \esc_attr( $attachment_id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
915 + $block .= '<figure class="wp-block-image size-large">';
916 + $block .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $attachment_id ) . '"/>';
917 + $block .= '</figure>' . "\n";
918 + $block .= '<!-- /wp:image -->';
919 +
920 + return $block;
921 + }
922 +
923 + /**
924 + * Get gallery block markup.
925 + *
926 + * @param int[] $attachment_ids The attachment IDs to use.
927 + *
928 + * @return string The gallery block markup.
929 + */
930 + private static function get_gallery_block( $attachment_ids ) {
931 + $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","sizeSlug":"large","imageCrop":true} -->' . "\n";
932 + $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
933 +
934 + foreach ( $attachment_ids as $id ) {
935 + $image_src = \wp_get_attachment_image_src( $id, 'large' );
936 + if ( ! $image_src ) {
937 + continue;
938 + }
939 +
940 + $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
941 + if ( ! $alt ) {
942 + $alt = \get_post_field( 'post_excerpt', $id );
943 + }
944 +
945 + $gallery .= "\n" . '<!-- wp:image {"id":' . \esc_attr( $id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
946 + $gallery .= '<figure class="wp-block-image size-large">';
947 + $gallery .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $id ) . '"/>';
948 + $gallery .= '</figure>';
949 + $gallery .= "\n<!-- /wp:image -->\n";
950 + }
951 +
952 + $gallery .= "</figure>\n";
953 + $gallery .= '<!-- /wp:gallery -->';
954 +
955 + return $gallery;
956 + }
957 +
958 + /**
748 959 * Get gallery block markup for file-based attachments.
749 960 *
750 961 * @param array[] $files {
751 962 * Array of file data arrays.
@@ -756,9 +967,9 @@
756 967 * }
757 968 *
758 969 * @return string The gallery block markup.
759 970 */
760 - public static function get_files_gallery_block( $files ) {
971 + private static function get_files_gallery_block( $files ) {
761 972 $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","imageCrop":true} -->' . "\n";
762 973 $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
763 974
764 975 foreach ( $files as $file ) {
@@ -763,9 +974,9 @@
763 974
764 975 foreach ( $files as $file ) {
765 976 $gallery .= "\n<!-- wp:image {\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n";
766 977 $gallery .= '<figure class="wp-block-image size-large">';
767 - $gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
978 + $gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ) . '"/>';
768 979 $gallery .= '</figure>';
769 980 $gallery .= "\n<!-- /wp:image -->\n";
770 981 }
771 982
@@ -772,6 +983,64 @@
772 983 $gallery .= "</figure>\n";
773 984 $gallery .= '<!-- /wp:gallery -->';
774 985
775 986 return $gallery;
987 + }
988 +
989 + /**
990 + * Save a remote actor's avatar locally.
991 + *
992 + * Downloads the avatar image, optimizes it, and stores it in the actors directory.
993 + * Returns the local URL for the saved avatar.
994 + *
995 + * @param int $actor_id The local actor post ID.
996 + * @param string $avatar_url The remote avatar URL.
997 + *
998 + * @return string|false The local avatar URL on success, false on failure.
999 + */
1000 + public static function save_actor_avatar( $actor_id, $avatar_url ) {
1001 + // Validate actor_id is a positive integer to prevent path traversal.
1002 + $actor_id = (int) $actor_id;
1003 + if ( $actor_id <= 0 ) {
1004 + return false;
1005 + }
1006 +
1007 + if ( empty( $avatar_url ) || ! \filter_var( $avatar_url, FILTER_VALIDATE_URL ) ) {
1008 + return false;
1009 + }
1010 +
1011 + // Delete existing avatar files before saving new one.
1012 + // This prevents accumulating old avatar files since save_file creates unique filenames.
1013 + self::delete_actors_directory( $actor_id );
1014 +
1015 + $attachment_data = array( 'url' => $avatar_url );
1016 + $result = self::save_file( $attachment_data, $actor_id, 'actor', self::MAX_AVATAR_DIMENSION );
1017 +
1018 + if ( \is_wp_error( $result ) || ! isset( $result['url'] ) ) {
1019 + return false;
1020 + }
1021 +
1022 + return $result['url'];
1023 + }
1024 +
1025 + /**
1026 + * Delete the activitypub files directory for an actor.
1027 + *
1028 + * @param int $actor_id The actor post ID.
1029 + */
1030 + public static function delete_actors_directory( $actor_id ) {
1031 + if ( Remote_Actors::POST_TYPE !== \get_post_type( $actor_id ) ) {
1032 + return;
1033 + }
1034 +
1035 + require_once ABSPATH . 'wp-admin/includes/file.php';
1036 +
1037 + \WP_Filesystem();
1038 + global $wp_filesystem;
1039 +
1040 + $activitypub_dir = self::get_storage_paths( $actor_id, 'actor' )['basedir'];
1041 +
1042 + if ( $wp_filesystem->is_dir( $activitypub_dir ) ) {
1043 + $wp_filesystem->rmdir( $activitypub_dir, true );
1044 + }
776 1045 }
777 1046 }