| 1 |
<?php |
| 2 |
/** Progress and raster uploads from a paired external agent. */ |
| 3 |
if ( ! defined( 'ABSPATH' ) ) { exit; } |
| 4 |
|
| 5 |
/** Start at actual agent work, not while waiting for pairing. Finish only once. */ |
| 6 |
function bkbg_external_run_clock( &$s, $finish = false ) { |
| 7 |
if ( $finish ) { |
| 8 |
if ( ! empty( $s['run'] ) && empty( $s['run']['finished_at'] ) ) { $s['run']['finished_at'] = microtime( true ); } |
| 9 |
} elseif ( empty( $s['run'] ) || ! empty( $s['run']['finished_at'] ) ) { |
| 10 |
$s['run'] = array( 'id' => wp_generate_uuid4(), 'started_at' => microtime( true ), 'finished_at' => null ); |
| 11 |
} |
| 12 |
} |
| 13 |
function bkbg_external_progress( $request ) { |
| 14 |
$phase = $request['phase']; $message = $request['message']; $plan = $request['plan']; |
| 15 |
if ( strlen( $request->get_body() ) > 16000 || ! in_array( $phase, array( 'planning', 'building', 'generating_image', 'reviewing', 'waiting', 'done', 'error' ), true ) || ! is_string( $message ) || mb_strlen( $message ) > 500 ) { |
| 16 |
return bkbg_external_error( 'Supply a supported phase and a message of up to 500 characters.' ); |
| 17 |
} |
| 18 |
$clean = array(); |
| 19 |
if ( null !== $plan ) { |
| 20 |
if ( ! is_array( $plan ) || count( $plan ) > 12 ) { return bkbg_external_error( 'A plan can have up to 12 steps.' ); } |
| 21 |
foreach ( $plan as $step ) { |
| 22 |
if ( ! is_array( $step ) || ! is_string( $step['id'] ?? null ) || ! preg_match( '/^[a-zA-Z0-9_-]{1,60}$/D', $step['id'] ) || isset( $clean[ $step['id'] ] ) || ! is_string( $step['label'] ?? null ) || mb_strlen( $step['label'] ) > 160 || ! in_array( $step['status'] ?? '', array( 'pending', 'active', 'done' ), true ) ) { |
| 23 |
return bkbg_external_error( 'Plan steps need unique ids, short labels and pending, active or done status.' ); |
| 24 |
} |
| 25 |
$clean[ $step['id'] ] = array( 'id' => $step['id'], 'label' => sanitize_text_field( $step['label'] ), 'status' => $step['status'] ); |
| 26 |
} |
| 27 |
} |
| 28 |
return bkbg_external_change( $request['session'], function ( &$s ) use ( $phase, $message, $plan, $clean ) { |
| 29 |
if ( 'done' === $phase ) { |
| 30 |
foreach ( $s['messages'] ?? array() as $message ) { |
| 31 |
if ( 'panel' === $message['source'] && 'queued' === $message['status'] ) { return bkbg_external_error( 'New user messages are waiting. Read /inbox and acknowledge them before finishing.', 409 ); } |
| 32 |
} |
| 33 |
$current_capture = empty( $s['captures'] ); |
| 34 |
foreach ( $s['captures'] ?? array() as $capture ) { |
| 35 |
if ( $capture['revision'] === ( $s['visual_revision'] ?? 0 ) ) { $current_capture = true; } |
| 36 |
if ( 'passed' !== $capture['reviewed'] ) { return bkbg_external_error( 'Capture the finished section again and review its current screenshots before marking done.', 409 ); } |
| 37 |
} |
| 38 |
} |
| 39 |
if ( 'done' === $phase && !$current_capture ) { return bkbg_external_error( 'The page changed after its screenshots. Capture and review the updated section.', 409 ); } |
| 40 |
bkbg_external_run_clock( $s, in_array( $phase, array( 'done', 'error' ), true ) ); |
| 41 |
$s['agent_seen'] = time(); |
| 42 |
$s['progress'] = array( 'phase' => $phase, 'message' => sanitize_text_field( $message ), 'updated' => time(), 'plan' => null === $plan ? ( $s['progress']['plan'] ?? array() ) : array_values( $clean ) ); |
| 43 |
return array( 'ok' => true ); |
| 44 |
} ); |
| 45 |
} |
| 46 |
|
| 47 |
function bkbg_external_image_result( $request ) { |
| 48 |
if ( ! is_string( $request['upload'] ) || ! preg_match( '/^[a-zA-Z0-9_-]{8,80}$/D', $request['upload'] ) ) { return bkbg_external_error( 'Invalid upload id.' ); } |
| 49 |
$s = bkbg_external_read( $request['session'] ); |
| 50 |
if ( ! $s ) { return bkbg_external_error( 'Session expired.', 410 ); } |
| 51 |
$item = $s['images'][ $request['upload'] ] ?? null; |
| 52 |
if ( ! $item ) { return bkbg_external_error( 'Unknown upload.', 404 ); } |
| 53 |
return array_intersect_key( $item, array_flip( array( 'status', 'image', 'error' ) ) ); |
| 54 |
} |
| 55 |
|
| 56 |
function bkbg_external_upload_image( $request ) { |
| 57 |
if ( ! is_string( $request['upload'] ) || ! preg_match( '/^[a-zA-Z0-9_-]{8,80}$/D', $request['upload'] ) ) { return bkbg_external_error( 'Invalid upload id.' ); } |
| 58 |
$s = bkbg_external_read( $request['session'] ); |
| 59 |
if ( ! $s ) { return bkbg_external_error( 'Session expired.', 410 ); } |
| 60 |
if ( ! user_can( $s['user'], 'upload_files' ) ) { return bkbg_external_error( 'This user cannot upload images.', 403 ); } |
| 61 |
$bytes = $request->get_body(); |
| 62 |
if ( ! strlen( $bytes ) || strlen( $bytes ) > min( 20 * MB_IN_BYTES, wp_max_upload_size() ) ) { return bkbg_external_error( 'Image exceeds the upload limit or is empty.', 413 ); } |
| 63 |
$info = @getimagesizefromstring( $bytes ); |
| 64 |
$extensions = array( 'image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp' ); |
| 65 |
if ( ! $info || ! isset( $extensions[ $info['mime'] ] ) || $request->get_header( 'content-type' ) !== $info['mime'] || $info[0] * $info[1] > 40000000 ) { |
| 66 |
return bkbg_external_error( 'Upload a valid PNG, JPEG or WebP image up to 40 megapixels, with its matching Content-Type.' ); |
| 67 |
} |
| 68 |
$meta = array(); |
| 69 |
foreach ( array( 'filename' => 200, 'title' => 200, 'alt' => 1000 ) as $key => $limit ) { |
| 70 |
$value = $request[ $key ] ?? ''; |
| 71 |
if ( ! is_string( $value ) || mb_strlen( $value ) > $limit ) { return bkbg_external_error( 'Invalid image metadata.' ); } |
| 72 |
$meta[ $key ] = sanitize_text_field( $value ); |
| 73 |
} |
| 74 |
$id = $request['upload']; |
| 75 |
$fingerprint = hash( 'sha256', $bytes . wp_json_encode( $meta ) ); |
| 76 |
// Reserve before writing any file. A retry cannot create a second attachment. |
| 77 |
$reservation = bkbg_external_change( $request['session'], function ( &$current ) use ( $id, $fingerprint ) { |
| 78 |
$images = $current['images'] ?? array(); |
| 79 |
if ( isset( $images[ $id ] ) ) { |
| 80 |
return $images[ $id ]['fingerprint'] === $fingerprint ? array( 'existing' => true ) : bkbg_external_error( 'This upload id belongs to another image.', 409 ); |
| 81 |
} |
| 82 |
if ( count( $images ) >= 40 ) { return bkbg_external_error( 'Session image capacity reached.', 409 ); } |
| 83 |
foreach ( $images as $image ) { |
| 84 |
if ( 'uploading' === $image['status'] ) { return bkbg_external_error( 'Wait for the previous image upload.', 409 ); } |
| 85 |
} |
| 86 |
if ( time() - $current['heartbeat'] > 15 ) { return bkbg_external_error( 'The editor is offline. Open the AI sidebar.', 409 ); } |
| 87 |
bkbg_external_run_clock( $current ); |
| 88 |
$current['agent_seen'] = time(); |
| 89 |
$current['images'][ $id ] = array( 'fingerprint' => $fingerprint, 'status' => 'uploading' ); |
| 90 |
return array( 'existing' => false ); |
| 91 |
} ); |
| 92 |
if ( is_wp_error( $reservation ) ) { return $reservation; } |
| 93 |
if ( $reservation['existing'] ) { return bkbg_external_image_result( $request ); } |
| 94 |
|
| 95 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 96 |
require_once ABSPATH . 'wp-admin/includes/media.php'; |
| 97 |
require_once ABSPATH . 'wp-admin/includes/image.php'; |
| 98 |
$filename = sanitize_file_name( pathinfo( $meta['filename'] ?: 'generated-image', PATHINFO_FILENAME ) ) . '.' . $extensions[ $info['mime'] ]; |
| 99 |
$temp = wp_tempnam( $filename ); |
| 100 |
$attachment = 0; |
| 101 |
$previous_user = get_current_user_id(); |
| 102 |
try { |
| 103 |
if ( ! $temp || file_put_contents( $temp, $bytes ) !== strlen( $bytes ) ) { throw new RuntimeException( 'Could not write the image upload.' ); } |
| 104 |
wp_set_current_user( $s['user'] ); |
| 105 |
$attachment = media_handle_sideload( array( 'name' => $filename, 'tmp_name' => $temp, 'error' => 0, 'size' => strlen( $bytes ) ), $s['post'], $meta['title'] ?: 'Generated image' ); |
| 106 |
if ( is_wp_error( $attachment ) ) { throw new RuntimeException( $attachment->get_error_message() ); } |
| 107 |
update_post_meta( $attachment, '_wp_attachment_image_alt', $meta['alt'] ); |
| 108 |
update_post_meta( $attachment, '_bkbg_ai_generated', true ); |
| 109 |
update_post_meta( $attachment, '_bkbg_ai_external_agent', $s['agent'] ); |
| 110 |
$size = wp_get_attachment_metadata( $attachment ); |
| 111 |
$image = array( 'id' => $attachment, 'url' => wp_get_attachment_url( $attachment ), 'alt' => $meta['alt'], 'width' => $size['width'] ?? $info[0], 'height' => $size['height'] ?? $info[1], 'mime' => get_post_mime_type( $attachment ) ); |
| 112 |
$saved = bkbg_external_change( $request['session'], function ( &$current ) use ( $id, $image ) { |
| 113 |
$current['images'][ $id ]['status'] = 'done'; |
| 114 |
$current['images'][ $id ]['image'] = $image; |
| 115 |
$current['agent_seen'] = time(); |
| 116 |
return array( 'status' => 'done', 'image' => $image ); |
| 117 |
} ); |
| 118 |
if ( is_wp_error( $saved ) ) { throw new RuntimeException( $saved->get_error_message() ); } |
| 119 |
return $saved; |
| 120 |
} catch ( Throwable $error ) { |
| 121 |
if ( is_int( $attachment ) && $attachment > 0 ) { wp_delete_attachment( $attachment, true ); } |
| 122 |
bkbg_external_change( $request['session'], function ( &$current ) use ( $id, $error ) { |
| 123 |
$current['images'][ $id ]['status'] = 'error'; |
| 124 |
$current['images'][ $id ]['error'] = $error->getMessage(); |
| 125 |
return true; |
| 126 |
} ); |
| 127 |
return bkbg_external_error( $error->getMessage(), 500 ); |
| 128 |
} finally { |
| 129 |
wp_set_current_user( $previous_user ); |
| 130 |
if ( $temp && file_exists( $temp ) ) { wp_delete_file( $temp ); } |
| 131 |
} |
| 132 |
} |
| 133 |
|