Skip to content
Open
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
323 changes: 8 additions & 315 deletions includes/Abilities/Image/Alt_Text_Generation.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
*/
class Alt_Text_Generation extends Abstract_Ability {

use Resolves_Image_Reference;

/**
* The maximum character length for generated alt text.
*
Expand Down Expand Up @@ -122,7 +124,7 @@ protected function execute_callback( $input ) {
);

// Get the image reference.
$image_reference = $this->get_image_reference( $args );
$image_reference = $this->resolve_image_reference( $args );

if ( is_wp_error( $image_reference ) ) {
return $image_reference;
Expand Down Expand Up @@ -153,75 +155,18 @@ protected function execute_callback( $input ) {
);
}

/**
* Gets the image as a data URI from the input arguments.
*
* @since 0.3.0
*
* @param array<string, mixed> $args The input arguments.
* @return array{reference: string}|\WP_Error The prepared reference payload or WP_Error on failure.
*/
protected function get_image_reference( array $args ) {
// If an attachment ID is provided, get the attachment from the database.
if ( ! empty( $args['attachment_id'] ) ) {
return $this->get_attachment_reference( absint( $args['attachment_id'] ) );
}

// If an image URL is provided, get the image from the URL.
if ( ! empty( $args['image_url'] ) ) {
// Preserve data URIs as-is so the AI client can read the inline bytes.
if ( str_starts_with( $args['image_url'], 'data:' ) ) {
return $this->prepare_reference_result( $args['image_url'] );
}

// Try to map the URL to a local path.
$path = $this->maybe_map_url_to_local_path( $args['image_url'] );

if ( $path ) {
$data_uri = $this->file_to_data_uri( $path );
if ( $data_uri ) {
return $this->prepare_reference_result( $data_uri );
}
}

// Download the remote image to a temporary file.
$downloaded = $this->download_remote_image_to_temp_file( $args['image_url'] );

if ( is_wp_error( $downloaded ) ) {
return $downloaded;
}

$data_uri = $this->file_to_data_uri( $downloaded );
$this->cleanup_temporary_file( $downloaded );

if ( ! $data_uri ) {
return new WP_Error(
'file_read_error',
esc_html__( 'Could not read the downloaded image file.', 'ai' )
);
}

return $this->prepare_reference_result( $data_uri );
}

return new WP_Error(
'no_image_provided',
esc_html__( 'Either attachment_id or image_url must be provided.', 'ai' )
);
}

/**
* Generates alt text for the given image reference.
*
* @since 0.3.0
*
* @param array{reference: string} $image_reference Prepared image reference containing a data URI.
* @param string $context Optional context to improve alt text relevance.
* @param string $image_meta Optional metadata about how the image block is used.
* @param string $image_reference Data URI for the image to analyze.
* @param string $context Optional context to improve alt text relevance.
* @param string $image_meta Optional metadata about how the image block is used.
* @return string|\WP_Error The generated alt text or WP_Error on failure.
*/
protected function generate_alt_text( array $image_reference, string $context = '', string $image_meta = '' ) {
$prompt_builder = $this->get_prompt_builder( $this->build_prompt( $context, $image_meta ), $image_reference['reference'] );
protected function generate_alt_text( string $image_reference, string $context = '', string $image_meta = '' ) {
$prompt_builder = $this->get_prompt_builder( $this->build_prompt( $context, $image_meta ), $image_reference );

if ( is_wp_error( $prompt_builder ) ) {
return $prompt_builder;
Expand All @@ -240,158 +185,6 @@ protected function generate_alt_text( array $image_reference, string $context =
return $alt_text;
}

/**
* Converts an attachment to a data URI.
*
* @since 0.3.0
*
* @param int $attachment_id Attachment ID.
* @return array{reference: string}|\WP_Error Data URI reference array or WP_Error on failure.
*/
protected function get_attachment_reference( int $attachment_id ) {
$attachment = get_post( $attachment_id );

// Ensure the attachment is valid.
if ( ! $attachment || 'attachment' !== $attachment->post_type ) {
return new WP_Error(
'invalid_attachment',
/* translators: %d: Attachment ID. */
sprintf( esc_html__( 'Attachment with ID %d not found.', 'ai' ), $attachment_id )
);
}

// Ensure the attachment is an image.
if ( ! wp_attachment_is_image( $attachment_id ) ) {
return new WP_Error(
'not_an_image',
esc_html__( 'The specified attachment is not an image.', 'ai' )
);
}

// Try and get the data URI from the file path.
$file_path = get_attached_file( $attachment_id );
if ( $file_path && file_exists( $file_path ) ) {
$data_uri = $this->file_to_data_uri( $file_path );
if ( $data_uri ) {
return $this->prepare_reference_result( $data_uri );
}
}

// If the file path doesn't exist, try and use the image src.
$image_src = wp_get_attachment_image_src( $attachment_id, 'large' );

if ( ! $image_src ) {
$image_src = wp_get_attachment_image_src( $attachment_id, 'full' );
}

if ( ! $image_src || empty( $image_src[0] ) ) {
return new WP_Error(
'image_url_not_found',
esc_html__( 'Could not retrieve image URL from attachment.', 'ai' )
);
}

// Download remote URL and convert to data URI.
$downloaded = $this->download_remote_image_to_temp_file( $image_src[0] );

if ( is_wp_error( $downloaded ) ) {
return $downloaded;
}

$data_uri = $this->file_to_data_uri( $downloaded );
$this->cleanup_temporary_file( $downloaded );

if ( ! $data_uri ) {
return new WP_Error(
'file_read_error',
esc_html__( 'Could not read the downloaded image file.', 'ai' )
);
}

return $this->prepare_reference_result( $data_uri );
}

/**
* Attempts to map an uploads URL to a local filesystem path.
*
* @since 0.3.0
*
* @param string $url The URL to convert.
* @return string|null The local path if found, otherwise null.
*/
protected function maybe_map_url_to_local_path( string $url ): ?string {
$uploads = wp_get_upload_dir();

if (
empty( $uploads['baseurl'] ) ||
empty( $uploads['basedir'] )
) {
return null;
}

$normalized_url = $this->normalize_upload_url( $url );
$normalized_baseurl = $this->normalize_upload_url( $uploads['baseurl'] );

if ( ! str_contains( $normalized_url, $normalized_baseurl ) ) {
return null;
}

$relative_path = ltrim(
substr( $normalized_url, strlen( $normalized_baseurl ) ),
'/'
);

if ( '' === $relative_path ) {
return null;
}

// Reject path traversal attempts in the relative path.
if (
'..' === $relative_path ||
str_starts_with( $relative_path, '../' ) ||
str_contains( $relative_path, '/..' )
) {
return null;
}

$base_dir = wp_normalize_path(
trailingslashit( $uploads['basedir'] )
);
$full_path = $base_dir . $relative_path;
$real_full_path = realpath( $full_path );

if ( false === $real_full_path ) {
return null;
}

$real_full_path = wp_normalize_path( $real_full_path );

// Ensure the resolved path is strictly within the uploads base directory.
if ( ! str_starts_with( $real_full_path, $base_dir ) ) {
return null;
}

if ( file_exists( $real_full_path ) && is_file( $real_full_path ) ) {
return $real_full_path;
}

return null;
}

/**
* Normalizes an uploads URL for comparison.
*
* @since 0.3.0
*
* @param string $url URL to normalize.
* @return string Normalized URL without scheme and trailing slashes.
*/
protected function normalize_upload_url( string $url ): string {
$without_scheme = preg_replace( '#^https?://#i', '', $url );

return rtrim( $without_scheme ?? $url, '/' );
}

/**
* Gets a prompt builder for generating alt text.
*
Expand Down Expand Up @@ -447,106 +240,6 @@ protected function build_prompt( string $context = '', string $image_meta = '' )
return $prompt;
}

/**
* Sanitizes incoming image references while allowing data URIs.
*
* @since 0.3.0
*
* @param mixed $value Raw user input.
* @return string Sanitized value.
*/
protected function sanitize_image_reference_input( $value ): string {
if ( ! is_string( $value ) ) {
return '';
}

$value = trim( $value );

if ( '' === $value ) {
return '';
}

if ( str_starts_with( $value, 'data:' ) ) {
return $value;
}

return esc_url_raw( $value );
}

/**
* Downloads a remote image to a temporary file for processing.
*
* @since 0.3.0
*
* @param string $url Remote image URL.
* @return string|\WP_Error Path to the temporary file or WP_Error on failure.
*/
protected function download_remote_image_to_temp_file( string $url ) {
if ( ! function_exists( 'download_url' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}

$temp_file = download_url( $url );

if ( is_wp_error( $temp_file ) ) {
return $temp_file;
}

return $temp_file;
}

/**
* Cleans up a temporary file if it exists.
*
* @since 0.3.0
*
* @param string $file_path The file path to clean up.
* @return void
*/
protected function cleanup_temporary_file( string $file_path ): void {
if ( ! file_exists( $file_path ) ) {
return;
}

wp_delete_file( $file_path );
}

/**
* Helper to standardize the reference result.
*
* @since 0.3.0
*
* @param string $reference The data URI reference.
* @return array{reference: string} Standardized reference array.
*/
protected function prepare_reference_result( string $reference ): array {
return array(
'reference' => $reference,
);
}

/**
* Converts a file to a data URI.
*
* @since 0.3.0
*
* @param string $file_path Path to the file.
* @return string|null Data URI or null on failure.
*/
protected function file_to_data_uri( string $file_path ): ?string {
$mime_type = wp_check_filetype( $file_path )['type'];
if ( ! $mime_type ) {
return null;
}

$contents = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown
if ( false === $contents ) {
return null;
}

return 'data:' . $mime_type . ';base64,' . base64_encode( $contents );
}

/**
* {@inheritDoc}
*
Expand Down
Loading
Loading