Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 312 additions & 10 deletions includes/class-attachments.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
* Attachments processor class.
*/
class Attachments {
/**
* Directory for storing ap_post media files.
*
* @var string
*/
private static $ap_posts_dir = '/activitypub/ap_posts';

/**
* Initialize the class and set up filters.
Expand All @@ -32,8 +38,22 @@ public static function delete_attachments_with_post( $post_id ) {
return;
}

foreach ( \get_attached_media( '', $post_id ) as $attachment ) {
\wp_delete_attachment( $attachment->ID, true );
self::delete_directory( $post_id );
}

/**
* Delete the activitypub files directory for a post.
*
* @param int $post_id The post ID.
*/
public static function delete_directory( $post_id ) {
\WP_Filesystem();
global $wp_filesystem;

$activitypub_dir = \wp_upload_dir()['basedir'] . self::$ap_posts_dir . $post_id;

if ( $wp_filesystem->is_dir( $activitypub_dir ) ) {
$wp_filesystem->delete( $activitypub_dir, true );
}
}

Expand Down Expand Up @@ -126,6 +146,53 @@ public static function import( $attachments, $post_id, $author_id = 0 ) {
return $attachment_ids;
}

/**
* Import attachments as direct files (for ap_post types).
*
* Saves files directly to uploads/activitypub/ap_posts/{post_id}/ without creating
* WordPress attachment posts. Used for ActivityPub inbox items.
*
* @param array $attachments Array of ActivityPub attachment objects.
* @param int $post_id The post ID to attach files to.
*
* @return array[] Array of file data arrays.
*/
public static function import_files( $attachments, $post_id ) {
// First, import inline images from the post content.
$inline_mappings = self::import_inline_files( $post_id );

if ( empty( $attachments ) || ! is_array( $attachments ) ) {
return array();
}

$files = array();
foreach ( $attachments as $attachment ) {
$attachment_data = self::normalize_attachment( $attachment );

if ( empty( $attachment_data['url'] ) ) {
continue;
}

// Skip if this URL was already processed as an inline image.
if ( isset( $inline_mappings[ $attachment_data['url'] ] ) ) {
continue;
}

$file_data = self::save_file( $attachment_data, $post_id );

if ( ! \is_wp_error( $file_data ) ) {
$files[] = $file_data;
}
}

// Append media markup to post content.
if ( ! empty( $files ) ) {
self::append_files_to_content( $post_id, $files );
}

return $files;
}

/**
* Check if an attachment with the same source URL already exists for a post.
*
Expand Down Expand Up @@ -204,6 +271,61 @@ private static function import_inline_images( $post_id, $author_id = 0 ) {
return $url_mappings;
}

/**
* Process inline images from post content (for direct file storage).
*
* @param int $post_id The post ID.
*
* @return array Array of URL mappings (old URL => new URL).
*/
private static function import_inline_files( $post_id ) {
$post = \get_post( $post_id );
if ( ! $post || empty( $post->post_content ) ) {
return array();
}

// Find all img tags in the content.
preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );

if ( empty( $matches[1] ) ) {
return array();
}

$url_mappings = array();
$content = $post->post_content;

foreach ( $matches[1] as $image_url ) {
// Skip if already processed.
if ( isset( $url_mappings[ $image_url ] ) ) {
continue;
}

$file_data = self::save_file( array( 'url' => $image_url ), $post_id );

if ( \is_wp_error( $file_data ) ) {
continue;
}

$new_url = $file_data['url'];
if ( $new_url ) {
$url_mappings[ $image_url ] = $new_url;
$content = \str_replace( $image_url, $new_url, $content );
}
}

// Update post content if URLs were replaced.
if ( ! empty( $url_mappings ) ) {
\wp_update_post(
array(
'ID' => $post_id,
'post_content' => $content,
)
);
}

return $url_mappings;
}

