Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
74.74% |
290 / 388 |
|
37.50% |
3 / 8 |
CRAP | |
0.00% |
0 / 1 |
| ImageEdit | |
74.74% |
290 / 388 |
|
37.50% |
3 / 8 |
190.35 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register_abilities | |
100.00% |
106 / 106 |
|
100.00% |
1 / 1 |
1 | |||
| extract_colors | |
93.75% |
30 / 32 |
|
0.00% |
0 / 1 |
7.01 | |||
| analyze_image_colors | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
240 | |||
| edit | |
87.80% |
72 / 82 |
|
0.00% |
0 / 1 |
31.63 | |||
| is_allowed_source_url | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
5.05 | |||
| fetch_source_image | |
61.70% |
58 / 94 |
|
0.00% |
0 / 1 |
42.47 | |||
| build_multipart_body | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace BLU\Abilities; |
| 6 | |
| 7 | /** |
| 8 | * Image edit ability using the AI platform service. |
| 9 | */ |
| 10 | class ImageEdit { |
| 11 | |
| 12 | |
| 13 | /** |
| 14 | * Constructor - registers image generation ability. |
| 15 | */ |
| 16 | public function __construct() { |
| 17 | $this->register_abilities(); |
| 18 | } |
| 19 | |
| 20 | /** |
| 21 | * Register image generation abilities. |
| 22 | */ |
| 23 | private function register_abilities(): void { |
| 24 | blu_register_ability( |
| 25 | 'blu/extract-image-colors', |
| 26 | array( |
| 27 | 'label' => 'Extract Image Colors', |
| 28 | 'description' => 'Analyze an uploaded image and return its dominant colors as hex codes. Use this when the user asks to apply a logo or image color to the site\'s global styles (accent color, primary color, brand color, etc.).', |
| 29 | 'category' => 'blu-mcp', |
| 30 | 'input_schema' => array( |
| 31 | 'type' => 'object', |
| 32 | 'properties' => array( |
| 33 | 'image_url' => array( |
| 34 | 'type' => 'string', |
| 35 | 'description' => 'URL of the image to analyze. Must be a URL on this site (e.g. from the [User uploaded images] list).', |
| 36 | ), |
| 37 | 'max_colors' => array( |
| 38 | 'type' => 'integer', |
| 39 | 'description' => 'Maximum number of dominant colors to return. Defaults to 5.', |
| 40 | 'minimum' => 1, |
| 41 | 'maximum' => 10, |
| 42 | ), |
| 43 | ), |
| 44 | 'required' => array( 'image_url' ), |
| 45 | ), |
| 46 | 'execute_callback' => array( $this, 'extract_colors' ), |
| 47 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 48 | 'meta' => array( |
| 49 | 'annotations' => array( |
| 50 | 'readonly' => true, |
| 51 | 'destructive' => false, |
| 52 | 'idempotent' => true, |
| 53 | ), |
| 54 | ), |
| 55 | ) |
| 56 | ); |
| 57 | |
| 58 | blu_register_ability( |
| 59 | 'blu/edit-image', |
| 60 | array( |
| 61 | 'label' => 'Edit Image', |
| 62 | 'description' => 'Generatively edit an existing image: add or remove objects, change colours, adjust lighting, apply styles, or transform any aspect of the photo. Requires a source_url pointing to the current image. Use this instead of blu/generate-image whenever an existing image is available and the user wants to modify, change, or enhance it.', |
| 63 | 'category' => 'blu-mcp', |
| 64 | 'input_schema' => array( |
| 65 | 'type' => 'object', |
| 66 | 'properties' => array( |
| 67 | 'prompt' => array( |
| 68 | 'type' => 'string', |
| 69 | 'description' => 'What to change in the image — describe the desired result or the modification (e.g. "add a dog under the table", "make the background white", "change the shirt to red"). Max 1000 characters.', |
| 70 | 'maxLength' => 1000, |
| 71 | ), |
| 72 | 'source_url' => array( |
| 73 | 'type' => 'string', |
| 74 | 'description' => 'URL of the existing image to edit. Must be an accessible HTTP/HTTPS URL.', |
| 75 | ), |
| 76 | 'reference_url' => array( |
| 77 | 'type' => 'string', |
| 78 | 'description' => 'URL of a second reference image to blend or combine with the source. Use this when the user has uploaded an image and wants to merge, blend, or apply elements from it onto the source. Must be an accessible HTTP/HTTPS URL.', |
| 79 | ), |
| 80 | 'orientation' => array( |
| 81 | 'type' => 'string', |
| 82 | 'description' => 'The orientation of the image. Defaults to landscape.', |
| 83 | 'enum' => array( 'landscape', 'portrait', 'square' ), |
| 84 | ), |
| 85 | 'width' => array( |
| 86 | 'type' => 'integer', |
| 87 | 'description' => 'The width of the image. Defaults to 1024.', |
| 88 | 'maximum' => 1920, |
| 89 | 'minimum' => 1, |
| 90 | ), |
| 91 | 'height' => array( |
| 92 | 'type' => 'integer', |
| 93 | 'description' => 'The height of the image. Defaults to 1024.', |
| 94 | 'maximum' => 1080, |
| 95 | 'minimum' => 1, |
| 96 | ), |
| 97 | 'quality' => array( |
| 98 | 'type' => 'integer', |
| 99 | 'description' => 'The quality of the image. Defaults to 85.', |
| 100 | 'maximum' => 100, |
| 101 | 'minimum' => 1, |
| 102 | ), |
| 103 | 'background' => array( |
| 104 | 'type' => 'string', |
| 105 | 'description' => 'The background of the image. Defaults to auto.', |
| 106 | 'enum' => array( 'transparent', 'opaque', 'auto' ), |
| 107 | ), |
| 108 | 'trim' => array( |
| 109 | 'type' => 'boolean', |
| 110 | 'description' => 'Whether to trim the image. Defaults to false.', |
| 111 | ), |
| 112 | 'fit' => array( |
| 113 | 'type' => 'string', |
| 114 | 'description' => 'The fit of the image. Defaults to cover.', |
| 115 | 'enum' => array( 'cover', 'contain', 'fill', 'none', 'scale-down' ), |
| 116 | ), |
| 117 | ), |
| 118 | 'required' => array( 'prompt', 'source_url' ), |
| 119 | ), |
| 120 | 'execute_callback' => array( $this, 'edit' ), |
| 121 | 'permission_callback' => fn() => current_user_can( 'upload_files' ), |
| 122 | 'meta' => array( |
| 123 | 'annotations' => array( |
| 124 | 'readonly' => false, |
| 125 | 'destructive' => false, |
| 126 | 'idempotent' => false, |
| 127 | ), |
| 128 | ), |
| 129 | ) |
| 130 | ); |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Extract dominant colors from an image using GD. |
| 135 | * |
| 136 | * @param array $input Tool input parameters. |
| 137 | * @return array Standardized ability response. |
| 138 | */ |
| 139 | public function extract_colors( array $input ): array { |
| 140 | if ( ! function_exists( 'imagecreatefromstring' ) ) { |
| 141 | return blu_prepare_ability_response( 500, __( 'GD image library is not available on this server.', 'wp-module-mcp' ) ); |
| 142 | } |
| 143 | |
| 144 | $raw_url = (string) ( $input['image_url'] ?? '' ); |
| 145 | $url = esc_url_raw( $raw_url ); |
| 146 | if ( empty( $url ) || ! filter_var( $raw_url, FILTER_VALIDATE_URL ) ) { |
| 147 | return blu_prepare_ability_response( 400, __( 'A valid image_url is required.', 'wp-module-mcp' ) ); |
| 148 | } |
| 149 | |
| 150 | if ( ! $this->is_allowed_source_url( $url ) ) { |
| 151 | return blu_prepare_ability_response( 400, __( 'The image_url is not allowed.', 'wp-module-mcp' ) ); |
| 152 | } |
| 153 | |
| 154 | $image_payload = $this->fetch_source_image( $url ); |
| 155 | if ( isset( $image_payload['error'] ) ) { |
| 156 | return blu_prepare_ability_response( (int) $image_payload['status'], (string) $image_payload['error'] ); |
| 157 | } |
| 158 | |
| 159 | $max_colors = min( max( (int) ( $input['max_colors'] ?? 5 ), 1 ), 10 ); |
| 160 | $colors = $this->analyze_image_colors( $image_payload['content'], $max_colors ); |
| 161 | |
| 162 | if ( empty( $colors ) ) { |
| 163 | return blu_prepare_ability_response( |
| 164 | 200, |
| 165 | array( |
| 166 | 'colors' => array(), |
| 167 | 'message' => __( 'No distinct colors found. The image may be mostly white, black, or transparent.', 'wp-module-mcp' ), |
| 168 | ) |
| 169 | ); |
| 170 | } |
| 171 | |
| 172 | return blu_prepare_ability_response( |
| 173 | 200, |
| 174 | array( |
| 175 | 'dominant' => $colors[0]['hex'], |
| 176 | 'colors' => $colors, |
| 177 | 'message' => sprintf( |
| 178 | /* translators: %s: dominant hex color value */ |
| 179 | __( 'Dominant color: %s. Use this hex value with blu/update-global-styles to apply it as the accent or primary color.', 'wp-module-mcp' ), |
| 180 | $colors[0]['hex'] |
| 181 | ), |
| 182 | ) |
| 183 | ); |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * Sample pixels from image binary data and return dominant non-background colors. |
| 188 | * |
| 189 | * Resizes to an 80×80 thumbnail for efficient sampling, skips transparent, |
| 190 | * near-white, and near-black pixels, then quantizes to 32-step bins to group |
| 191 | * similar shades before ranking by frequency. |
| 192 | * |
| 193 | * @param string $content Raw image binary content. |
| 194 | * @param int $max_colors Maximum colors to return. |
| 195 | * @return array Array of {hex, percentage} sorted by frequency descending. |
| 196 | */ |
| 197 | private function analyze_image_colors( string $content, int $max_colors ): array { |
| 198 | $img = @imagecreatefromstring( $content ); // phpcs:ignore WordPress.PHP.NoSilencedErrors |
| 199 | if ( false === $img ) { |
| 200 | return array(); |
| 201 | } |
| 202 | |
| 203 | // Convert palette-mode images (indexed PNG/GIF) to truecolor so that |
| 204 | // GD maps the transparent colour index to a real alpha channel before |
| 205 | // resampling — otherwise transparent pixels blend as opaque colours. |
| 206 | if ( ! imageistruecolor( $img ) ) { |
| 207 | imagepalettetotruecolor( $img ); |
| 208 | imagealphablending( $img, false ); |
| 209 | imagesavealpha( $img, true ); |
| 210 | } |
| 211 | |
| 212 | $sample_w = 80; |
| 213 | $sample_h = 80; |
| 214 | $thumb = imagecreatetruecolor( $sample_w, $sample_h ); |
| 215 | |
| 216 | // Preserve transparency during resampling. |
| 217 | imagealphablending( $thumb, false ); |
| 218 | imagesavealpha( $thumb, true ); |
| 219 | $transparent = imagecolorallocatealpha( $thumb, 255, 255, 255, 127 ); |
| 220 | imagefill( $thumb, 0, 0, $transparent ); |
| 221 | imagealphablending( $thumb, true ); |
| 222 | |
| 223 | imagecopyresampled( $thumb, $img, 0, 0, 0, 0, $sample_w, $sample_h, imagesx( $img ), imagesy( $img ) ); |
| 224 | unset( $img ); |
| 225 | |
| 226 | $color_counts = array(); |
| 227 | $total = 0; |
| 228 | |
| 229 | for ( $x = 0; $x < $sample_w; $x++ ) { |
| 230 | for ( $y = 0; $y < $sample_h; $y++ ) { |
| 231 | $rgba = imagecolorat( $thumb, $x, $y ); |
| 232 | $alpha = ( $rgba >> 24 ) & 0x7F; |
| 233 | |
| 234 | // Skip mostly-transparent pixels (127 = fully transparent in GD). |
| 235 | if ( $alpha > 90 ) { |
| 236 | continue; |
| 237 | } |
| 238 | |
| 239 | $r = ( $rgba >> 16 ) & 0xFF; |
| 240 | $g = ( $rgba >> 8 ) & 0xFF; |
| 241 | $b = $rgba & 0xFF; |
| 242 | |
| 243 | // Skip near-white (typical image backgrounds). |
| 244 | if ( $r > 230 && $g > 230 && $b > 230 ) { |
| 245 | continue; |
| 246 | } |
| 247 | |
| 248 | // Skip near-black. |
| 249 | if ( $r < 20 && $g < 20 && $b < 20 ) { |
| 250 | continue; |
| 251 | } |
| 252 | |
| 253 | // Quantize to 32-step bins to group perceptually similar shades. |
| 254 | $qr = (int) round( $r / 32 ) * 32; |
| 255 | $qg = (int) round( $g / 32 ) * 32; |
| 256 | $qb = (int) round( $b / 32 ) * 32; |
| 257 | $key = sprintf( '%02x%02x%02x', min( 255, $qr ), min( 255, $qg ), min( 255, $qb ) ); |
| 258 | |
| 259 | $color_counts[ $key ] = ( $color_counts[ $key ] ?? 0 ) + 1; |
| 260 | ++$total; |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | unset( $thumb ); |
| 265 | |
| 266 | if ( 0 === $total || empty( $color_counts ) ) { |
| 267 | return array(); |
| 268 | } |
| 269 | |
| 270 | arsort( $color_counts ); |
| 271 | |
| 272 | $colors = array(); |
| 273 | foreach ( array_slice( $color_counts, 0, $max_colors, true ) as $hex => $count ) { |
| 274 | $colors[] = array( |
| 275 | 'hex' => '#' . $hex, |
| 276 | 'percentage' => (int) round( $count / $total * 100 ), |
| 277 | ); |
| 278 | } |
| 279 | |
| 280 | return $colors; |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Edit an image via the AI platform. |
| 285 | * |
| 286 | * @param array $input Tool input parameters. |
| 287 | * @return array Standardized ability response. |
| 288 | */ |
| 289 | public function edit( array $input ): array { |
| 290 | // Extend PHP execution time for this long-running request. |
| 291 | set_time_limit( 120 ); |
| 292 | |
| 293 | $raw_source_url = (string) ( $input['source_url'] ?? '' ); |
| 294 | $source_url = esc_url_raw( $raw_source_url ); |
| 295 | if ( empty( $source_url ) || ! filter_var( $raw_source_url, FILTER_VALIDATE_URL ) ) { |
| 296 | return blu_prepare_ability_response( 400, 'A valid source_url is required.' ); |
| 297 | } |
| 298 | |
| 299 | if ( ! $this->is_allowed_source_url( $source_url ) ) { |
| 300 | return blu_prepare_ability_response( 400, 'source_url is not allowed.' ); |
| 301 | } |
| 302 | |
| 303 | $image_payload = $this->fetch_source_image( $source_url ); |
| 304 | if ( is_array( $image_payload ) && isset( $image_payload['error'] ) ) { |
| 305 | return blu_prepare_ability_response( $image_payload['status'], $image_payload['error'] ); |
| 306 | } |
| 307 | |
| 308 | $api_url = defined( 'NFD_AI_PLATFORM_URL' ) ? NFD_AI_PLATFORM_URL : 'https://ai-platform.hiive.cloud'; |
| 309 | |
| 310 | // Get Hiive auth token — required by the ai-platform middleware. |
| 311 | $hiive_token = ''; |
| 312 | if ( class_exists( '\NewfoldLabs\WP\Module\Data\HiiveConnection' ) ) { |
| 313 | $hiive_token = \NewfoldLabs\WP\Module\Data\HiiveConnection::get_auth_token(); |
| 314 | } |
| 315 | |
| 316 | if ( empty( $hiive_token ) ) { |
| 317 | return blu_prepare_ability_response( 401, 'Unable to retrieve Hiive authentication token for image generation.' ); |
| 318 | } |
| 319 | |
| 320 | $fields = array( |
| 321 | 'prompt' => substr( (string) $input['prompt'], 0, 1000 ), |
| 322 | ); |
| 323 | if ( ! empty( $input['orientation'] ) ) { |
| 324 | $fields['orientation'] = $input['orientation']; |
| 325 | } |
| 326 | if ( ! empty( $input['background'] ) ) { |
| 327 | $fields['background'] = $input['background']; |
| 328 | } |
| 329 | if ( isset( $input['trim'] ) ) { |
| 330 | $fields['trim'] = $input['trim'] ? '1' : '0'; |
| 331 | } |
| 332 | if ( ! empty( $input['width'] ) ) { |
| 333 | $fields['width'] = (string) min( (int) $input['width'], 1920 ); |
| 334 | } |
| 335 | if ( ! empty( $input['height'] ) ) { |
| 336 | $fields['height'] = (string) min( (int) $input['height'], 1080 ); |
| 337 | } |
| 338 | if ( ! empty( $input['quality'] ) ) { |
| 339 | $fields['quality'] = (string) min( max( (int) $input['quality'], 1 ), 100 ); |
| 340 | } |
| 341 | if ( ! empty( $input['fit'] ) ) { |
| 342 | $fields['fit'] = (string) $input['fit']; |
| 343 | } |
| 344 | $files = array( |
| 345 | array( |
| 346 | 'field' => 'images[]', |
| 347 | 'filename' => $image_payload['filename'], |
| 348 | 'content' => $image_payload['content'], |
| 349 | 'mime' => $image_payload['mime'], |
| 350 | ), |
| 351 | ); |
| 352 | |
| 353 | // If a reference image is provided, fetch and append it as a second image. |
| 354 | $raw_reference_url = (string) ( $input['reference_url'] ?? '' ); |
| 355 | if ( ! empty( $raw_reference_url ) ) { |
| 356 | $reference_url = esc_url_raw( $raw_reference_url ); |
| 357 | if ( filter_var( $raw_reference_url, FILTER_VALIDATE_URL ) && $this->is_allowed_source_url( $reference_url ) ) { |
| 358 | $reference_payload = $this->fetch_source_image( $reference_url ); |
| 359 | if ( is_array( $reference_payload ) && isset( $reference_payload['content'] ) ) { |
| 360 | $files[] = array( |
| 361 | 'field' => 'images[]', |
| 362 | 'filename' => $reference_payload['filename'], |
| 363 | 'content' => $reference_payload['content'], |
| 364 | 'mime' => $reference_payload['mime'], |
| 365 | ); |
| 366 | } |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | $multipart_body = $this->build_multipart_body( $fields, $files ); |
| 371 | $response = wp_remote_post( |
| 372 | trailingslashit( $api_url ) . 'api/v1/imagegen/edit', |
| 373 | array( |
| 374 | 'headers' => array( |
| 375 | 'Authorization' => 'Bearer ' . $hiive_token, |
| 376 | 'Accept' => 'application/json', |
| 377 | 'Content-Type' => 'multipart/form-data; boundary=' . $multipart_body['boundary'], |
| 378 | ), |
| 379 | 'body' => $multipart_body['body'], |
| 380 | 'timeout' => 120, |
| 381 | ) |
| 382 | ); |
| 383 | if ( is_wp_error( $response ) ) { |
| 384 | $message = $response->get_error_message(); |
| 385 | if ( false !== strpos( $message, 'timed out' ) || false !== strpos( $message, 'cURL error 28' ) ) { |
| 386 | return blu_prepare_ability_response( 504, 'Image edit timed out' ); |
| 387 | } |
| 388 | return blu_prepare_ability_response( 502, 'Image edit service unavailable: ' . $message ); |
| 389 | } |
| 390 | $status_code = wp_remote_retrieve_response_code( $response ); |
| 391 | if ( $status_code < 200 || $status_code >= 300 ) { |
| 392 | $error_body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 393 | $error_msg = is_array( $error_body ) && ! empty( $error_body['message'] ) |
| 394 | ? (string) $error_body['message'] |
| 395 | : 'Image edit failed with status ' . $status_code; |
| 396 | return blu_prepare_ability_response( $status_code, $error_msg ); |
| 397 | } |
| 398 | $data = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 399 | if ( empty( $data['url'] ) ) { |
| 400 | return blu_prepare_ability_response( 500, 'No image URL in response' ); |
| 401 | } |
| 402 | // CDN URL only — no media library sideload here. |
| 403 | return blu_prepare_ability_response( 200, array( 'url' => $data['url'] ) ); |
| 404 | } |
| 405 | |
| 406 | /** |
| 407 | * Allow local site URLs, hiive.cloud CDN / platform images or unsplash.com images only (SSRF guard). |
| 408 | * |
| 409 | * @param string $url The URL to check. |
| 410 | * @return bool True if the URL is allowed, false otherwise. |
| 411 | */ |
| 412 | private function is_allowed_source_url( $url ): bool { |
| 413 | $parsed = wp_parse_url( $url ); |
| 414 | if ( empty( $parsed['host'] ) ) { |
| 415 | return false; |
| 416 | } |
| 417 | $host = strtolower( $parsed['host'] ); |
| 418 | $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); |
| 419 | |
| 420 | // local site URLs, hiive.cloud CDN / platform images (same domains onboarding cron sideloads) or unsplash.com images. |
| 421 | if ( $host === $site_host || preg_match( '/(^|\.)hiive\.cloud$/', $host ) || preg_match( '/(^|\.)unsplash\.com$/', $host ) ) { |
| 422 | return true; |
| 423 | } |
| 424 | return (bool) apply_filters( 'blu_mcp_allowed_image_edit_source_hosts', false, $host, $url ); |
| 425 | } |
| 426 | |
| 427 | |
| 428 | /** |
| 429 | * Fetch source image bytes for forwarding to Laravel (not saved to media library). |
| 430 | * |
| 431 | * @param string $url The URL of the image to fetch. |
| 432 | * |
| 433 | * @return array |
| 434 | */ |
| 435 | private function fetch_source_image( string $url ): array { |
| 436 | $allowed_mimes = array( |
| 437 | 'image/jpeg' => 'jpg', |
| 438 | 'image/png' => 'png', |
| 439 | 'image/webp' => 'webp', |
| 440 | ); |
| 441 | |
| 442 | // For local-site URLs, read directly from the filesystem to avoid loopback |
| 443 | // HTTP issues (SSL certificate problems, blocked requests on local dev envs). |
| 444 | $url_host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); |
| 445 | $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); |
| 446 | if ( $url_host === $site_host ) { |
| 447 | $url_path = wp_parse_url( $url, PHP_URL_PATH ); |
| 448 | if ( ! is_string( $url_path ) ) { |
| 449 | return array( |
| 450 | 'status' => 400, |
| 451 | 'error' => __( 'Invalid local URL path.', 'wp-module-mcp' ), |
| 452 | ); |
| 453 | } |
| 454 | $abs_root = realpath( ABSPATH ); |
| 455 | $abs_file = realpath( $abs_root . '/' . ltrim( $url_path, '/' ) ); |
| 456 | if ( false === $abs_file || strpos( $abs_file, $abs_root ) !== 0 ) { |
| 457 | return array( |
| 458 | 'status' => 400, |
| 459 | 'error' => __( 'Local file path is outside the WordPress root.', 'wp-module-mcp' ), |
| 460 | ); |
| 461 | } |
| 462 | if ( ! is_file( $abs_file ) ) { |
| 463 | return array( |
| 464 | 'status' => 404, |
| 465 | 'error' => __( 'Local source image not found.', 'wp-module-mcp' ), |
| 466 | ); |
| 467 | } |
| 468 | $content = file_get_contents( $abs_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions |
| 469 | if ( false === $content || '' === $content ) { |
| 470 | return array( |
| 471 | 'status' => 400, |
| 472 | 'error' => __( 'Could not read local source image.', 'wp-module-mcp' ), |
| 473 | ); |
| 474 | } |
| 475 | $max_bytes = 10 * 1024 * 1024; |
| 476 | if ( strlen( $content ) > $max_bytes ) { |
| 477 | return array( |
| 478 | 'status' => 400, |
| 479 | 'error' => __( 'Source image exceeds 10 MB limit.', 'wp-module-mcp' ), |
| 480 | ); |
| 481 | } |
| 482 | $mime = is_callable( 'mime_content_type' ) ? strtolower( mime_content_type( $abs_file ) ) : ''; |
| 483 | if ( ! isset( $allowed_mimes[ $mime ] ) ) { |
| 484 | return array( |
| 485 | 'status' => 400, |
| 486 | 'error' => __( 'Unsupported source image type.', 'wp-module-mcp' ), |
| 487 | ); |
| 488 | } |
| 489 | return array( |
| 490 | 'filename' => basename( $abs_file ), |
| 491 | 'content' => $content, |
| 492 | 'mime' => $mime, |
| 493 | ); |
| 494 | } |
| 495 | |
| 496 | // Remote URL — fetch via HTTP. |
| 497 | $response = wp_remote_get( |
| 498 | $url, |
| 499 | array( |
| 500 | 'timeout' => 30, |
| 501 | 'redirection' => 3, |
| 502 | ) |
| 503 | ); |
| 504 | if ( is_wp_error( $response ) ) { |
| 505 | return array( |
| 506 | 'status' => 502, |
| 507 | /* translators: %s: error message from wp_remote_get */ |
| 508 | 'error' => sprintf( __( 'Unable to fetch source image: %s', 'wp-module-mcp' ), $response->get_error_message() ), |
| 509 | ); |
| 510 | } |
| 511 | $status_code = wp_remote_retrieve_response_code( $response ); |
| 512 | if ( $status_code < 200 || $status_code >= 300 ) { |
| 513 | return array( |
| 514 | 'status' => 400, |
| 515 | /* translators: %d: HTTP status code */ |
| 516 | 'error' => sprintf( __( 'Unable to fetch source image (HTTP %d).', 'wp-module-mcp' ), $status_code ), |
| 517 | ); |
| 518 | } |
| 519 | $content = wp_remote_retrieve_body( $response ); |
| 520 | if ( '' === $content ) { |
| 521 | return array( |
| 522 | 'status' => 400, |
| 523 | 'error' => __( 'Source image is empty.', 'wp-module-mcp' ), |
| 524 | ); |
| 525 | } |
| 526 | $max_bytes = 10 * 1024 * 1024; |
| 527 | if ( strlen( $content ) > $max_bytes ) { |
| 528 | return array( |
| 529 | 'status' => 400, |
| 530 | 'error' => __( 'Source image exceeds 10 MB limit.', 'wp-module-mcp' ), |
| 531 | ); |
| 532 | } |
| 533 | $content_type = wp_remote_retrieve_header( $response, 'content-type' ); |
| 534 | $mime = is_string( $content_type ) ? strtolower( trim( explode( ';', $content_type )[0] ) ) : ''; |
| 535 | if ( ! isset( $allowed_mimes[ $mime ] ) ) { |
| 536 | return array( |
| 537 | 'status' => 400, |
| 538 | 'error' => __( 'Unsupported source image type.', 'wp-module-mcp' ), |
| 539 | ); |
| 540 | } |
| 541 | $path = wp_parse_url( $url, PHP_URL_PATH ); |
| 542 | $basename = is_string( $path ) ? basename( $path ) : ''; |
| 543 | $filename = '' !== $basename ? $basename : 'source.' . $allowed_mimes[ $mime ]; |
| 544 | return array( |
| 545 | 'filename' => $filename, |
| 546 | 'content' => $content, |
| 547 | 'mime' => $mime, |
| 548 | ); |
| 549 | } |
| 550 | |
| 551 | /** |
| 552 | * Build a multipart/form-data body for wp_remote_post. |
| 553 | * |
| 554 | * @param array $fields Fields to include in the multipart body. |
| 555 | * @param array $files Files to include in the multipart body. |
| 556 | * |
| 557 | * @return array |
| 558 | */ |
| 559 | private function build_multipart_body( array $fields, array $files ): array { |
| 560 | $boundary = '----BluMcpFormBoundary' . wp_generate_password( 16, false ); |
| 561 | $body = ''; |
| 562 | foreach ( $fields as $name => $value ) { |
| 563 | $body .= '--' . $boundary . "\r\n"; |
| 564 | $body .= 'Content-Disposition: form-data; name="' . $name . '"' . "\r\n\r\n"; |
| 565 | $body .= $value . "\r\n"; |
| 566 | } |
| 567 | foreach ( $files as $file ) { |
| 568 | $body .= '--' . $boundary . "\r\n"; |
| 569 | $body .= 'Content-Disposition: form-data; name="' . $file['field'] . '"; filename="' . $file['filename'] . '"' . "\r\n"; |
| 570 | $body .= 'Content-Type: ' . $file['mime'] . "\r\n\r\n"; |
| 571 | $body .= $file['content'] . "\r\n"; |
| 572 | } |
| 573 | $body .= '--' . $boundary . '--' . "\r\n"; |
| 574 | return array( |
| 575 | 'boundary' => $boundary, |
| 576 | 'body' => $body, |
| 577 | ); |
| 578 | } |
| 579 | } |