258 lines
7.2 KiB
PHP
258 lines
7.2 KiB
PHP
<?php
|
|
// HttpUploadedFile.php
|
|
// Created: 2022-02-10
|
|
// Updated: 2024-08-01
|
|
|
|
namespace Index\Http;
|
|
|
|
use Index\MediaType;
|
|
use Index\ICloseable;
|
|
use Index\IO\Stream;
|
|
use Index\IO\FileStream;
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Represents an uploaded file in a multipart/form-data request.
|
|
*/
|
|
class HttpUploadedFile implements ICloseable {
|
|
private int $errorCode;
|
|
private int $size;
|
|
private string $localFileName;
|
|
private string $suggestedFileName;
|
|
private MediaType $suggestedMediaType;
|
|
private bool $hasMoved = false;
|
|
private ?Stream $stream = null;
|
|
|
|
/**
|
|
* @param int $errorCode PHP file upload error code.
|
|
* @param int $size Size, as provided by the client.
|
|
* @param string $localFileName Filename generated by PHP on the server.
|
|
* @param string $suggestedFileName Filename included by the client.
|
|
* @param MediaType|string $suggestedMediaType Mediatype included by the client.
|
|
*/
|
|
public function __construct(
|
|
int $errorCode,
|
|
int $size,
|
|
string $localFileName,
|
|
string $suggestedFileName,
|
|
MediaType|string $suggestedMediaType
|
|
) {
|
|
if(!($suggestedMediaType instanceof MediaType))
|
|
$suggestedMediaType = MediaType::parse($suggestedMediaType);
|
|
|
|
$this->errorCode = $errorCode;
|
|
$this->size = $size;
|
|
$this->localFileName = $localFileName;
|
|
$this->suggestedFileName = $suggestedFileName;
|
|
$this->suggestedMediaType = $suggestedMediaType;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the PHP file upload error code.
|
|
*
|
|
* @return int PHP file upload error code.
|
|
*/
|
|
public function getErrorCode(): int {
|
|
return $this->errorCode;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the size of the uploaded file.
|
|
*
|
|
* @return int Size of uploaded file.
|
|
*/
|
|
public function getSize(): int {
|
|
return $this->size;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the local path to the uploaded file.
|
|
*
|
|
* @return ?string Path to file, or null.
|
|
*/
|
|
public function getLocalFileName(): ?string {
|
|
return $this->localFileName;
|
|
}
|
|
|
|
/**
|
|
* Retrieves media type of the uploaded file.
|
|
*
|
|
* @return ?MediaType Type of file, or null.
|
|
*/
|
|
public function getLocalMediaType(): ?MediaType {
|
|
return MediaType::fromPath($this->localFileName);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the suggested name for the uploaded file.
|
|
*
|
|
* @return string Suggested name for the file.
|
|
*/
|
|
public function getSuggestedFileName(): string {
|
|
return $this->suggestedFileName;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the suggested media type for the uploaded file.
|
|
*
|
|
* @return MediaType Suggested type for the file.
|
|
*/
|
|
public function getSuggestedMediaType(): MediaType {
|
|
return $this->suggestedMediaType;
|
|
}
|
|
|
|
/**
|
|
* Checks whether the file has been moved to its final destination.
|
|
*
|
|
* @return bool true if it has been moved.
|
|
*/
|
|
public function hasMoved(): bool {
|
|
return $this->hasMoved;
|
|
}
|
|
|
|
/**
|
|
* Gets a read-only stream to the uploaded file.
|
|
*
|
|
* @throws RuntimeException If the file cannot be opened.
|
|
* @return Stream Read-only stream of the upload file.
|
|
*/
|
|
public function getStream(): Stream {
|
|
if($this->stream === null) {
|
|
if($this->errorCode !== UPLOAD_ERR_OK)
|
|
throw new RuntimeException('Can\'t open stream because of an upload error.');
|
|
if($this->hasMoved)
|
|
throw new RuntimeException('Can\'t open stream because file has already been moved.');
|
|
|
|
$this->stream = FileStream::openRead($this->localFileName);
|
|
}
|
|
|
|
return $this->stream;
|
|
}
|
|
|
|
/**
|
|
* Moves the uploaded file to its final destination.
|
|
*
|
|
* @param string $path Path to move the file to.
|
|
* @throws RuntimeException If the file has already been moved.
|
|
* @throws RuntimeException If an upload error occurred.
|
|
* @throws InvalidArgumentException If the provided $path is not valid.
|
|
* @throws RuntimeException If the file failed to move.
|
|
*/
|
|
public function moveTo(string $path): void {
|
|
if($this->hasMoved)
|
|
throw new RuntimeException('This uploaded file has already been moved.');
|
|
if($this->errorCode !== UPLOAD_ERR_OK)
|
|
throw new RuntimeException('Can\'t move file because of an upload error.');
|
|
|
|
if(empty($path))
|
|
throw new InvalidArgumentException('$path is not a valid path.');
|
|
|
|
if($this->stream !== null)
|
|
$this->stream->close();
|
|
|
|
$this->hasMoved = PHP_SAPI === 'CLI'
|
|
? rename($this->localFileName, $path)
|
|
: move_uploaded_file($this->localFileName, $path);
|
|
|
|
if(!$this->hasMoved)
|
|
throw new RuntimeException('Failed to move file to ' . $path);
|
|
|
|
$this->localFileName = $path;
|
|
}
|
|
|
|
public function close(): void {
|
|
if($this->stream !== null)
|
|
$this->stream->close();
|
|
}
|
|
|
|
public function __destruct() {
|
|
$this->close();
|
|
}
|
|
|
|
/**
|
|
* Creates a HttpUploadedFile instance from an entry in the $_FILES superglobal.
|
|
*
|
|
* @return HttpUploadedFile Uploaded file info.
|
|
*/
|
|
public static function createFromFILE(array $file): self {
|
|
return new HttpUploadedFile(
|
|
$file['error'] ?? UPLOAD_ERR_NO_FILE,
|
|
$file['size'] ?? -1,
|
|
$file['tmp_name'] ?? '',
|
|
$file['name'] ?? '',
|
|
$file['type'] ?? ''
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates a collection of HttpUploadedFile instances from the $_FILES superglobal.
|
|
*
|
|
* @return array<string, mixed> Uploaded files.
|
|
*/
|
|
public static function createFromFILES(array $files): array {
|
|
if(empty($files))
|
|
return [];
|
|
|
|
return self::createObjectInstances(self::normalizeFILES($files));
|
|
}
|
|
|
|
private static function traverseFILES(array $files, string $keyName): array {
|
|
$arr = [];
|
|
|
|
foreach($files as $key => $val) {
|
|
$key = "_{$key}";
|
|
|
|
if(is_array($val)) {
|
|
$arr[$key] = self::traverseFILES($val, $keyName);
|
|
} else {
|
|
$arr[$key][$keyName] = $val;
|
|
}
|
|
}
|
|
|
|
return $arr;
|
|
}
|
|
|
|
private static function normalizeFILES(array $files): array {
|
|
$out = [];
|
|
|
|
foreach($files as $key => $arr) {
|
|
if(empty($arr))
|
|
continue;
|
|
|
|
$key = '_' . $key;
|
|
|
|
if(is_int($arr['error'])) {
|
|
$out[$key] = $arr;
|
|
continue;
|
|
}
|
|
|
|
if(is_array($arr['error'])) {
|
|
$keys = array_keys($arr);
|
|
|
|
foreach($keys as $keyName) {
|
|
$out[$key] = array_merge_recursive($out[$key] ?? [], self::traverseFILES($arr[$keyName], $keyName));
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private static function createObjectInstances(array $files): array {
|
|
$coll = [];
|
|
|
|
foreach($files as $key => $val) {
|
|
$key = substr($key, 1);
|
|
|
|
if(isset($val['error'])) {
|
|
$coll[$key] = self::createFromFILE($val);
|
|
} else {
|
|
$coll[$key] = self::createObjectInstances($val);
|
|
}
|
|
}
|
|
|
|
return $coll;
|
|
}
|
|
}
|