/**
* Normalize an ActivityPub attachment object to a standard format.
*
Expand Down Expand Up @@ -311,11 +433,86 @@ private static function save_attachment( $attachment_data, $post_id, $author_id
return $attachment_id;
}

/**
* Save a file directly to uploads/activitypub/ap_posts/{post_id}/ (for ap_post types).
*
* @param array $attachment_data The normalized attachment data.
* @param int $post_id The post ID to attach to.
*
* @return array|\WP_Error {
* Array of file data on success, WP_Error on failure.
*
* @type string $url Full URL to the saved file.
* @type string $mime_type MIME type of the file.
* @type string $alt Alt text from attachment name field.
* }
*/
private static function save_file( $attachment_data, $post_id ) {
if ( ! \function_exists( 'download_url' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}

// Download remote URL.
$tmp_file = \download_url( $attachment_data['url'] );

if ( \is_wp_error( $tmp_file ) ) {
return $tmp_file;
}

// Get upload directory and create activitypub subdirectory.
$upload_dir = \wp_upload_dir();
$base_dir = $upload_dir['basedir'] . self::$ap_posts_dir . $post_id;
$base_url = $upload_dir['baseurl'] . self::$ap_posts_dir . $post_id;

// Create directory if it doesn't exist.
if ( ! file_exists( $base_dir ) ) {
\wp_mkdir_p( $base_dir );
}

// Generate unique filename.
$filename = \sanitize_file_name( \basename( $attachment_data['url'] ) );
$filepath = $base_dir . '/' . $filename;

// Initialize filesystem if needed.
\WP_Filesystem();
global $wp_filesystem;

// Make sure filename is unique.
$counter = 1;
while ( $wp_filesystem->exists( $filepath ) ) {
$path_info = pathinfo( $filename );
$file_name = $path_info['filename'] . '-' . $counter;
if ( ! empty( $path_info['extension'] ) ) {
$file_name .= '.' . $path_info['extension'];
}
$filepath = $base_dir . '/' . $file_name;
++$counter;
}

// Move file to destination.
if ( ! $wp_filesystem->move( $tmp_file, $filepath, true ) ) {
\wp_delete_file( $tmp_file );
return new \WP_Error( 'file_move_failed', \__( 'Failed to move file to destination.', 'activitypub' ) );
}

// Get mime type.
$mime_type = $attachment_data['mediaType'] ?? '';
if ( empty( $mime_type ) && \function_exists( 'mime_content_type' ) ) {
$mime_type = mime_content_type( $filepath );
}

return array(
'url' => $base_url . '/' . $filename,
'mime_type' => $mime_type,
'alt' => $attachment_data['name'] ?? '',
);
}

/**
* Append media to post content.
*
* @param int $post_id The post ID.
* @param array $attachment_ids Array of attachment IDs.
* @param int[] $attachment_ids Array of attachment IDs.
*/
private static function append_media_to_content( $post_id, $attachment_ids ) {
$post = \get_post( $post_id );
Expand All @@ -324,13 +521,37 @@ private static function append_media_to_content( $post_id, $attachment_ids ) {
}

$media = self::generate_media_markup( $attachment_ids );
$separator = "\n\n";
$separator = empty( trim( $post->post_content ) ) ? '' : "\n\n";

\wp_update_post(
array(
'ID' => $post_id,
'post_content' => $post->post_content . $separator . $media,
)
);
}

// Don't add separator if content is empty.
if ( empty( trim( $post->post_content ) ) ) {
$separator = '';
/**
* Append file-based media to post content.
*
* @param int $post_id The post ID.
* @param array[] $files {
* Array of file data arrays.
*
* @type string $url Full URL to the file.
* @type string $mime_type MIME type of the file.
* @type string $alt Alt text for the file.
* }
*/
private static function append_files_to_content( $post_id, $files ) {
$post = \get_post( $post_id );
if ( ! $post ) {
return;
}

$media = self::generate_files_markup( $files );
$separator = empty( trim( $post->post_content ) ) ? '' : "\n\n";

\wp_update_post(
array(
'ID' => $post_id,
Expand All @@ -342,7 +563,7 @@ private static function append_media_to_content( $post_id, $attachment_ids ) {
/**
* Generate media markup for attachments.
*
* @param array $attachment_ids Array of attachment IDs.
* @param int[] $attachment_ids Array of attachment IDs.
*
* @return string The generated markup.
*/
Expand All @@ -359,7 +580,7 @@ private static function generate_media_markup( $attachment_ids ) {
* the default block markup.
*
* @param string $markup The custom markup. Default empty string.
* @param array $attachment_ids Array of attachment IDs.
* @param int[] $attachment_ids Array of attachment IDs.
*/
$custom_markup = \apply_filters( 'activitypub_attachments_media_markup', '', $attachment_ids );

Expand All @@ -384,10 +605,60 @@ private static function generate_media_markup( $attachment_ids ) {
return self::get_gallery_block( $attachment_ids );
}

/**
* Generate media markup for file-based attachments.
*
* @param array[] $files {
* Array of file data arrays.
*
* @type string $url Full URL to the file.
* @type string $mime_type MIME type of the file.
* @type string $alt Alt text for the file.
* }
*
* @return string The generated markup.
*/
private static function generate_files_markup( $files ) {
if ( empty( $files ) ) {
return '';
}

/**
* Filters the media markup for ActivityPub file-based attachments.
*
* Allows plugins to provide custom markup for file-based 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 $files Array of file data arrays.
*/
$custom_markup = \apply_filters( 'activitypub_files_media_markup', '', $files );

if ( ! empty( $custom_markup ) ) {
return $custom_markup;
}

// Default to block markup.
$type = strtok( $files[0]['mime_type'], '/' );

// Single video or audio file.
if ( 1 === \count( $files ) && ( 'video' === $type || 'audio' === $type ) ) {
return sprintf(
'<!-- wp:%1$s --><figure class="wp-block-%1$s"><%1$s controls src="%2$s"></%1$s></figure><!-- /wp:%1$s -->',
\esc_attr( $type ),
\esc_url( $files[0]['url'] )
);
}

// Multiple attachments or images: use gallery block.
return self::get_files_gallery_block( $files );
}

/**
* Get gallery block markup.
*
* @param array $attachment_ids The attachment IDs to use.
* @param int[] $attachment_ids The attachment IDs to use.
*
* @return string The gallery block markup.
*/
Expand All @@ -414,4 +685,35 @@ private static function get_gallery_block( $attachment_ids ) {

return $gallery;
}

/**
* Get gallery block markup for file-based attachments.
*
* @param array[] $files {
* Array of file data arrays.
*
* @type string $url Full URL to the file.
* @type string $mime_type MIME type of the file.
* @type string $alt Alt text for the file.
* }
*
* @return string The gallery block markup.
*/
private static function get_files_gallery_block( $files ) {
$gallery = '<!-- wp:gallery {"linkTo":"none"} -->' . "\n";
$gallery .= '<figure class="wp-block-gallery has-nested-images columns-default is-cropped">';

foreach ( $files as $file ) {
$gallery .= "\n<!-- wp:image {\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n";
$gallery .= '<figure class="wp-block-image size-large">';
$gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ) . '"/>';
$gallery .= '</figure>';
$gallery .= "\n<!-- /wp:image -->\n";
}

$gallery .= "</figure>\n";
$gallery .= '<!-- /wp:gallery -->';

return $gallery;
}
}
Loading
Loading