Files
laipower/wp-content/plugins/activitypub/includes/class-attachments.php

760 lines
24 KiB
PHP

<?php
/**
* Attachments processing file.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* Attachments processor class.
*
* Handles importing media attachments into the WordPress Media Library.
* Creates full WordPress attachment posts that are searchable and manageable.
*
* For lightweight file caching without Media Library overhead, use the
* Cache\Media, Cache\Avatar, and Cache\Emoji classes instead.
*
* @since 1.0.0
*/
class Attachments {
/**
* Maximum width for imported images into Media Library.
*
* @var int
*/
const MAX_IMAGE_DIMENSION = 1200;
/**
* Import attachments from an ActivityPub object and attach them to a post.
*
* Creates full WordPress attachment posts in the media library. Each attachment
* becomes a searchable, manageable attachment post that appears in the WordPress
* Media Library and is part of the user's content.
*
* Use this when:
* - Importing content that will be owned and editable by the user.
* - You need WordPress attachment posts with full metadata support.
* - Media should be searchable and manageable in the Media Library.
* - Working with content that will be part of the user's site (e.g., importers).
*
* @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 import( $attachments, $post_id, $author_id = 0 ) {
// First, import inline images from the post content.
$inline_mappings = self::import_inline_images( $post_id, $author_id );
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;
}
// Skip if this URL was already processed as an inline image.
if ( isset( $inline_mappings[ $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_post_content( $post_id, $attachment_ids );
}
return $attachment_ids;
}
/**
* Check if an attachment with the same source URL already exists for a post.
*
* @param string $source_url The source URL to check.
* @param int $post_id The post ID to check attachments for.
*
* @return int|false The existing attachment ID or false if not found.
*/
private static function get_existing_attachment( $source_url, $post_id ) {
foreach ( \get_attached_media( '', $post_id ) as $attachment ) {
if ( \get_post_meta( $attachment->ID, '_source_url', true ) === $source_url ) {
return $attachment->ID;
}
}
return false;
}
/**
* Process inline images from post content.
*
* @param int $post_id The post ID.
* @param int $author_id Optional. User ID to set as attachment author. Default 0.
*
* @return array Array of URL mappings (old URL => new URL).
*/
private static function import_inline_images( $post_id, $author_id = 0 ) {
$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 or is a local URL.
if ( isset( $url_mappings[ $image_url ] ) ) {
continue;
}
// Check if this image was already processed as an attachment.
$attachment_id = self::get_existing_attachment( $image_url, $post_id );
if ( ! $attachment_id ) {
$attachment_id = self::save_attachment( array( 'url' => $image_url ), $post_id, $author_id );
if ( \is_wp_error( $attachment_id ) ) {
continue;
}
}
$new_url = \wp_get_attachment_url( $attachment_id );
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.
*
* @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 ) ) {
$attachment = \get_object_vars( $attachment );
}
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 ) {
// Ensure required WordPress functions are loaded.
if ( ! \function_exists( 'media_handle_sideload' ) || ! \function_exists( 'download_url' ) ) {
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
}
// Use WP_Filesystem_Direct explicitly to avoid FTP fallback from WP_Filesystem().
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
$filesystem = new \WP_Filesystem_Direct( null );
$is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] );
if ( $is_local ) {
// Validate local path is within allowed directories to prevent file disclosure.
$allowed = self::is_allowed_local_path( $attachment_data['url'] );
if ( ! $allowed ) {
return new \WP_Error( 'invalid_path', \__( 'Local file path is not within allowed directories.', 'activitypub' ) );
}
// Read local file from disk.
if ( ! $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'] ) );
$filesystem->copy( $attachment_data['url'], $tmp_file, true );
} else {
// Validate remote URL before downloading.
if ( ! \wp_http_validate_url( $attachment_data['url'] ) ) {
return new \WP_Error( 'invalid_url', \__( 'URL is not allowed.', 'activitypub' ) );
}
// Download remote URL.
$tmp_file = \download_url( $attachment_data['url'] );
if ( \is_wp_error( $tmp_file ) ) {
return $tmp_file;
}
}
// Get original filename from URL.
$original_name = \basename( \wp_parse_url( $attachment_data['url'], PHP_URL_PATH ) );
// Rename temp file to have proper extension for optimize_image to detect mime type.
$original_ext = \pathinfo( $original_name, PATHINFO_EXTENSION );
if ( $original_ext ) {
$renamed_tmp = $tmp_file . '.' . $original_ext;
if ( $filesystem->move( $tmp_file, $renamed_tmp, true ) ) {
$tmp_file = $renamed_tmp;
}
}
// Optimize images before sideloading (resize and convert to WebP).
$tmp_file = self::optimize_image( $tmp_file, self::MAX_IMAGE_DIMENSION );
// Update filename extension to match optimized file.
$new_ext = \pathinfo( $tmp_file, PATHINFO_EXTENSION );
if ( $new_ext ) {
$original_name = \preg_replace( '/\.[^.]+$/', '.' . $new_ext, $original_name );
}
$file_array = array(
'name' => $original_name,
'tmp_name' => $tmp_file,
);
// Prepare attachment post data.
// Let WordPress auto-detect the mime type from the file.
$post_data = array(
'post_title' => $attachment_data['name'] ?? '',
'post_content' => $attachment_data['name'] ?? '',
'post_author' => $author_id,
'meta_input' => array(
'_source_url' => $attachment_data['url'],
),
);
// Add alt text for images.
if ( ! empty( $attachment_data['name'] ) ) {
$original_mime = $attachment_data['mediaType'] ?? '';
if ( 'image' === strtok( $original_mime, '/' ) ) {
$post_data['meta_input']['_wp_attachment_image_alt'] = $attachment_data['name'];
}
}
// 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;
}
/**
* Get a unique file path by appending a counter if the file already exists.
*
* @param string $file_path The desired file path.
*
* @return string A unique file path that doesn't exist.
*/
private static function get_unique_path( $file_path ) {
if ( ! \file_exists( $file_path ) ) {
return $file_path;
}
$path_info = \pathinfo( $file_path );
$dir = $path_info['dirname'];
$base_name = $path_info['filename'];
$extension = isset( $path_info['extension'] ) ? '.' . $path_info['extension'] : '';
$counter = 1;
do {
$new_path = $dir . '/' . $base_name . '-' . $counter . $extension;
++$counter;
} while ( \file_exists( $new_path ) );
return $new_path;
}
/**
* Check if a local file path is within allowed directories.
*
* Prevents arbitrary file access by restricting local paths to known safe
* directories like the uploads folder or WordPress temp directory.
*
* @param string $file_path The local file path to validate.
*
* @return bool True if the path is allowed, false otherwise.
*/
private static function is_allowed_local_path( $file_path ) {
// Normalize the path and resolve any relative components.
$real_path = \realpath( $file_path );
if ( false === $real_path ) {
// If file doesn't exist yet, check the directory.
$dir_path = \realpath( \dirname( $file_path ) );
if ( false === $dir_path ) {
return false;
}
$real_path = $dir_path . '/' . \basename( $file_path );
}
// Get allowed base directories.
$upload_dir = \wp_upload_dir();
$allowed_dirs = array(
\realpath( $upload_dir['basedir'] ),
\realpath( \get_temp_dir() ),
\realpath( ABSPATH . 'wp-content' ),
);
/**
* Filters the allowed directories for local file imports.
*
* @since 5.6.0
*
* @param string[] $allowed_dirs Array of allowed directory paths.
* @param string $file_path The file path being validated.
*/
$allowed_dirs = \apply_filters( 'activitypub_allowed_import_directories', $allowed_dirs, $file_path );
// Remove any false values from realpath failures.
$allowed_dirs = \array_filter( $allowed_dirs );
// Check if the file is within any allowed directory.
foreach ( $allowed_dirs as $allowed_dir ) {
if ( \str_starts_with( $real_path, $allowed_dir ) ) {
return true;
}
}
return false;
}
/**
* Optimize an image file by resizing and converting to WebP.
*
* Uses WordPress image editor to resize large images and convert them
* to WebP format for better compression while maintaining quality.
*
* @param string $file_path Path to the image file.
* @param int $max_dimension Maximum width/height in pixels.
*
* @return string The optimized file path.
*/
private static function optimize_image( $file_path, $max_dimension ) {
// Check if it's an image.
$mime_type = \wp_check_filetype( $file_path )['type'] ?? '';
if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) {
return $file_path;
}
// Skip SVG and GIF files (GIFs may be animated).
if ( \in_array( $mime_type, array( 'image/svg+xml', 'image/gif' ), true ) ) {
return $file_path;
}
$editor = \wp_get_image_editor( $file_path );
if ( \is_wp_error( $editor ) ) {
return $file_path;
}
$size = $editor->get_size();
$needs_resize = $size['width'] > $max_dimension || $size['height'] > $max_dimension;
// Resize if needed.
if ( $needs_resize ) {
$editor->resize( $max_dimension, $max_dimension, false );
}
// Check if WebP is supported.
$can_webp = $editor->supports_mime_type( 'image/webp' );
// Determine output format and save.
if ( $can_webp ) {
// Convert to WebP.
$new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.webp', $file_path ) );
$result = $editor->save( $new_path, 'image/webp' );
} elseif ( \in_array( $mime_type, array( 'image/png', 'image/webp' ), true ) ) {
// Keep original format for potentially transparent images when WebP not available.
if ( ! $needs_resize ) {
// No changes needed.
return $file_path;
}
$result = $editor->save( $file_path );
} else {
// Convert to JPEG when WebP not available.
$new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.jpg', $file_path ) );
$result = $editor->save( $new_path, 'image/jpeg' );
}
if ( \is_wp_error( $result ) ) {
return $file_path;
}
// Handle result - $result is always an array from $editor->save().
$result_path = $result['path'] ?? $file_path;
// If path changed (format conversion), delete the original file.
if ( $result_path !== $file_path ) {
\wp_delete_file( $file_path );
}
return $result_path;
}
/**
* Append media to post content.
*
* @param int $post_id The post ID.
* @param int[] $attachment_ids Array of attachment IDs.
*/
private static function append_media_to_post_content( $post_id, $attachment_ids ) {
$post = \get_post( $post_id );
if ( ! $post ) {
return;
}
$media = self::generate_media_markup( $attachment_ids );
$separator = empty( trim( $post->post_content ) ) ? '' : "\n\n";
\wp_update_post(
array(
'ID' => $post_id,
'post_content' => $post->post_content . $separator . $media,
)
);
}
/**
* Generate media markup for attachments.
*
* @param int[] $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 int[] $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] ) )
);
}
// Single image: use standalone image block.
if ( 1 === \count( $attachment_ids ) && 'image' === $type ) {
return self::get_image_block( $attachment_ids[0] );
}
// Multiple attachments: use gallery block.
return self::get_gallery_block( $attachment_ids );
}
/**
* Get standalone image block markup.
*
* @param int $attachment_id The attachment ID.
*
* @return string The image block markup.
*/
private static function get_image_block( $attachment_id ) {
$image_src = \wp_get_attachment_image_src( $attachment_id, 'large' );
if ( ! $image_src ) {
return '';
}
$alt = \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
if ( ! $alt ) {
$alt = \get_post_field( 'post_excerpt', $attachment_id );
}
$block = '<!-- wp:image {"id":' . \esc_attr( $attachment_id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
$block .= '<figure class="wp-block-image size-large">';
$block .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $attachment_id ) . '"/>';
$block .= '</figure>' . "\n";
$block .= '<!-- /wp:image -->';
return $block;
}
/**
* Get gallery block markup.
*
* @param int[] $attachment_ids The attachment IDs to use.
*
* @return string The gallery block markup.
*/
private static function get_gallery_block( $attachment_ids ) {
$gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","sizeSlug":"large","imageCrop":true} -->' . "\n";
$gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';
foreach ( $attachment_ids as $id ) {
$image_src = \wp_get_attachment_image_src( $id, 'large' );
if ( ! $image_src ) {
continue;
}
$alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
if ( ! $alt ) {
$alt = \get_post_field( 'post_excerpt', $id );
}
$gallery .= "\n" . '<!-- wp:image {"id":' . \esc_attr( $id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
$gallery .= '<figure class="wp-block-image size-large">';
$gallery .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $id ) . '"/>';
$gallery .= '</figure>';
$gallery .= "\n<!-- /wp:image -->\n";
}
$gallery .= "</figure>\n";
$gallery .= '<!-- /wp:gallery -->';
return $gallery;
}
/**
* Get content from an object based on its type.
*
* @param int $object_id The object ID (post or comment).
* @param string $object_type The object type ('post' or 'comment').
*
* @return string The object content.
*/
private static function get_object_content( $object_id, $object_type ) {
if ( 'comment' === $object_type ) {
$comment = \get_comment( $object_id );
return $comment ? $comment->comment_content : '';
}
return \get_post_field( 'post_content', $object_id );
}
/**
* Update content for an object based on its type.
*
* @param int $object_id The object ID (post or comment).
* @param string $object_type The object type ('post' or 'comment').
* @param string $content The new content.
*/
private static function update_object_content( $object_id, $object_type, $content ) {
if ( 'comment' === $object_type ) {
\wp_update_comment(
array(
'comment_ID' => $object_id,
'comment_content' => $content,
)
);
} else {
\wp_update_post(
array(
'ID' => $object_id,
'post_content' => $content,
)
);
}
}
/**
* Append file-based media markup to an object's content.
*
* Used for cached remote media (via Cache classes) that doesn't go through
* the Media Library. Works with posts and comments.
*
* @param int $object_id The object ID (post or comment).
* @param array $files Array of file data arrays with 'url', 'mime_type', and 'alt' keys.
* @param string $object_type The object type ('post' or 'comment').
*/
public static function append_files_to_content( $object_id, $files, $object_type = 'post' ) {
$content = self::get_object_content( $object_id, $object_type );
if ( empty( $content ) ) {
return;
}
$media = self::generate_files_markup( $files );
$separator = empty( trim( $content ) ) ? '' : "\n\n";
self::update_object_content( $object_id, $object_type, $content . $separator . $media );
}
/**
* Generate media markup for file-based attachments.
*
* Creates WordPress block markup from file data arrays. Used for cached
* remote media that doesn't have WordPress attachment posts.
*
* @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.
*/
public 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'] )
);
}
// Single image: use standalone image block.
if ( 1 === \count( $files ) && 'image' === $type ) {
return self::get_files_image_block( $files[0] );
}
// Multiple attachments: use gallery block.
return self::get_files_gallery_block( $files );
}
/**
* Get standalone image block markup for file-based attachments.
*
* @param array $file {
* File data array.
*
* @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 image block markup.
*/
public static function get_files_image_block( $file ) {
$block = '<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
$block .= '<figure class="wp-block-image size-large">';
$block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
$block .= '</figure>' . "\n";
$block .= '<!-- /wp:image -->';
return $block;
}
/**
* 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.
*/
public static function get_files_gallery_block( $files ) {
$gallery = '<!-- wp:gallery {"columns":2,"linkTo":"none","imageCrop":true} -->' . "\n";
$gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 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;
}
}