PluginProbe
ActivityPub / 7.7.0
ActivityPub v7.7.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
← All changes | includes/class-attachments.php +391 -352 9.3.07.7.0 View file →
@@ -6,28 +6,58 @@
6 6 */
7 7
8 8 namespace Activitypub;
9 9
10 +use Activitypub\Collection\Posts;
11 +
10 12 /**
11 13 * 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 14 */
21 15 class Attachments {
22 16 /**
23 - * Maximum width for imported images into Media Library.
17 + * Directory for storing ap_post media files.
24 18 *
25 - * @var int
19 + * @var string
26 20 */
27 - const MAX_IMAGE_DIMENSION = 1200;
21 + public static $ap_posts_dir = '/activitypub/ap_posts/';
28 22
29 23 /**
24 + * Directory for storing comment media files.
25 + *
26 + * @var string
27 + */
28 + public static $comments_dir = '/activitypub/comments/';
29 +
30 + /**
31 + * Initialize the class and set up filters.
32 + */
33 + public static function init() {
34 + \add_action( 'before_delete_post', array( self::class, 'delete_ap_posts_directory' ) );
35 + }
36 +
37 + /**
38 + * Delete the activitypub files directory for a post.
39 + *
40 + * @param int $post_id The post ID.
41 + */
42 + public static function delete_ap_posts_directory( $post_id ) {
43 + if ( Posts::POST_TYPE !== \get_post_type( $post_id ) ) {
44 + return;
45 + }
46 +
47 + require_once ABSPATH . 'wp-admin/includes/file.php';
48 +
49 + \WP_Filesystem();
50 + global $wp_filesystem;
51 +
52 + $activitypub_dir = self::get_storage_paths( $post_id, 'post' )['basedir'];
53 +
54 + if ( $wp_filesystem->is_dir( $activitypub_dir ) ) {
55 + $wp_filesystem->delete( $activitypub_dir, true );
56 + }
57 + }
58 +
59 + /**
30 60 * Import attachments from an ActivityPub object and attach them to a post.
31 61 *
32 62 * Creates full WordPress attachment posts in the media library. Each attachment
33 63 * becomes a searchable, manageable attachment post that appears in the WordPress
@@ -48,9 +78,9 @@
48 78 public static function import( $attachments, $post_id, $author_id = 0 ) {
49 79 // First, import inline images from the post content.
50 80 $inline_mappings = self::import_inline_images( $post_id, $author_id );
51 81
52 - if ( empty( $attachments ) || ! \is_array( $attachments ) ) {
82 + if ( empty( $attachments ) || ! is_array( $attachments ) ) {
53 83 return array();
54 84 }
55 85
56 86 $attachment_ids = array();
@@ -81,8 +111,147 @@
81 111 return $attachment_ids;
82 112 }
83 113
84 114 /**
115 + * Import attachments as direct files for posts.
116 + *
117 + * Saves files directly to uploads/activitypub/ap_posts/{post_id}/ without creating
118 + * WordPress attachment posts. This lightweight approach is ideal for federated content
119 + * that doesn't require full WordPress media management.
120 + *
121 + * Files are stored in a dedicated directory structure and automatically cleaned up
122 + * when the parent post is deleted. Media URLs point directly to the stored files
123 + * rather than going through WordPress attachment APIs.
124 + *
125 + * Use this when:
126 + * - Processing ActivityPub Create/Update activities from the inbox.
127 + * - Handling federated content that won't be owned or edited by the user.
128 + * - You want lightweight storage without Media Library overhead.
129 + *
130 + * @param array $attachments Array of ActivityPub attachment objects.
131 + * @param int $post_id The post ID to attach files to.
132 + *
133 + * @return array[] Array of file data arrays.
134 + */
135 + public static function import_post_files( $attachments, $post_id ) {
136 + return self::import_files_for_object( $attachments, $post_id, 'post' );
137 + }
138 +
139 + /**
140 + * Import attachments as direct files for any object type.
141 + *
142 + * Saves files directly to uploads/activitypub/{type}/{id}/ without creating
143 + * WordPress attachment posts. This is the internal method that handles
144 + * the actual import logic for both posts and comments.
145 + *
146 + * @param array $attachments Array of ActivityPub attachment objects.
147 + * @param int $object_id The object ID (post or comment).
148 + * @param string $object_type The object type ('post' or 'comment').
149 + *
150 + * @return array[] Array of file data arrays.
151 + */
152 + private static function import_files_for_object( $attachments, $object_id, $object_type ) {
153 + // First, import inline images from the content.
154 + $inline_mappings = self::import_inline_files( $object_id, $object_type );
155 +
156 + if ( empty( $attachments ) || ! is_array( $attachments ) ) {
157 + return array();
158 + }
159 +
160 + $files = array();
161 + foreach ( $attachments as $attachment ) {
162 + $attachment_data = self::normalize_attachment( $attachment );
163 +
164 + if ( empty( $attachment_data['url'] ) ) {
165 + continue;
166 + }
167 +
168 + // Skip if this URL was already processed as an inline image.
169 + if ( isset( $inline_mappings[ $attachment_data['url'] ] ) ) {
170 + continue;
171 + }
172 +
173 + $file_data = self::save_file( $attachment_data, $object_id, $object_type );
174 +
175 + if ( ! \is_wp_error( $file_data ) ) {
176 + $files[] = $file_data;
177 + }
178 + }
179 +
180 + // Append media markup to content.
181 + if ( ! empty( $files ) ) {
182 + self::append_files_to_content( $object_id, $files, $object_type );
183 + }
184 +
185 + return $files;
186 + }
187 +
188 + /**
189 + * Get storage paths for an object based on its type.
190 + *
191 + * @param int $object_id The object ID (post or comment).
192 + * @param string $object_type The object type ('post' or 'comment').
193 + *
194 + * @return array {
195 + * Storage paths for the object.
196 + *
197 + * @type string $basedir Base directory path.
198 + * @type string $baseurl Base URL.
199 + * }
200 + */
201 + private static function get_storage_paths( $object_id, $object_type ) {
202 + $upload_dir = \wp_upload_dir();
203 + $sub_dir = 'comment' === $object_type ? self::$comments_dir : self::$ap_posts_dir;
204 +
205 + return array(
206 + 'basedir' => $upload_dir['basedir'] . $sub_dir . $object_id,
207 + 'baseurl' => $upload_dir['baseurl'] . $sub_dir . $object_id,
208 + );
209 + }
210 +
211 + /**
212 + * Get content 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 string The content string or empty if not found.
218 + */
219 + private static function get_object_content( $object_id, $object_type ) {
220 + if ( 'comment' === $object_type ) {
221 + $comment = \get_comment( $object_id );
222 + return $comment ? $comment->comment_content : '';
223 + }
224 +
225 + return \get_post_field( 'post_content', $object_id );
226 + }
227 +
228 + /**
229 + * Update content for an object based on its type.
230 + *
231 + * @param int $object_id The object ID (post or comment).
232 + * @param string $object_type The object type ('post' or 'comment').
233 + * @param string $content The new content.
234 + */
235 + private static function update_object_content( $object_id, $object_type, $content ) {
236 + if ( 'comment' === $object_type ) {
237 + \wp_update_comment(
238 + array(
239 + 'comment_ID' => $object_id,
240 + 'comment_content' => $content,
241 + )
242 + );
243 + } else {
244 + \wp_update_post(
245 + array(
246 + 'ID' => $object_id,
247 + 'post_content' => $content,
248 + )
249 + );
250 + }
251 + }
252 +
253 + /**
85 254 * Check if an attachment with the same source URL already exists for a post.
86 255 *
87 256 * @param string $source_url The source URL to check.
88 257 * @param int $post_id The post ID to check attachments for.
@@ -113,9 +282,9 @@
113 282 return array();
114 283 }
115 284
116 285 // Find all img tags in the content.
117 - \preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );
286 + preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );
118 287
119 288 if ( empty( $matches[1] ) ) {
120 289 return array();
121 290 }
@@ -159,8 +328,58 @@
159 328 return $url_mappings;
160 329 }
161 330
162 331 /**
332 + * Process inline images from content (for direct file storage).
333 + *
334 + * @param int $object_id The post or comment ID.
335 + * @param string $object_type The object type ('post' or 'comment').
336 + *
337 + * @return array Array of URL mappings (old URL => new URL).
338 + */
339 + private static function import_inline_files( $object_id, $object_type ) {
340 + $content = self::get_object_content( $object_id, $object_type );
341 + if ( ! $content ) {
342 + return array();
343 + }
344 +
345 + // Find all img tags in the content.
346 + preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $matches );
347 +
348 + if ( empty( $matches[1] ) ) {
349 + return array();
350 + }
351 +
352 + $url_mappings = array();
353 +
354 + foreach ( $matches[1] as $image_url ) {
355 + // Skip if already processed.
356 + if ( isset( $url_mappings[ $image_url ] ) ) {
357 + continue;
358 + }
359 +
360 + $file_data = self::save_file( array( 'url' => $image_url ), $object_id, $object_type );
361 +
362 + if ( \is_wp_error( $file_data ) ) {
363 + continue;
364 + }
365 +
366 + $new_url = $file_data['url'];
367 + if ( $new_url ) {
368 + $url_mappings[ $image_url ] = $new_url;
369 + $content = \str_replace( $image_url, $new_url, $content );
370 + }
371 + }
372 +
373 + // Update content if URLs were replaced.
374 + if ( ! empty( $url_mappings ) ) {
375 + self::update_object_content( $object_id, $object_type, $content );
376 + }
377 +
378 + return $url_mappings;
379 + }
380 +
381 + /**
163 382 * Normalize an ActivityPub attachment object to a standard format.
164 383 *
165 384 * @param mixed $attachment The attachment data (array or object).
166 385 *
@@ -171,9 +390,9 @@
171 390 if ( \is_object( $attachment ) ) {
172 391 $attachment = \get_object_vars( $attachment );
173 392 }
174 393
175 - if ( ! \is_array( $attachment ) || empty( $attachment['url'] ) ) {
394 + if ( ! is_array( $attachment ) || empty( $attachment['url'] ) ) {
176 395 return false;
177 396 }
178 397
179 398 return array(
@@ -200,38 +419,24 @@
200 419 require_once ABSPATH . 'wp-admin/includes/file.php';
201 420 require_once ABSPATH . 'wp-admin/includes/image.php';
202 421 }
203 422
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';
423 + $is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] );
207 424
208 - $filesystem = new \WP_Filesystem_Direct( null );
209 -
210 - $is_local = ! \preg_match( '#^https?://#i', $attachment_data['url'] );
211 -
212 425 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 - }
426 + // Read local file from disk.
427 + \WP_Filesystem();
428 + global $wp_filesystem;
218 429
219 - // Read local file from disk.
220 - if ( ! $filesystem->exists( $attachment_data['url'] ) ) {
430 + if ( ! $wp_filesystem->exists( $attachment_data['url'] ) ) {
221 431 /* translators: %s: file path */
222 - return new \WP_Error( 'file_not_found', \sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
432 + return new \WP_Error( 'file_not_found', sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
223 433 }
224 434
225 435 // Copy to temp file so media_handle_sideload doesn't move the original.
226 436 $tmp_file = \wp_tempnam( \basename( $attachment_data['url'] ) );
227 - $filesystem->copy( $attachment_data['url'], $tmp_file, true );
437 + $wp_filesystem->copy( $attachment_data['url'], $tmp_file, true );
228 438 } 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 439 // Download remote URL.
235 440 $tmp_file = \download_url( $attachment_data['url'] );
236 441
237 442 if ( \is_wp_error( $tmp_file ) ) {
@@ -238,68 +443,30 @@
238 443 return $tmp_file;
239 444 }
240 445 }
241 446
242 - // Get original filename from URL.
243 - $original_name = \basename( \wp_parse_url( $attachment_data['url'], PHP_URL_PATH ) );
244 -
245 - // Rename temp file to have proper extension for optimize_image to detect mime type.
246 - $original_ext = \pathinfo( $original_name, PATHINFO_EXTENSION );
247 - if ( $original_ext ) {
248 - $renamed_tmp = $tmp_file . '.' . $original_ext;
249 - if ( $filesystem->move( $tmp_file, $renamed_tmp, true ) ) {
250 - $tmp_file = $renamed_tmp;
251 - }
252 - }
253 -
254 - // Optimize images before sideloading (resize and convert to WebP).
255 - $tmp_file = self::optimize_image( $tmp_file, self::MAX_IMAGE_DIMENSION );
256 -
257 - // Update filename extension to match optimized file.
258 - $new_ext = \pathinfo( $tmp_file, PATHINFO_EXTENSION );
259 - if ( $new_ext ) {
260 - $original_name = \preg_replace( '/\.[^.]+$/', '.' . $new_ext, $original_name );
261 - }
262 -
447 + // Prepare file array for WordPress.
263 448 $file_array = array(
264 - 'name' => $original_name,
449 + 'name' => \basename( \wp_parse_url( $attachment_data['url'], PHP_URL_PATH ) ),
265 450 'tmp_name' => $tmp_file,
266 451 );
267 452
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 453 // Prepare attachment post data.
273 - // Let WordPress auto-detect the mime type from the file.
274 454 $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 ),
285 - 'post_author' => $author_id,
286 - 'meta_input' => array(
455 + 'post_mime_type' => $attachment_data['mediaType'] ?? '',
456 + 'post_title' => $attachment_data['name'] ?? '',
457 + 'post_content' => $attachment_data['name'] ?? '',
458 + 'post_author' => $author_id,
459 + 'meta_input' => array(
287 460 '_source_url' => $attachment_data['url'],
288 461 ),
289 462 );
290 463
291 464 // Add alt text for images.
292 - if ( '' !== $plain_name ) {
293 - $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 );
465 + if ( ! empty( $attachment_data['name'] ) ) {
466 + $mime_type = $attachment_data['mediaType'] ?? '';
467 + if ( 'image' === strtok( $mime_type, '/' ) ) {
468 + $post_data['meta_input']['_wp_attachment_image_alt'] = $attachment_data['name'];
302 469 }
303 470 }
304 471
305 472 // Sideload the attachment into WordPress.
@@ -313,156 +480,76 @@
313 480 return $attachment_id;
314 481 }
315 482
316 483 /**
317 - * Get a unique file path by appending a counter if the file already exists.
484 + * Save a file directly to uploads/activitypub/{type}/{id}/.
318 485 *
319 - * @param string $file_path The desired file path.
486 + * @param array $attachment_data The normalized attachment data.
487 + * @param int $object_id The post or comment ID to attach to.
488 + * @param string $object_type The object type ('post' or 'comment').
320 489 *
321 - * @return string A unique file path that doesn't exist.
322 - */
323 - private static function get_unique_path( $file_path ) {
324 - if ( ! \file_exists( $file_path ) ) {
325 - return $file_path;
326 - }
327 -
328 - $path_info = \pathinfo( $file_path );
329 - $dir = $path_info['dirname'];
330 - $base_name = $path_info['filename'];
331 - $extension = isset( $path_info['extension'] ) ? '.' . $path_info['extension'] : '';
332 - $counter = 1;
333 -
334 - do {
335 - $new_path = $dir . '/' . $base_name . '-' . $counter . $extension;
336 - ++$counter;
337 - } while ( \file_exists( $new_path ) );
338 -
339 - return $new_path;
340 - }
341 -
342 - /**
343 - * Check if a local file path is within allowed directories.
490 + * @return array|\WP_Error {
491 + * Array of file data on success, WP_Error on failure.
344 492 *
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.
493 + * @type string $url Full URL to the saved file.
494 + * @type string $mime_type MIME type of the file.
495 + * @type string $alt Alt text from attachment name field.
496 + * }
351 497 */
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 );
498 + private static function save_file( $attachment_data, $object_id, $object_type ) {
499 + if ( ! \function_exists( 'download_url' ) ) {
500 + require_once ABSPATH . 'wp-admin/includes/file.php';
362 501 }
363 502
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 - );
503 + // Download remote URL.
504 + $tmp_file = \download_url( $attachment_data['url'] );
371 505
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 - }
506 + if ( \is_wp_error( $tmp_file ) ) {
507 + return $tmp_file;
390 508 }
391 509
392 - return false;
393 - }
510 + // Get storage paths for this object.
511 + $paths = self::get_storage_paths( $object_id, $object_type );
394 512
395 - /**
396 - * Optimize an image file by resizing and converting to WebP.
397 - *
398 - * Uses WordPress image editor to resize large images and convert them
399 - * to WebP format for better compression while maintaining quality.
400 - *
401 - * @param string $file_path Path to the image file.
402 - * @param int $max_dimension Maximum width/height in pixels.
403 - *
404 - * @return string The optimized file path.
405 - */
406 - private static function optimize_image( $file_path, $max_dimension ) {
407 - // Check if it's an image.
408 - $mime_type = \wp_check_filetype( $file_path )['type'] ?? '';
409 - if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) {
410 - return $file_path;
411 - }
513 + // Create directory if it doesn't exist.
514 + \wp_mkdir_p( $paths['basedir'] );
412 515
413 - // Skip SVG and GIF files (GIFs may be animated).
414 - if ( \in_array( $mime_type, array( 'image/svg+xml', 'image/gif' ), true ) ) {
415 - return $file_path;
416 - }
516 + // Generate unique file name.
517 + $url_path = \wp_parse_url( $attachment_data['url'], PHP_URL_PATH );
518 + $file_name = \sanitize_file_name( \basename( $url_path ) );
519 + $file_path = $paths['basedir'] . '/' . $file_name;
417 520
418 - $editor = \wp_get_image_editor( $file_path );
419 - if ( \is_wp_error( $editor ) ) {
420 - return $file_path;
421 - }
521 + // Initialize filesystem if needed.
522 + \WP_Filesystem();
523 + global $wp_filesystem;
422 524
423 - $size = $editor->get_size();
424 - $needs_resize = $size['width'] > $max_dimension || $size['height'] > $max_dimension;
425 -
426 - // Resize if needed.
427 - if ( $needs_resize ) {
428 - $editor->resize( $max_dimension, $max_dimension, false );
429 - }
430 -
431 - // Check if WebP is supported.
432 - $can_webp = $editor->supports_mime_type( 'image/webp' );
433 -
434 - // Determine output format and save.
435 - if ( $can_webp ) {
436 - // Convert to WebP.
437 - $new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.webp', $file_path ) );
438 - $result = $editor->save( $new_path, 'image/webp' );
439 - } elseif ( \in_array( $mime_type, array( 'image/png', 'image/webp' ), true ) ) {
440 - // Keep original format for potentially transparent images when WebP not available.
441 - if ( ! $needs_resize ) {
442 - // No changes needed.
443 - return $file_path;
525 + // Make sure file name is unique.
526 + $counter = 1;
527 + while ( $wp_filesystem->exists( $file_path ) ) {
528 + $path_info = pathinfo( $file_name );
529 + $file_name = $path_info['filename'] . '-' . $counter;
530 + if ( ! empty( $path_info['extension'] ) ) {
531 + $file_name .= '.' . $path_info['extension'];
444 532 }
445 - $result = $editor->save( $file_path );
446 - } else {
447 - // Convert to JPEG when WebP not available.
448 - $new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.jpg', $file_path ) );
449 - $result = $editor->save( $new_path, 'image/jpeg' );
533 + $file_path = $paths['basedir'] . '/' . $file_name;
534 + ++$counter;
450 535 }
451 536
452 - if ( \is_wp_error( $result ) ) {
453 - return $file_path;
537 + // Move file to destination.
538 + if ( ! $wp_filesystem->move( $tmp_file, $file_path, true ) ) {
539 + \wp_delete_file( $tmp_file );
540 + return new \WP_Error( 'file_move_failed', \__( 'Failed to move file to destination.', 'activitypub' ) );
454 541 }
455 542
456 - // Handle result - $result is always an array from $editor->save().
457 - $result_path = $result['path'] ?? $file_path;
543 + // Get mime type and validate file.
544 + $file_info = \wp_check_filetype_and_ext( $file_path, $file_name );
545 + $mime_type = $file_info['type'] ?? $attachment_data['mediaType'] ?? '';
458 546
459 - // If path changed (format conversion), delete the original file.
460 - if ( $result_path !== $file_path ) {
461 - \wp_delete_file( $file_path );
462 - }
463 -
464 - return $result_path;
547 + return array(
548 + 'url' => $paths['baseurl'] . '/' . $file_name,
549 + 'mime_type' => $mime_type,
550 + 'alt' => $attachment_data['name'] ?? '',
551 + );
465 552 }
466 553
467 554 /**
468 555 * Append media to post content.
@@ -476,9 +563,9 @@
476 563 return;
477 564 }
478 565
479 566 $media = self::generate_media_markup( $attachment_ids );
480 - $separator = empty( \trim( $post->post_content ) ) ? '' : "\n\n";
567 + $separator = empty( trim( $post->post_content ) ) ? '' : "\n\n";
481 568
482 569 \wp_update_post(
483 570 array(
484 571 'ID' => $post_id,
@@ -487,8 +574,27 @@
487 574 );
488 575 }
489 576
490 577 /**
578 + * Append file-based media to content.
579 + *
580 + * @param int $object_id The post or comment ID.
581 + * @param array[] $files Array of file data arrays.
582 + * @param string $object_type The object type ('post' or 'comment').
583 + */
584 + private static function append_files_to_content( $object_id, $files, $object_type ) {
585 + $content = self::get_object_content( $object_id, $object_type );
586 + if ( empty( $content ) ) {
587 + return;
588 + }
589 +
590 + $media = self::generate_files_markup( $files );
591 + $separator = empty( trim( $content ) ) ? '' : "\n\n";
592 +
593 + self::update_object_content( $object_id, $object_type, $content . $separator . $media );
594 + }
595 +
596 + /**
491 597 * Generate media markup for attachments.
492 598 *
493 599 * @param int[] $attachment_ids Array of attachment IDs.
494 600 *
@@ -515,13 +621,13 @@
515 621 return $custom_markup;
516 622 }
517 623
518 624 // Default to block markup.
519 - $type = \strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
625 + $type = strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );
520 626
521 627 // Single video or audio file.
522 628 if ( 1 === \count( $attachment_ids ) && ( 'video' === $type || 'audio' === $type ) ) {
523 - return \sprintf(
629 + return sprintf(
524 630 '<!-- 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 631 \esc_attr( $type ),
526 632 \esc_attr( $attachment_ids[0] ),
527 633 \esc_url( \wp_get_attachment_url( $attachment_ids[0] ) )
@@ -537,139 +643,10 @@
537 643 return self::get_gallery_block( $attachment_ids );
538 644 }
539 645
540 646 /**
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 647 * Generate media markup for file-based attachments.
668 648 *
669 - * Creates WordPress block markup from file data arrays. Used for cached
670 - * remote media that doesn't have WordPress attachment posts.
671 - *
672 649 * @param array[] $files {
673 650 * Array of file data arrays.
674 651 *
675 652 * @type string $url Full URL to the file.
@@ -678,9 +655,9 @@
678 655 * }
679 656 *
680 657 * @return string The generated markup.
681 658 */
682 - public static function generate_files_markup( $files ) {
659 + private static function generate_files_markup( $files ) {
683 660 if ( empty( $files ) ) {
684 661 return '';
685 662 }
686 663
@@ -700,13 +677,13 @@
700 677 return $custom_markup;
701 678 }
702 679
703 680 // Default to block markup.
704 - $type = \strtok( $files[0]['mime_type'], '/' );
681 + $type = strtok( $files[0]['mime_type'], '/' );
705 682
706 683 // Single video or audio file.
707 684 if ( 1 === \count( $files ) && ( 'video' === $type || 'audio' === $type ) ) {
708 - return \sprintf(
685 + return sprintf(
709 686 '<!-- wp:%1$s --><figure class="wp-block-%1$s"><%1$s controls src="%2$s"></%1$s></figure><!-- /wp:%1$s -->',
710 687 \esc_attr( $type ),
711 688 \esc_url( $files[0]['url'] )
712 689 );
@@ -733,12 +710,12 @@
733 710 * }
734 711 *
735 712 * @return string The image block markup.
736 713 */
737 - public static function get_files_image_block( $file ) {
714 + private static function get_files_image_block( $file ) {
738 715 $block = '<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
739 716 $block .= '<figure class="wp-block-image size-large">';
740 - $block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
717 + $block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ) . '"/>';
741 718 $block .= '</figure>' . "\n";
742 719 $block .= '<!-- /wp:image -->';
743 720
744 721 return $block;
@@ -744,8 +721,70 @@
744 721 return $block;
745 722 }
746 723
747 724 /**
725 + * Get standalone image block markup.
726 + *
727 + * @param int $attachment_id The attachment ID.
728 + *
729 + * @return string The image block markup.
730 + */
731 + private static function get_image_block( $attachment_id ) {
732 + $image_src = \wp_get_attachment_image_src( $attachment_id, 'large' );
733 + if ( ! $image_src ) {
734 + return '';
735 + }
736 +
737 + $alt = \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
738 + if ( ! $alt ) {
739 + $alt = \get_post_field( 'post_excerpt', $attachment_id );
740 + }
741 +
742 + $block = '<!-- wp:image {"id":' . \esc_attr( $attachment_id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
743 + $block .= '<figure class="wp-block-image size-large">';
744 + $block .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $attachment_id ) . '"/>';
745 + $block .= '</figure>' . "\n";
746 + $block .= '<!-- /wp:image -->';
747 +
748 + return $block;
749 + }
750 +
751 + /**
752 + * Get gallery block markup.
753 + *
754 + * @param int[] $attachment_ids The attachment IDs to use.
755 + *
756 + * @return string The gallery block markup.
757 + */
758 + private static function get_gallery_block( $attachment_ids ) {
759 + $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","sizeSlug":"large","imageCrop":true} -->' . "\n";
760 + $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
761 +
762 + foreach ( $attachment_ids as $id ) {
763 + $image_src = \wp_get_attachment_image_src( $id, 'large' );
764 + if ( ! $image_src ) {
765 + continue;
766 + }
767 +
768 + $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
769 + if ( ! $alt ) {
770 + $alt = \get_post_field( 'post_excerpt', $id );
771 + }
772 +
773 + $gallery .= "\n" . '<!-- wp:image {"id":' . \esc_attr( $id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
774 + $gallery .= '<figure class="wp-block-image size-large">';
775 + $gallery .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $id ) . '"/>';
776 + $gallery .= '</figure>';
777 + $gallery .= "\n<!-- /wp:image -->\n";
778 + }
779 +
780 + $gallery .= "</figure>\n";
781 + $gallery .= '<!-- /wp:gallery -->';
782 +
783 + return $gallery;
784 + }
785 +
786 + /**
748 787 * Get gallery block markup for file-based attachments.
749 788 *
750 789 * @param array[] $files {
751 790 * Array of file data arrays.
@@ -756,9 +795,9 @@
756 795 * }
757 796 *
758 797 * @return string The gallery block markup.
759 798 */
760 - public static function get_files_gallery_block( $files ) {
799 + private static function get_files_gallery_block( $files ) {
761 800 $gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","imageCrop":true} -->' . "\n";
762 801 $gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
763 802
764 803 foreach ( $files as $file ) {
@@ -763,9 +802,9 @@
763 802
764 803 foreach ( $files as $file ) {
765 804 $gallery .= "\n<!-- wp:image {\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n";
766 805 $gallery .= '<figure class="wp-block-image size-large">';
767 - $gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
806 + $gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ) . '"/>';
768 807 $gallery .= '</figure>';
769 808 $gallery .= "\n<!-- /wp:image -->\n";
770 809 }
771 810