-
Notifications
You must be signed in to change notification settings - Fork 83
Add Attachments processor class for handling ActivityPub media #2314
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 40 commits
Commits
Show all changes
77 commits
Select commit
Hold shift + click to select a range
27ce87b
Add support for ActivityPub object post type
pfefferle fd3661e
Add ActivityPub content sanitizer and update usage
pfefferle 5707420
Add HTML to blocks conversion and refactor sanitization
pfefferle 630aec6
Remove get_node_attributes method and inline list attribute
pfefferle ca3305e
Rename html_to_blocks to convert_from_html in Blocks
pfefferle 5ab729b
Apply suggestion from @Copilot
pfefferle e302e93
Apply suggestion from @Copilot
pfefferle 955921c
Apply suggestion from @Copilot
pfefferle d0fa8e1
Add tests for content sanitization and block conversion
pfefferle 7d6004e
Add tests for Create handler object sanitization and edge cases
pfefferle 7466883
Add unit tests for Objects collection class
pfefferle b61302e
Add changelog
matticbot a400925
Refactor Update handler methods for clarity
pfefferle 859b33b
Update @covers annotation to handle_actor_update
pfefferle 9f43130
Update post type labels from 'Post' to 'Object'
pfefferle 67932d8
Add missing newline after use statements
pfefferle 7f07d36
Remove HTML to blocks conversion logic
pfefferle 02528e1
Rename Objects collection to Posts throughout codebase
pfefferle f59a8f4
Fix comments and return types
obenland 64d92ce
Rename create_object to create_post in Create handler
pfefferle 94a9cc7
Update @covers annotation in test for create_post
pfefferle 406d34e
Register post meta for remote actor ID
pfefferle 3b345a0
Add 'ap_object_type' taxonomy to post type
pfefferle 5dbbd42
Remove custom labels from taxonomy registration
pfefferle b5adfa3
Remove custom rewrite slugs from taxonomy registration
pfefferle f6dd350
Rename handle_actor_update to update_actor
pfefferle f784eb1
Rename test method and update covered function
pfefferle f9fc961
Refactor update handler result and docblock types
pfefferle a367a97
Improve error handling in Create and Update handlers
pfefferle c86fb3c
Refactor default error handling in handle_object_update
pfefferle ea44b16
Handle missing actor data in update handler
pfefferle bd60160
Add Attachments processor class with comprehensive tests
obenland 737aafd
Add filter for extensible attachment markup generation
obenland ce7d197
Refactor attachment processing calls
obenland b76da8c
Add unit tests for Posts collection attachment handling
obenland 8502af9
Update Classic Editor support comment
obenland becb0b9
Refactor test image copy logic in mock download functions
obenland 15866dc
Simplify array formatting in test attachments
obenland 298f313
Fix attachment author handling for different contexts
obenland 2d949b5
Simplify Attachments API by removing base_path parameter
obenland 4ff81f7
Fix array comparison in has_updated_attachments
obenland a06dbc6
Move WordPress function checks earlier in save_attachment
pfefferle 6f0790b
Merge branch 'trunk' into attachment-handling
pfefferle c9c821b
Merge branch 'trunk' into attachment-handling
pfefferle b431811
Fix incorrect post type registration in test setup
pfefferle 0ee25a2
Experiment with "manually" inherited post status
obenland fafe402
Merge trunk into attachment-handling branch
obenland e59c0f1
Add changelog
matticbot 596bcfc
Hide ActivityPub attachments from Media Library
obenland 8c4469c
Delete all attachments when ap_post is deleted
obenland 3ae2d88
Fix race condition in attachment processing
obenland 2e0bb8a
Fix get_attached_media() for ActivityPub posts
obenland a0b5d0c
Revert "Fix race condition in attachment processing"
obenland a94646c
Fix failing tests after merge with trunk
obenland f640496
Add inline image processing for ActivityPub imports
obenland d66dd90
Fix whitespace
obenland 6692c5a
Merge branch 'trunk' into attachment-handling
obenland e8cebe8
Rename Attachments::process() to Attachments::import()
obenland cdc0c43
Rename process_inline_images to import_inline_images
obenland 3da9b0a
Refactor meta_query retrieval in Attachments class
obenland 972f57c
Do not federate imports (#2372)
pfefferle 6ee806e
Use direct file storage for ActivityPub inbox attachments (#2375)
obenland 669050b
Merge branch 'trunk' into attachment-handling
obenland cee8935
Simplify attachment update logic and remove actor check
obenland 09ca189
Remove maybe_hide_from_media_library method
obenland adf0eb8
Call wp_mkdir_p unconditionally to ensure parent directories exist
obenland 4bfb8e7
Refactor file name variable naming in Attachments class
obenland 1bca355
Update includes/class-attachments.php
obenland 138f2dc
Remove stray comment
obenland e8444d9
Use wp_check_filetype_and_ext() for file validation
obenland 0876ad0
Refactor attachment deletion and media append methods
obenland 7abd6ef
Update cover annotation
obenland 6820a9a
Refactor Attachments class for better extensibility
obenland e0d5e56
Merge branch 'trunk' into attachment-handling
obenland 8b50cf8
Merge branch 'trunk' into attachment-handling
obenland ad8249e
Fix merge conflict resolution left over
obenland 81812db
Merge branch 'trunk' into attachment-handling
obenland File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| Significance: minor | ||
| Type: added | ||
|
|
||
| Added a new ap_object post type and taxonomies for storing and managing incoming ActivityPub objects, with updated handlers |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| <?php | ||
| /** | ||
| * Attachments processing file. | ||
| * | ||
| * @package Activitypub | ||
| */ | ||
|
|
||
| namespace Activitypub; | ||
|
|
||
| /** | ||
| * Attachments processor class. | ||
| */ | ||
| class Attachments { | ||
|
|
||
| /** | ||
| * Process attachments from an ActivityPub object and attach them to a post. | ||
| * | ||
| * @param array $attachments Array of ActivityPub attachment objects. | ||
| * @param int $post_id The post ID to attach files to. | ||
| * @param int $author_id Optional. User ID to set as attachment author. Default 0. | ||
| * | ||
| * @return array Array of attachment IDs. | ||
| */ | ||
| public static function process( $attachments, $post_id, $author_id = 0 ) { | ||
obenland marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if ( empty( $attachments ) || ! is_array( $attachments ) ) { | ||
| return array(); | ||
| } | ||
|
|
||
| $attachment_ids = array(); | ||
| foreach ( $attachments as $attachment ) { | ||
| $attachment_data = self::normalize_attachment( $attachment ); | ||
|
|
||
| if ( empty( $attachment_data['url'] ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $attachment_id = self::save_attachment( $attachment_data, $post_id, $author_id ); | ||
|
|
||
| if ( ! \is_wp_error( $attachment_id ) ) { | ||
| $attachment_ids[] = $attachment_id; | ||
| } | ||
| } | ||
|
|
||
| // Append media markup to post content. | ||
| if ( ! empty( $attachment_ids ) ) { | ||
| self::append_media_to_content( $post_id, $attachment_ids ); | ||
| } | ||
|
|
||
| return $attachment_ids; | ||
| } | ||
|
|
||
| /** | ||
| * Normalize an ActivityPub attachment object to a standard format. | ||
| * | ||
| * @param mixed $attachment The attachment data (array or object). | ||
| * | ||
| * @return array|false Normalized attachment data or false on failure. | ||
| */ | ||
| private static function normalize_attachment( $attachment ) { | ||
| // Convert object to array if needed. | ||
| if ( is_object( $attachment ) ) { | ||
obenland marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| $attachment = get_object_vars( $attachment ); | ||
obenland marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| if ( ! is_array( $attachment ) || empty( $attachment['url'] ) ) { | ||
| return false; | ||
| } | ||
|
|
||
| return array( | ||
| 'url' => $attachment['url'], | ||
| 'mediaType' => $attachment['mediaType'] ?? '', | ||
| 'name' => $attachment['name'] ?? '', | ||
| 'type' => $attachment['type'] ?? 'Document', | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Save an attachment (local file or remote URL) to the media library. | ||
| * | ||
| * @param array $attachment_data The normalized attachment data. | ||
| * @param int $post_id The post ID to attach to. | ||
| * @param int $author_id Optional. User ID to set as attachment author. Default 0. | ||
| * | ||
| * @return int|\WP_Error The attachment ID or WP_Error on failure. | ||
| */ | ||
| private static function save_attachment( $attachment_data, $post_id, $author_id = 0 ) { | ||
| $is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] ); | ||
|
|
||
| if ( $is_local ) { | ||
| // Read local file from disk. | ||
| \WP_Filesystem(); | ||
obenland marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| global $wp_filesystem; | ||
|
|
||
| if ( ! $wp_filesystem->exists( $attachment_data['url'] ) ) { | ||
| /* translators: %s: file path */ | ||
| return new \WP_Error( 'file_not_found', sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) ); | ||
| } | ||
|
|
||
| // Copy to temp file so media_handle_sideload doesn't move the original. | ||
| $tmp_file = \wp_tempnam( \basename( $attachment_data['url'] ) ); | ||
| $wp_filesystem->copy( $attachment_data['url'], $tmp_file, true ); | ||
obenland marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } else { | ||
| // Download remote URL. | ||
| $tmp_file = \download_url( $attachment_data['url'] ); | ||
|
|
||
| if ( \is_wp_error( $tmp_file ) ) { | ||
| return $tmp_file; | ||
| } | ||
| } | ||
|
|
||
| // Prepare file array for WordPress. | ||
| $file_array = array( | ||
| 'name' => \basename( $attachment_data['url'] ), | ||
| 'tmp_name' => $tmp_file, | ||
| ); | ||
|
|
||
| // Prepare attachment post data. | ||
| $post_data = array( | ||
| 'post_mime_type' => $attachment_data['mediaType'] ?? '', | ||
| 'post_title' => $attachment_data['name'] ?? '', | ||
| 'post_content' => $attachment_data['name'] ?? '', | ||
obenland marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 'post_status' => 'inherit', | ||
| 'post_author' => $author_id, | ||
| 'meta_input' => array( | ||
| '_activitypub_source_url' => $attachment_data['url'], | ||
| ), | ||
| ); | ||
|
|
||
| // Add alt text for images. | ||
| if ( ! empty( $attachment_data['name'] ) ) { | ||
| $mime_type = $attachment_data['mediaType'] ?? ''; | ||
obenland marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if ( 'image' === strtok( $mime_type, '/' ) ) { | ||
| $post_data['meta_input']['_wp_attachment_image_alt'] = $attachment_data['name']; | ||
| } | ||
| } | ||
|
|
||
| // Ensure required WordPress functions are loaded. | ||
| if ( ! function_exists( 'media_handle_sideload' ) ) { | ||
| require_once ABSPATH . 'wp-admin/includes/media.php'; | ||
| require_once ABSPATH . 'wp-admin/includes/file.php'; | ||
| require_once ABSPATH . 'wp-admin/includes/image.php'; | ||
| } | ||
|
|
||
| // Sideload the attachment into WordPress. | ||
| $attachment_id = \media_handle_sideload( $file_array, $post_id, '', $post_data ); | ||
|
|
||
| // Clean up temp file if there was an error. | ||
| if ( \is_wp_error( $attachment_id ) ) { | ||
| \wp_delete_file( $tmp_file ); | ||
| } | ||
|
|
||
| return $attachment_id; | ||
| } | ||
|
|
||
| /** | ||
| * Append media to post content. | ||
| * | ||
| * @param int $post_id The post ID. | ||
| * @param array $attachment_ids Array of attachment IDs. | ||
| */ | ||
| private static function append_media_to_content( $post_id, $attachment_ids ) { | ||
obenland marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| $post = \get_post( $post_id ); | ||
| if ( ! $post ) { | ||
| return; | ||
| } | ||
|
|
||
| $media = self::generate_media_markup( $attachment_ids ); | ||
| $separator = "\n\n"; | ||
|
|
||
| // Don't add separator if content is empty. | ||
| if ( empty( trim( $post->post_content ) ) ) { | ||
| $separator = ''; | ||
| } | ||
|
|
||
| \wp_update_post( | ||
| array( | ||
| 'ID' => $post_id, | ||
| 'post_content' => $post->post_content . $separator . $media, | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Generate media markup for attachments. | ||
| * | ||
| * @param array $attachment_ids Array of attachment IDs. | ||
| * | ||
| * @return string The generated markup. | ||
| */ | ||
| private static function generate_media_markup( $attachment_ids ) { | ||
| if ( empty( $attachment_ids ) ) { | ||
| return ''; | ||
| } | ||
|
|
||
| /** | ||
| * Filters the media markup for ActivityPub attachments. | ||
| * | ||
| * Allows plugins to provide custom markup for attachments. | ||
| * If this filter returns a non-empty string, it will be used instead of | ||
| * the default block markup. | ||
| * | ||
| * @param string $markup The custom markup. Default empty string. | ||
| * @param array $attachment_ids Array of attachment IDs. | ||
| */ | ||
| $custom_markup = \apply_filters( 'activitypub_attachments_media_markup', '', $attachment_ids ); | ||
|
|
||
| if ( ! empty( $custom_markup ) ) { | ||
| return $custom_markup; | ||
| } | ||
|
|
||
| // Default to block markup. | ||
| $type = strtok( \get_post_mime_type( $attachment_ids[0] ), '/' ); | ||
|
|
||
| // Single video or audio file. | ||
| if ( 1 === \count( $attachment_ids ) && ( 'video' === $type || 'audio' === $type ) ) { | ||
| return sprintf( | ||
| '<!-- wp:%1$s {"id":"%2$s"} --><figure class="wp-block-%1$s"><%1$s controls src="%3$s"></%1$s></figure><!-- /wp:%1$s -->', | ||
| \esc_attr( $type ), | ||
| \esc_attr( $attachment_ids[0] ), | ||
| \esc_url( \wp_get_attachment_url( $attachment_ids[0] ) ) | ||
| ); | ||
| } | ||
|
|
||
| // Multiple attachments or images: use gallery block. | ||
| return self::get_gallery_block( $attachment_ids ); | ||
| } | ||
|
|
||
| /** | ||
| * Get gallery block markup. | ||
| * | ||
| * @param array $attachment_ids The attachment IDs to use. | ||
| * | ||
| * @return string The gallery block markup. | ||
| */ | ||
| private static function get_gallery_block( $attachment_ids ) { | ||
| $gallery = '<!-- wp:gallery {"ids":[' . \implode( ',', $attachment_ids ) . '],"linkTo":"none"} -->' . "\n"; | ||
| $gallery .= '<figure class="wp-block-gallery has-nested-images columns-default is-cropped">'; | ||
|
|
||
| foreach ( $attachment_ids as $id ) { | ||
| $image_src = \wp_get_attachment_image_src( $id, 'large' ); | ||
| if ( ! $image_src ) { | ||
| continue; | ||
| } | ||
|
|
||
| $caption = \get_post_field( 'post_content', $id ); | ||
| $gallery .= "\n<!-- wp:image {\"id\":{$id},\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n"; | ||
obenland marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| $gallery .= '<figure class="wp-block-image size-large">'; | ||
| $gallery .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $caption ) . '" class="' . \esc_attr( 'wp-image-' . $id ) . '"/>'; | ||
| $gallery .= '</figure>'; | ||
| $gallery .= "\n<!-- /wp:image -->\n"; | ||
| } | ||
|
|
||
| $gallery .= "</figure>\n"; | ||
| $gallery .= '<!-- /wp:gallery -->'; | ||
|
|
||
| return $gallery; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would at least mention that this is used by the importer!?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you tell me more about that? We don't usually document callers, what makes this different?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I always stumble upon that function, because it still has an unprefixed/unsuffixed name
importbut it is very specific to the post import used by the mastodon import. I think it would be nice to give a hint about these two differences and when to use which function.