*/ public function capabilities(): array { $profile = $this->imageProfile(); return [ 'protocol' => self::PROTOCOL, 'path' => '/wp-json/crocoai-data-bridge/v1/staged/assets/v2', 'accepted_mime_types' => ['image/jpeg'], 'image_profile' => $profile, 'limits' => [ 'max_items' => self::MAX_ITEMS, 'max_file_bytes' => self::MAX_FILE_BYTES, 'max_batch_bytes' => self::MAX_BATCH_BYTES, ], 'semantics' => [ 'key_algorithm' => 'sha256(esc_url_raw(source_url))', 'product_images_keep_source_url' => true, 'complete_rendition_set_required' => true, 'server_generated_filenames' => true, 'wordpress_resize_skipped' => true, 'fallback_path' => '/wp-json/crocoai-data-bridge/v1/staged/assets', ], ]; } /** @return array{hash:string,sizes:array}>} */ public function imageProfile(): array { $sizes = []; foreach (wp_get_registered_image_subsizes() as $name => $definition) { $crop = $definition['crop'] ?? false; if (is_array($crop)) { $crop = array_values(array_map('strval', $crop)); } else { $crop = (bool) $crop; } $sizes[sanitize_key((string) $name)] = [ 'width' => max(0, (int) ($definition['width'] ?? 0)), 'height' => max(0, (int) ($definition['height'] ?? 0)), 'crop' => $crop, ]; } ksort($sizes); return [ 'hash' => hash('sha256', (string) wp_json_encode($sizes)), 'sizes' => $sizes, ]; } /** @return array{attachments:array,image_profile_hash:string} */ public function upload(array $payload): array { $this->assertExactKeys($payload, ['image_profile_hash', 'items'], 'Prepared asset request'); $profile = $this->imageProfile(); $requestedProfile = strtolower(trim((string) ($payload['image_profile_hash'] ?? ''))); if (!preg_match('/^[a-f0-9]{64}$/', $requestedProfile) || !hash_equals($profile['hash'], $requestedProfile)) { throw new RuntimeException('The staged asset image profile is stale; refresh capabilities and regenerate renditions.'); } $items = $payload['items'] ?? null; if (!is_array($items) || !array_is_list($items) || count($items) < 1 || count($items) > self::MAX_ITEMS) { throw new RuntimeException('A prepared asset batch must contain between 1 and 10 images.'); } $uploaded = []; $created = []; $batchBytes = 0; try { foreach ($items as $item) { if (!is_array($item)) { throw new RuntimeException('Each prepared asset must be an object.'); } $result = $this->uploadOne($item, $profile); $key = $result['key']; $attachmentId = $result['attachment_id']; $uploaded[$key] = $attachmentId; if ($result['created']) { $created[] = $result; } $batchBytes += $result['bytes']; if ($batchBytes > self::MAX_BATCH_BYTES) { throw new RuntimeException('Prepared asset batch exceeds the 25 MB decoded limit.'); } } } catch (Throwable $error) { global $wpdb; foreach (array_reverse($created) as $result) { wp_delete_attachment($result['attachment_id'], true); if (is_array($result['previous'])) { $wpdb->replace(Database::table('media_assets'), $result['previous']); } else { $wpdb->delete(Database::table('media_assets'), ['url_hash' => $result['key']]); } } throw $error; } return ['attachments' => $uploaded, 'image_profile_hash' => $profile['hash']]; } /** * @param array{hash:string,sizes:array}>} $profile * @return array{key:string,attachment_id:int,created:bool,bytes:int,previous:?array} */ private function uploadOne(array $item, array $profile): array { $this->assertExactKeys($item, ['key', 'content_hash', 'content', 'renditions'], 'Prepared asset'); $key = strtolower(trim((string) ($item['key'] ?? ''))); if (!preg_match('/^[a-f0-9]{64}$/', $key)) { throw new RuntimeException('Prepared asset key must be a SHA-256 hash.'); } global $wpdb; $assetTable = Database::table('media_assets'); $previous = $wpdb->get_row($wpdb->prepare( 'SELECT url_hash, source_url, attachment_id, created_at FROM %i WHERE url_hash=%s', $assetTable, $key ), ARRAY_A); $existing = is_array($previous) ? (int) ($previous['attachment_id'] ?? 0) : 0; if ($this->isReusable($existing, $profile)) { return [ 'key' => $key, 'attachment_id' => $existing, 'created' => false, 'bytes' => 0, 'previous' => null, ]; } $contentHash = $this->hash($item['content_hash'] ?? null, 'Prepared original'); $binary = $this->binary($item['content'] ?? null, $contentHash, 'Prepared original'); [$width, $height] = $this->jpegDimensions($binary, 'Prepared original'); $expected = $this->expectedRenditions($width, $height, $profile['sizes']); $renditions = $item['renditions'] ?? null; if (!is_array($renditions) || ($renditions !== [] && array_is_list($renditions))) { throw new RuntimeException('Prepared renditions must be an object keyed by registered size name.'); } $providedNames = array_values(array_map('strval', array_keys($renditions))); sort($providedNames); $expectedNames = array_keys($expected); sort($expectedNames); if ($providedNames !== $expectedNames) { throw new RuntimeException('Prepared renditions do not exactly match the current WordPress image profile.'); } $decodedRenditions = []; $totalBytes = strlen($binary); foreach ($expected as $name => $dimensions) { $rendition = $renditions[$name] ?? null; if (!is_array($rendition)) { throw new RuntimeException('Prepared rendition ' . $name . ' is missing.'); } $this->assertExactKeys( $rendition, ['content_hash', 'content'], 'Prepared rendition ' . $name ); $hash = $this->hash($rendition['content_hash'] ?? null, 'Prepared rendition ' . $name); $bytes = $this->binary($rendition['content'] ?? null, $hash, 'Prepared rendition ' . $name); [$actualWidth, $actualHeight] = $this->jpegDimensions($bytes, 'Prepared rendition ' . $name); if ($actualWidth !== $dimensions['width'] || $actualHeight !== $dimensions['height']) { throw new RuntimeException(sprintf( 'Prepared rendition %s must be exactly %dx%d pixels.', $name, $dimensions['width'], $dimensions['height'] )); } $decodedRenditions[$name] = [ 'content' => $bytes, 'hash' => $hash, 'width' => $actualWidth, 'height' => $actualHeight, ]; $totalBytes += strlen($bytes); if ($totalBytes > self::MAX_BATCH_BYTES) { throw new RuntimeException('One prepared asset exceeds the 25 MB decoded batch limit.'); } } return $this->persist( $key, $contentHash, $binary, $width, $height, $decodedRenditions, $profile['hash'], $totalBytes, is_array($previous) ? $previous : null ); } /** @return array{key:string,attachment_id:int,created:bool,bytes:int,previous:?array} */ private function persist( string $key, string $contentHash, string $binary, int $width, int $height, array $renditions, string $profileHash, int $totalBytes, ?array $previous ): array { $written = []; $attachmentId = 0; try { $original = wp_upload_bits('crocoai-dbridge-' . $contentHash . '.jpg', null, $binary); if (!empty($original['error'])) { throw new RuntimeException(sanitize_text_field((string) $original['error'])); } $written[] = (string) $original['file']; $metadataSizes = []; foreach ($renditions as $name => $rendition) { $filename = sprintf( 'crocoai-dbridge-%s-%dx%d-%s.jpg', $contentHash, (int) $rendition['width'], (int) $rendition['height'], substr(hash('sha256', (string) $name), 0, 8) ); $saved = wp_upload_bits($filename, null, (string) $rendition['content']); if (!empty($saved['error'])) { throw new RuntimeException(sanitize_text_field((string) $saved['error'])); } if (dirname((string) $saved['file']) !== dirname((string) $original['file'])) { wp_delete_file((string) $saved['file']); throw new RuntimeException('Prepared rendition was written outside the original upload directory.'); } $written[] = (string) $saved['file']; $metadataSizes[$name] = [ 'file' => basename((string) $saved['file']), 'width' => (int) $rendition['width'], 'height' => (int) $rendition['height'], 'mime-type' => 'image/jpeg', 'filesize' => strlen((string) $rendition['content']), ]; } $attachmentId = wp_insert_attachment([ 'post_mime_type' => 'image/jpeg', 'post_title' => sanitize_text_field(pathinfo((string) $original['file'], PATHINFO_FILENAME)), 'post_status' => 'inherit', 'guid' => (string) $original['url'], ], (string) $original['file']); if (is_wp_error($attachmentId)) { throw new RuntimeException(sanitize_text_field($attachmentId->get_error_message())); } $attachmentId = (int) $attachmentId; update_attached_file($attachmentId, (string) $original['file']); $attachmentMetadata = [ 'width' => $width, 'height' => $height, 'file' => _wp_relative_upload_path((string) $original['file']), 'filesize' => strlen($binary), 'sizes' => $metadataSizes, 'image_meta' => [], ]; wp_update_attachment_metadata($attachmentId, $attachmentMetadata); if (wp_get_attachment_metadata($attachmentId) !== $attachmentMetadata) { throw new RuntimeException('Prepared attachment metadata could not be persisted.'); } update_post_meta($attachmentId, '_crocoai_dbridge_asset_hash', $contentHash); update_post_meta($attachmentId, '_crocoai_dbridge_image_profile_hash', $profileHash); global $wpdb; $replaced = $wpdb->replace(Database::table('media_assets'), [ 'url_hash' => $key, 'source_url' => 'staged-v2:' . $contentHash, 'attachment_id' => $attachmentId, 'created_at' => current_time('mysql', true), ]); if ($replaced === false) { throw new RuntimeException('Prepared attachment cache record could not be persisted.'); } return [ 'key' => $key, 'attachment_id' => $attachmentId, 'created' => true, 'bytes' => $totalBytes, 'previous' => $previous, ]; } catch (Throwable $error) { if ($attachmentId > 0) { wp_delete_attachment($attachmentId, true); } foreach ($written as $file) { if (is_file($file)) { wp_delete_file($file); } } throw $error; } } /** @param array{hash:string,sizes:array}>} $profile */ private function isReusable(int $attachmentId, array $profile): bool { if ($attachmentId < 1 || get_post_type($attachmentId) !== 'attachment') { return false; } if (!hash_equals($profile['hash'], \CrocoAI\DataBridge\LegacyKeys::postMeta($attachmentId, 'image_profile_hash'))) { return false; } $file = get_attached_file($attachmentId); $metadata = wp_get_attachment_metadata($attachmentId); if (!is_string($file) || !is_file($file) || !is_array($metadata)) { return false; } $expected = $this->expectedRenditions( (int) ($metadata['width'] ?? 0), (int) ($metadata['height'] ?? 0), $profile['sizes'] ); $sizes = is_array($metadata['sizes'] ?? null) ? $metadata['sizes'] : []; $expectedNames = array_keys($expected); $actualNames = array_keys($sizes); sort($expectedNames); sort($actualNames); if ($expectedNames !== $actualNames) { return false; } foreach ($expected as $name => $dimensions) { $size = $sizes[$name] ?? null; if ( !is_array($size) || (int) ($size['width'] ?? 0) !== $dimensions['width'] || (int) ($size['height'] ?? 0) !== $dimensions['height'] || !is_file(dirname($file) . '/' . (string) ($size['file'] ?? '')) ) { return false; } } return true; } private function hash(mixed $value, string $label): string { $hash = strtolower(trim(is_string($value) ? $value : '')); if (!preg_match('/^[a-f0-9]{64}$/', $hash)) { throw new RuntimeException($label . ' content_hash must be a SHA-256 hash.'); } return $hash; } /** @param list $expected */ private function assertExactKeys(array $value, array $expected, string $label): void { $actual = array_values(array_map('strval', array_keys($value))); sort($actual); sort($expected); if ($actual !== $expected) { throw new RuntimeException($label . ' contains missing or unknown fields.'); } } private function binary(mixed $value, string $expectedHash, string $label): string { $binary = is_string($value) ? base64_decode($value, true) : false; if ($binary === false || strlen($binary) < 32 || strlen($binary) > self::MAX_FILE_BYTES) { throw new RuntimeException($label . ' must contain between 32 bytes and 5 MB of base64 data.'); } if (!hash_equals($expectedHash, hash('sha256', $binary))) { throw new RuntimeException($label . ' failed checksum validation.'); } return $binary; } /** @return array{int,int} */ private function jpegDimensions(string $binary, string $label): array { $size = @getimagesizefromstring($binary); if (!is_array($size) || $size['mime'] !== 'image/jpeg') { throw new RuntimeException($label . ' must be a valid JPEG image.'); } $width = (int) $size[0]; $height = (int) $size[1]; if ($width < 1 || $height < 1 || $width * $height > 100_000_000) { throw new RuntimeException($label . ' has unsafe image dimensions.'); } return [$width, $height]; } /** * @param array}> $sizes * @return array */ private function expectedRenditions(int $width, int $height, array $sizes): array { $expected = []; foreach ($sizes as $name => $definition) { $dimensions = image_resize_dimensions( $width, $height, $definition['width'], $definition['height'], $definition['crop'] ); if ($dimensions === false) { continue; } $expected[$name] = ['width' => (int) $dimensions[4], 'height' => (int) $dimensions[5]]; } return $expected; } }