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( '/]+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( '
<%1$s controls src="%3$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 = '' . "\n"; $block .= '
'; $block .= '' . \esc_attr( $alt ) . ''; $block .= '
' . "\n"; $block .= ''; 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 = '' . "\n"; $gallery .= '\n"; $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( '
<%1$s controls src="%2$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 = '' . "\n"; $block .= '
'; $block .= '' . \esc_attr( $file['alt'] ?? '' ) . ''; $block .= '
' . "\n"; $block .= ''; 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 = '' . "\n"; $gallery .= '\n"; $gallery .= ''; return $gallery; } }