| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 3 declare (strict_types=1); 4 namespace WordPress\AiClient\Files\DTO; 5 6 use WordPress\AiClient\Common\AbstractDataTransferObject; 7 use WordPress\AiClient\Common\Exception\InvalidArgumentException; 8 use WordPress\AiClient\Common\Exception\RuntimeException; 9 use WordPress\AiClient\Files\Enums\FileTypeEnum; 10 use WordPress\AiClient\Files\ValueObjects\MimeType; 11 /** 12 * Represents a file in the AI client. 13 * 14 * A file is either remote (an http(s) URL that the provider fetches) or inline (base64-encoded data). 15 * 16 * The constructor accepts an http(s) URL, a data URI, or plain base64 data with a MIME type. For backward 17 * compatibility it still detects an existing local file path and reads it, but this is deprecated and will be 18 * removed in the next major version: load local files explicitly via {@see File::fromLocalPath()} instead. 19 * The named constructors {@see File::fromUrl()}, {@see File::fromDataUri()} and {@see File::fromBase64()} 20 * never access the filesystem and should be preferred whenever the kind of input is known, especially for 21 * data that comes from an untrusted source such as an API response or user input. {@see File::fromArray()} 22 * never accesses the filesystem. 23 * 24 * @since 0.1.0 25 * @since 1.5.1 Implicit local file path detection in the constructor is deprecated; non-http(s) URL schemes 26 * are rejected before any filesystem access. 27 * 28 * @phpstan-type FileArrayShape array{ 29 * fileType: string, 30 * url?: string, 31 * mimeType: string, 32 * base64Data?: string 33 * } 34 * 35 * @extends AbstractDataTransferObject<FileArrayShape> 36 */ 37 class File extends AbstractDataTransferObject 38 { 39 public const KEY_FILE_TYPE = 'fileType'; 40 public const KEY_MIME_TYPE = 'mimeType'; 41 public const KEY_URL = 'url'; 42 public const KEY_BASE64_DATA = 'base64Data'; 43 /** 44 * Pattern matching a data URI with base64 payload. 45 * 46 * Group 1 is the optional MIME type (with parameters), group 2 is the base64 payload. 47 * 48 * @since 1.5.1 49 */ 50 private const DATA_URI_PATTERN = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})\z/'; 51 /** 52 * Pattern matching plain base64 data (an empty payload is allowed, e.g. for an empty file). 53 * 54 * @since 1.5.1 55 */ 56 private const BASE64_PATTERN = '/^[A-Za-z0-9+\/]*={0,2}\z/'; 57 /** 58 * @var MimeType The MIME type of the file. 59 */ 60 private MimeType $mimeType; 61 /** 62 * @var FileTypeEnum The type of file storage. 63 */ 64 private FileTypeEnum $fileType; 65 /** 66 * @var string|null The URL for remote files. 67 */ 68 private ?string $url = null; 69 /** 70 * @var string|null The base64 data for inline files. 71 */ 72 private ?string $base64Data = null; 73 /** 74 * Constructor. 75 * 76 * Passing a local file path is deprecated; use {@see File::fromLocalPath()} instead. When a local path is 77 * detected, an E_USER_DEPRECATED notice is emitted and the file is read. 78 * 79 * @since 0.1.0 80 * @since 1.5.1 Local file path detection is deprecated. Non-http(s) URL schemes are rejected. 81 * 82 * @param string $file The file string (http(s) URL, data URI, plain base64 data, or deprecated local path). 83 * @param string|null $mimeType The MIME type of the file (optional, required for plain base64 data). 84 * @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined. 85 */ 86 public function __construct(string $file, ?string $mimeType = null) 87 { 88 $this->detectAndProcessFile($file, $mimeType); 89 } 90 /** 91 * Creates a remote file from an http(s) URL. 92 * 93 * The URL is not fetched by the client; it is passed on to the provider. 94 * 95 * @since 1.5.1 96 * 97 * @param string $url The http(s) URL. 98 * @param string|null $mimeType The MIME type (optional, otherwise inferred from the URL path extension). 99 * @return self The remote file. 100 * @throws InvalidArgumentException If the URL is not an http(s) URL or the MIME type cannot be determined. 101 */ 102 public static function fromUrl(string $url, ?string $mimeType = null): self 103 { 104 if (!self::isHttpUrl($url)) { 105 throw new InvalidArgumentException('Invalid URL. Only http:// and https:// URLs are supported.'); 106 } 107 return new self($url, $mimeType); 108 } 109 /** 110 * Creates an inline file from a data URI (data:[mime/type];base64,[data]). 111 * 112 * @since 1.5.1 113 * 114 * @param string $dataUri The data URI. 115 * @param string|null $mimeType The MIME type (optional, overrides the MIME type embedded in the data URI). 116 * @return self The inline file. 117 * @throws InvalidArgumentException If the data URI is invalid or the MIME type cannot be determined. 118 */ 119 public static function fromDataUri(string $dataUri, ?string $mimeType = null): self 120 { 121 if (!preg_match(self::DATA_URI_PATTERN, $dataUri)) { 122 throw new InvalidArgumentException('Invalid data URI. Expected the format data:[mime/type];base64,[data].'); 123 } 124 return new self($dataUri, $mimeType); 125 } 126 /** 127 * Creates an inline file from plain base64-encoded data. 128 * 129 * @since 1.5.1 130 * 131 * @param string $base64 The base64-encoded data (without data URI prefix). 132 * @param string $mimeType The MIME type of the data. 133 * @return self The inline file. 134 * @throws InvalidArgumentException If the data is not valid base64 or the MIME type is invalid. 135 */ 136 public static function fromBase64(string $base64, string $mimeType): self 137 { 138 if (str_starts_with($base64, 'data:')) { 139 throw new InvalidArgumentException('Expected raw base64 data; use File::fromDataUri() for data URIs.'); 140 } 141 if (!self::isBase64($base64)) { 142 throw new InvalidArgumentException('Invalid base64 data provided.'); 143 } 144 if (!MimeType::isValid($mimeType)) { 145 throw new InvalidArgumentException(sprintf('Invalid MIME type: %s', $mimeType)); 146 } 147 /* 148 * Build an empty inline file through the data URI path (deterministic, filesystem-free), then attach the 149 * payload directly. Embedding the payload in a data URI and parsing it again would create several 150 * additional copies of a potentially large string. 151 */ 152 $instance = new self(sprintf('data:%s;base64,', $mimeType)); 153 $instance->base64Data = $base64; 154 return $instance; 155 } 156 /** 157 * Creates an inline file by reading a file from the local filesystem. 158 * 159 * Never pass untrusted input (such as user input or API response data) to this method. 160 * 161 * @since 1.5.1 162 * 163 * @param string $path The path to the local file. Stream wrappers (e.g. ftp://, phar://) are not supported. 164 * @param string|null $mimeType The MIME type (optional, otherwise inferred from the file extension). 165 * @return self The inline file. 166 * @throws InvalidArgumentException If the path is empty, uses a stream wrapper, is not a regular file, 167 * or the MIME type cannot be determined. 168 * @throws RuntimeException If the file cannot be read. 169 */ 170 public static function fromLocalPath(string $path, ?string $mimeType = null): self 171 { 172 if ($path === '') { 173 throw new InvalidArgumentException('Local file path must not be empty.'); 174 } 175 // Reject stream wrappers before touching the filesystem, so no wrapper can open a connection. 176 if (str_contains($path, '://')) { 177 throw new InvalidArgumentException('Stream wrappers are not supported in local file paths.'); 178 } 179 if (!is_file($path)) { 180 throw new InvalidArgumentException(sprintf('Local file not found or not a regular file: %s', $path)); 181 } 182 $resolvedMimeType = $mimeType !== null ? new MimeType($mimeType) : self::determineMimeType(null, null, $path); 183 $fileContent = @file_get_contents($path); 184 if ($fileContent === \false) { 185 throw new RuntimeException(sprintf('Unable to read file: %s', $path)); 186 } 187 return self::fromBase64(base64_encode($fileContent), (string) $resolvedMimeType); 188 } 189 /** 190 * Detects the file type and processes it accordingly. 191 * 192 * @since 0.1.0 193 * @since 1.5.1 Rejects non-http(s) URL schemes before any filesystem access; local path detection deprecated. 194 * 195 * @param string $file The file string to process. 196 * @param string|null $providedMimeType The explicitly provided MIME type. 197 * @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined. 198 */ 199 private function detectAndProcessFile(string $file, ?string $providedMimeType): void 200 { 201 // Check if it's an http(s) URL. 202 if (self::isHttpUrl($file)) { 203 $this->fileType = FileTypeEnum::remote(); 204 $this->url = $file; 205 $this->mimeType = self::determineMimeType($providedMimeType, null, $file); 206 return; 207 } 208 // Any other URL scheme (file://, ftp://, phar://, ...) is rejected before anything else runs. 209 if (str_contains($file, '://')) { 210 throw new InvalidArgumentException('Unsupported URL scheme. Only http:// and https:// URLs are supported.'); 211 } 212 // Check if it's a data URI. 213 if (preg_match(self::DATA_URI_PATTERN, $file, $matches)) { 214 $this->fileType = FileTypeEnum::inline(); 215 $this->base64Data = $matches[2]; 216 // Extract just the base64 data 217 $extractedMimeType = empty($matches[1]) ? null : $matches[1]; 218 $this->mimeType = self::determineMimeType($providedMimeType, $extractedMimeType, null); 219 return; 220 } 221 // Deprecated: local file path detection (kept for backward compatibility, before the base64 check). 222 // Stream wrappers were rejected above, so this cannot open a network connection. 223 if (strpos($file, "\x00") === \false && is_file($file)) { 224 @trigger_error('Passing a local file path to ' . self::class . '::__construct() is deprecated since 1.5.1; ' . 'use File::fromLocalPath() instead.', \E_USER_DEPRECATED); 225 $local = self::fromLocalPath($file, $providedMimeType); 226 $this->fileType = $local->fileType; 227 $this->base64Data = $local->base64Data; 228 $this->mimeType = $local->mimeType; 229 return; 230 } 231 // Check if it's plain base64. 232 if (self::isBase64($file)) { 233 if ($providedMimeType === null) { 234 throw new InvalidArgumentException('MIME type is required when providing plain base64 data without data URI format.'); 235 } 236 $this->fileType = FileTypeEnum::inline(); 237 $this->base64Data = $file; 238 $this->mimeType = new MimeType($providedMimeType); 239 return; 240 } 241 throw new InvalidArgumentException('Invalid file provided. Expected an http(s) URL, a data URI, base64 data with a MIME type, ' . 'or a valid local file path.'); 242 } 243 /** 244 * Checks if a string is an http(s) URL. 245 * 246 * @since 0.1.0 247 * @since 1.5.1 Made static. 248 * 249 * @param string $string The string to check. 250 * @return bool True if the string is an http(s) URL. 251 */ 252 private static function isHttpUrl(string $string): bool 253 { 254 return filter_var($string, \FILTER_VALIDATE_URL) !== \false && preg_match('/^https?:\/\//i', $string) === 1; 255 } 256 /** 257 * Checks if a string is plain base64 data (possibly empty). 258 * 259 * @since 1.5.1 260 * 261 * @param string $string The string to check. 262 * @return bool True if the string is plain base64 data. 263 */ 264 private static function isBase64(string $string): bool 265 { 266 return preg_match(self::BASE64_PATTERN, $string) === 1; 267 } 268 /** 269 * Gets the file type. 270 * 271 * @since 0.1.0 272 * 273 * @return FileTypeEnum The file type. 274 */ 275 public function getFileType(): FileTypeEnum 276 { 277 return $this->fileType; 278 } 279 /** 280 * Checks if the file is an inline file. 281 * 282 * @since 0.1.0 283 * 284 * @return bool True if the file is inline (base64/data URI). 285 */ 286 public function isInline(): bool 287 { 288 return $this->fileType->isInline(); 289 } 290 /** 291 * Checks if the file is a remote file. 292 * 293 * @since 0.1.0 294 * 295 * @return bool True if the file is remote (URL). 296 */ 297 public function isRemote(): bool 298 { 299 return $this->fileType->isRemote(); 300 } 301 /** 302 * Gets the URL for remote files. 303 * 304 * @since 0.1.0 305 * 306 * @return string|null The URL, or null if not a remote file. 307 */ 308 public function getUrl(): ?string 309 { 310 return $this->url; 311 } 312 /** 313 * Gets the base64-encoded data for inline files. 314 * 315 * @since 0.1.0 316 * 317 * @return string|null The plain base64-encoded data (without data URI prefix), or null if not an inline file. 318 */ 319 public function getBase64Data(): ?string 320 { 321 return $this->base64Data; 322 } 323 /** 324 * Gets the data as a data URI for inline files. 325 * 326 * @since 0.1.0 327 * 328 * @return string|null The data URI in format: data:[mimeType];base64,[data], or null if not an inline file. 329 */ 330 public function getDataUri(): ?string 331 { 332 if ($this->base64Data === null) { 333 return null; 334 } 335 return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->base64Data); 336 } 337 /** 338 * Gets the MIME type of the file as a string. 339 * 340 * @since 0.1.0 341 * 342 * @return string The MIME type string value. 343 */ 344 public function getMimeType(): string 345 { 346 return (string) $this->mimeType; 347 } 348 /** 349 * Gets the MIME type object. 350 * 351 * @since 0.1.0 352 * 353 * @return MimeType The MIME type object. 354 */ 355 public function getMimeTypeObject(): MimeType 356 { 357 return $this->mimeType; 358 } 359 /** 360 * Checks if the file is a video. 361 * 362 * @since 0.1.0 363 * 364 * @return bool True if the file is a video. 365 */ 366 public function isVideo(): bool 367 { 368 return $this->mimeType->isVideo(); 369 } 370 /** 371 * Checks if the file is an image. 372 * 373 * @since 0.1.0 374 * 375 * @return bool True if the file is an image. 376 */ 377 public function isImage(): bool 378 { 379 return $this->mimeType->isImage(); 380 } 381 /** 382 * Checks if the file is audio. 383 * 384 * @since 0.1.0 385 * 386 * @return bool True if the file is audio. 387 */ 388 public function isAudio(): bool 389 { 390 return $this->mimeType->isAudio(); 391 } 392 /** 393 * Checks if the file is text. 394 * 395 * @since 0.1.0 396 * 397 * @return bool True if the file is text. 398 */ 399 public function isText(): bool 400 { 401 return $this->mimeType->isText(); 402 } 403 /** 404 * Checks if the file is a document. 405 * 406 * @since 0.1.0 407 * 408 * @return bool True if the file is a document. 409 */ 410 public function isDocument(): bool 411 { 412 return $this->mimeType->isDocument(); 413 } 414 /** 415 * Checks if the file is a specific MIME type. 416 * 417 * @since 0.1.0 418 * 419 * @param string $type The mime type to check (e.g. 'image', 'text', 'video', 'audio'). 420 * 421 * @return bool True if the file is of the specified type. 422 */ 423 public function isMimeType(string $type): bool 424 { 425 return $this->mimeType->isType($type); 426 } 427 /** 428 * Determines the MIME type from various sources. 429 * 430 * @since 0.1.0 431 * @since 1.5.1 Made static. 432 * 433 * @param string|null $providedMimeType The explicitly provided MIME type. 434 * @param string|null $extractedMimeType The MIME type extracted from data URI. 435 * @param string|null $pathOrUrl The file path or URL to extract extension from. 436 * @return MimeType The determined MIME type. 437 * @throws InvalidArgumentException If MIME type cannot be determined. 438 */ 439 private static function determineMimeType(?string $providedMimeType, ?string $extractedMimeType, ?string $pathOrUrl): MimeType 440 { 441 // Prefer explicitly provided MIME type 442 if ($providedMimeType !== null) { 443 return new MimeType($providedMimeType); 444 } 445 // Use extracted MIME type from data URI 446 if ($extractedMimeType !== null) { 447 return new MimeType($extractedMimeType); 448 } 449 // Try to determine from file extension 450 if ($pathOrUrl !== null) { 451 $parsedUrl = parse_url($pathOrUrl); 452 $path = $parsedUrl['path'] ?? $pathOrUrl; 453 // Remove query string and fragment if present 454 $cleanPath = strtok($path, '?#'); 455 if ($cleanPath === \false) { 456 $cleanPath = $path; 457 } 458 $extension = pathinfo($cleanPath, \PATHINFO_EXTENSION); 459 if (!empty($extension)) { 460 try { 461 return MimeType::fromExtension($extension); 462 } catch (InvalidArgumentException $e) { 463 // Extension not recognized, continue to error 464 unset($e); 465 } 466 } 467 } 468 throw new InvalidArgumentException('Unable to determine MIME type. Please provide it explicitly.'); 469 } 470 /** 471 * {@inheritDoc} 472 * 473 * @since 0.1.0 474 */ 475 public static function getJsonSchema(): array 476 { 477 return ['type' => 'object', 'oneOf' => [['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::REMOTE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\-\^_+.]*$'], self::KEY_URL => ['type' => 'string', 'format' => 'uri', 'description' => 'The URL to the remote file.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_URL]], ['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::INLINE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\-\^_+.]*$'], self::KEY_BASE64_DATA => ['type' => 'string', 'description' => 'The base64-encoded file data.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_BASE64_DATA]]]]; 478 } 479 /** 480 * {@inheritDoc} 481 * 482 * @since 0.1.0 483 * 484 * @return FileArrayShape 485 */ 486 public function toArray(): array 487 { 488 $data = [self::KEY_FILE_TYPE => $this->fileType->value, self::KEY_MIME_TYPE => $this->getMimeType()]; 489 if ($this->url !== null) { 490 $data[self::KEY_URL] = $this->url; 491 } elseif (!$this->fileType->isRemote() && $this->base64Data !== null) { 492 $data[self::KEY_BASE64_DATA] = $this->base64Data; 493 } else { 494 throw new RuntimeException('File requires either url or base64Data. This should not be a possible condition.'); 495 } 496 return $data; 497 } 498 /** 499 * {@inheritDoc} 500 * 501 * This check is purely structural and never accesses the filesystem. 502 * 503 * @since 1.5.1 504 */ 505 public static function isArrayShape(array $array): bool 506 { 507 try { 508 self::assertArrayShape($array); 509 return \true; 510 } catch (InvalidArgumentException $e) { 511 return \false; 512 } 513 } 514 /** 515 * {@inheritDoc} 516 * 517 * @since 0.1.0 518 * @since 1.5.1 The fileType key is authoritative: remote files require an http(s) url, inline files 519 * require base64Data and mimeType. Local file paths are rejected. 520 */ 521 public static function fromArray(array $array): self 522 { 523 self::assertArrayShape($array); 524 $mimeType = $array[self::KEY_MIME_TYPE] ?? null; 525 if ($array[self::KEY_FILE_TYPE] === FileTypeEnum::REMOTE) { 526 return self::fromUrl($array[self::KEY_URL] ?? '', $mimeType); 527 } 528 return self::fromBase64($array[self::KEY_BASE64_DATA] ?? '', $mimeType ?? ''); 529 } 530 /** 531 * Asserts that an array has a valid file array shape, without constructing the file. 532 * 533 * @since 1.5.1 534 * 535 * @param array<mixed> $array The array to check. 536 * @throws InvalidArgumentException If the array is not a valid file array shape. 537 */ 538 private static function assertArrayShape(array $array): void 539 { 540 static::validateFromArrayData($array, [self::KEY_FILE_TYPE]); 541 $fileType = $array[self::KEY_FILE_TYPE]; 542 if ($fileType !== FileTypeEnum::REMOTE && $fileType !== FileTypeEnum::INLINE) { 543 throw new InvalidArgumentException(sprintf('Invalid file type. Expected "%s" or "%s".', FileTypeEnum::REMOTE, FileTypeEnum::INLINE)); 544 } 545 // A null mimeType is treated like an absent one (for remote files it is then inferred from the URL). 546 $mimeType = $array[self::KEY_MIME_TYPE] ?? null; 547 if ($mimeType !== null && (!is_string($mimeType) || !MimeType::isValid($mimeType))) { 548 throw new InvalidArgumentException('Invalid MIME type provided in file array.'); 549 } 550 if ($fileType === FileTypeEnum::REMOTE) { 551 if (array_key_exists(self::KEY_BASE64_DATA, $array)) { 552 throw new InvalidArgumentException('Remote files must not contain base64Data.'); 553 } 554 $url = $array[self::KEY_URL] ?? null; 555 if (!is_string($url) || !self::isHttpUrl($url)) { 556 throw new InvalidArgumentException('Remote files require a url with an http:// or https:// scheme.'); 557 } 558 if ($mimeType === null) { 559 // Without an explicit MIME type it must be inferable from the URL (no I/O involved). 560 self::determineMimeType(null, null, $url); 561 } 562 return; 563 } 564 if (array_key_exists(self::KEY_URL, $array)) { 565 throw new InvalidArgumentException('Inline files must not contain url.'); 566 } 567 $base64Data = $array[self::KEY_BASE64_DATA] ?? null; 568 if (!is_string($base64Data)) { 569 throw new InvalidArgumentException('Inline files require base64Data.'); 570 } 571 if (str_starts_with($base64Data, 'data:')) { 572 throw new InvalidArgumentException('Inline files require raw base64 data in base64Data, not a data URI.'); 573 } 574 if (!self::isBase64($base64Data)) { 575 throw new InvalidArgumentException('Inline files require valid base64 data in base64Data. Local file paths are not supported.'); 576 } 577 if ($mimeType === null) { 578 throw new InvalidArgumentException('Inline files require a mimeType.'); 579 } 580 } 581 /** 582 * Performs a deep clone of the file. 583 * 584 * This method ensures that the MimeType value object is cloned to prevent 585 * any shared references between the original and cloned file. 586 * 587 * @since 0.4.2 588 */ 589 public function __clone() 590 { 591 $this->mimeType = clone $this->mimeType; 592 } 593 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Sun Nov 1 08:20:35 2026 | Cross-referenced by PHPXref |