@ -147,6 +147,13 @@ class Request
*/
*/
public string $body = '';
public string $body = '';
/**
* Hold tmp file handles created via tmpfile() so they persist for request lifetime
*
* @var array< int , resource >
*/
private array $tmpFileHandles = [];
/**
/**
* Constructor.
* Constructor.
*
*
@ -177,10 +184,10 @@ class Request
'user_agent' => $this->getVar('HTTP_USER_AGENT'),
'user_agent' => $this->getVar('HTTP_USER_AGENT'),
'type' => $this->getVar('CONTENT_TYPE'),
'type' => $this->getVar('CONTENT_TYPE'),
'length' => intval($this->getVar('CONTENT_LENGTH', 0)),
'length' => intval($this->getVar('CONTENT_LENGTH', 0)),
'query' => new Collection($_GET ?? [] ),
'query' => new Collection($_GET),
'data' => new Collection($_POST ?? [] ),
'data' => new Collection($_POST),
'cookies' => new Collection($_COOKIE ?? [] ),
'cookies' => new Collection($_COOKIE),
'files' => new Collection($_FILES ?? [] ),
'files' => new Collection($_FILES),
'secure' => $scheme === 'https',
'secure' => $scheme === 'https',
'accept' => $this->getVar('HTTP_ACCEPT'),
'accept' => $this->getVar('HTTP_ACCEPT'),
'proxy_ip' => $this->getProxyIpAddress(),
'proxy_ip' => $this->getProxyIpAddress(),
@ -219,7 +226,7 @@ class Request
$this->url = '/';
$this->url = '/';
} else {
} else {
// Merge URL query parameters with $_GET
// Merge URL query parameters with $_GET
$_GET = array_merge($_GET ?? [] , self::parseQuery($this->url));
$_GET = array_merge($_GET, self::parseQuery($this->url));
$this->query->setData($_GET);
$this->query->setData($_GET);
}
}
@ -233,7 +240,7 @@ class Request
$this->data->setData($data);
$this->data->setData($data);
}
}
}
}
// Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data
// Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data
} elseif (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) {
} elseif (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) {
$this->parseRequestBodyForHttpMethods();
$this->parseRequestBodyForHttpMethods();
}
}
@ -468,7 +475,7 @@ class Request
/**
/**
* Retrieves the array of uploaded files.
* Retrieves the array of uploaded files.
*
*
* @return array< string , array< string , UploadedFile > |array< string , array < string , UploadedFile > >> The array of uploaded files .
* @return array< string , UploadedFile| array < int , UploadedFile > > Key is field name; value is either a single UploadedFile or an array of UploadedFile when multiple were uploaded .
*/
*/
public function getUploadedFiles(): array
public function getUploadedFiles(): array
{
{
@ -478,7 +485,7 @@ class Request
// Check if original data was array format (files_name[] style)
// Check if original data was array format (files_name[] style)
$originalFile = $this->files->getData()[$keyName] ?? null;
$originalFile = $this->files->getData()[$keyName] ?? null;
$isArrayFormat = $originalFile & & is_array($originalFile['name']);
$isArrayFormat = $originalFile & & is_array($originalFile['name']);
foreach ($files as $file) {
foreach ($files as $file) {
$UploadedFile = new UploadedFile(
$UploadedFile = new UploadedFile(
$file['name'],
$file['name'],
@ -487,9 +494,9 @@ class Request
$file['tmp_name'],
$file['tmp_name'],
$file['error']
$file['error']
);
);
// Always use array format if original data was array, regardless of count
// Always use array format if original data was array, regardless of count
if ($isArrayFormat) {
if ($isArrayFormat === true ) {
$uploadedFiles[$keyName][] = $UploadedFile;
$uploadedFiles[$keyName][] = $UploadedFile;
} else {
} else {
$uploadedFiles[$keyName] = $UploadedFile;
$uploadedFiles[$keyName] = $UploadedFile;
@ -531,198 +538,299 @@ class Request
/**
/**
* Parse request body data for HTTP methods that don't natively support form data (PUT, DELETE, PATCH)
* Parse request body data for HTTP methods that don't natively support form data (PUT, DELETE, PATCH)
*
* @return void
* @return void
*/
*/
protected function parseRequestBodyForHttpMethods(): void
protected function parseRequestBodyForHttpMethods(): void
{
{
$body = $this->getBody();
$body = $this->getBody();
// Empty body
// Empty body
if ($body === '') {
if ($body === '') {
return;
return;
}
}
// Check Content-Type for multipart/form-data
// Check Content-Type for multipart/form-data
$contentType = strtolower(trim($this->type));
$contentType = strtolower(trim($this->type));
$isMultipart = strpos($contentType, 'multipart/form-data') === 0;
$isMultipart = strpos($contentType, 'multipart/form-data') === 0;
$boundary = null;
$boundary = null;
if ($isMultipart) {
if ($isMultipart === true ) {
// Extract boundary more safely
// Extract boundary more safely
if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) {
if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) {
$boundary = $matches[2];
$boundary = $matches[2];
}
}
// If no boundary found, it's not valid multipart
// If no boundary found, it's not valid multipart
if (empty($boundary)) {
if (empty($boundary)) {
$isMultipart = false;
$isMultipart = false;
}
}
}
$data = [];
$firstLine = strtok($body, "\r\n");
$file = [];
if ($firstLine === false || strpos($firstLine, '--' . $boundary) !== 0) {
// Does not start with the boundary marker; fall back
$isMultipart = false;
}
}
// Parse application/x-www-form-urlencoded
// Parse application/x-www-form-urlencoded
if ($isMultipart === false) {
if ($isMultipart === false) {
parse_str($body, $data);
parse_str($body, $data);
$this->data->setData($data);
$this->data->setData($data);
return;
return;
}
}
$this->setParsedRequestBodyMultipartFormData($body, $boundary);
}
/**
* Sets the parsed request body for multipart form data requests
*
* This method processes and stores multipart form data from the request body,
* parsing it according to the specified boundary delimiter. It handles the
* complex parsing of multipart data including file uploads and form fields.
*
* @param string $body The raw multipart request body content
* @param string $boundary The boundary string used to separate multipart sections
*
* @return void
*/
protected function setParsedRequestBodyMultipartFormData(string $body, string $boundary): void
{
$data = [];
$file = [];
// Parse multipart/form-data
// Parse multipart/form-data
$bodyParts = preg_split('/\\R?-+' . preg_quote($boundary, '/') . '/s', $body);
$bodyParts = preg_split('/\R?-+' . preg_quote($boundary, '/') . '/s', $body);
array_pop($bodyParts); // Remove last element (empty)
array_pop($bodyParts); // Remove last element (closing boundary or empty)
$partsProcessed = 0;
$filesTotalBytes = 0;
// Use ini values directly
$maxParts = (int) ini_get('max_file_uploads');
if ($maxParts < = 0) {
// unlimited parts if not specified
$maxParts = PHP_INT_MAX; // @codeCoverageIgnore
}
$maxTotalBytes = self::derivePostMaxSizeBytes();
foreach ($bodyParts as $bodyPart) {
foreach ($bodyParts as $bodyPart) {
if (empty($bodyPart)) {
if ($partsProcessed >= $maxParts) {
// reached part limit from ini
break; // @codeCoverageIgnore
}
if ($bodyPart === '' || $bodyPart === null) {
continue; // skip empty segments
}
$partsProcessed++;
// Split headers and value; if format invalid, skip early
$split = preg_split('/\R\R/', $bodyPart, 2);
if ($split === false || count($split) < 2 ) {
continue;
continue;
}
}
[$header, $value] = $split;
// Get the headers and value
// Fast header sanity checks
[$header, $value] = preg_split('/\\R\\R/', $bodyPart, 2);
if (stripos($header, 'content-disposition') === false) {
continue;
// Check if the header is normal
}
if (strpos(strtolower($header), 'content-disposition') === false) {
if (strlen($header) > 16384) { // 16KB header block guard
continue;
continue;
}
}
$value = ltrim($value, "\r\n");
$value = ltrim($value, "\r\n");
/**
// Parse headers (simple approach, fail-fast on anomalies)
* Process Header
$headers = $this->parseRequestBodyHeadersFromMultipartFormData($header);
*/
$headers = [];
// split the headers
$headerParts = preg_split('/\\R/', $header);
foreach ($headerParts as $headerPart) {
if (strpos($headerPart, ':') === false) {
continue;
}
// Process the header
// Required disposition/name
[$headerKey, $headerValue] = explode(':', $headerPart, 2);
if (isset($headers['content-disposition']['name']) === false) {
$headerKey = strtolower(trim($headerKey));
$headerValue = trim($headerValue);
if (strpos($headerValue, ';') !== false) {
$headers[$headerKey] = [];
foreach (explode(';', $headerValue) as $headerValuePart) {
preg_match_all('/(\w+)=\"?([^";]+)\"?/', $headerValuePart, $headerMatches, PREG_SET_ORDER);
foreach ($headerMatches as $headerMatch) {
$headerSubKey = strtolower($headerMatch[1]);
$headerSubValue = $headerMatch[2];
$headers[$headerKey][$headerSubKey] = $headerSubValue;
}
}
} else {
$headers[$headerKey] = $headerValue;
}
}
/**
* Process Value
*/
if (!isset($headers['content-disposition']) || !isset($headers['content-disposition']['name'])) {
continue;
continue;
}
}
$keyName = str_replace('[]', '', (string) $headers['content-disposition']['name']);
if ($keyName === '') {
continue; // avoid empty keys
}
$keyName = str_replace("[]", "", $headers['content-disposition']['name']);
// Non-file field
if (isset($headers['content-disposition']['filename']) === false) {
// if is not file
if (isset($data[$keyName]) === false) {
if (!isset($headers['content-disposition']['filename'])) {
$data[$keyName] = $value;
if (isset($data[$keyName])) {
} else {
if (! is_array($data[$keyName])) {
if (is_array($data[$keyName]) === false) {
$data[$keyName] = [$data[$keyName]];
$data[$keyName] = [$data[$keyName]];
}
}
$data[$keyName][] = $value;
$data[$keyName][] = $value;
} else {
$data[$keyName] = $value;
}
}
continue;
continue; // done with this part
}
// Sanitize filename early
$rawFilename = (string) $headers['content-disposition']['filename'];
$rawFilename = str_replace(["\0", "\r", "\n"], '', $rawFilename);
$sanitizedFilename = basename($rawFilename);
$matchCriteria = preg_match('/^[A-Za-z0-9._-]{1,255}$/', $sanitizedFilename);
if ($sanitizedFilename === '' || $matchCriteria !== 1) {
$sanitizedFilename = 'upload_' . uniqid('', true);
}
}
$size = mb_strlen($value, '8bit');
$filesTotalBytes += $size;
$tmpFile = [
$tmpFile = [
'name' => $headers['content-disposition']['filename'],
'name' => $sanitizedFilename ,
'type' => $headers['content-type'] ?? 'application/octet-stream',
'type' => $headers['content-type'] ?? 'application/octet-stream',
'size' => mb_strlen($value, '8bit'),
'size' => $size ,
'tmp_name' => '',
'tmp_name' => '',
'error' => UPLOAD_ERR_OK,
'error' => UPLOAD_ERR_OK,
];
];
if ($tmpFile['size'] > $this->getUploadMaxFileSize()) {
// Fail fast on size constraints
$tmpFile['error'] = UPLOAD_ERR_INI_SIZE;
if ($size > $this->getUploadMaxFileSize() || $filesTotalBytes > $maxTotalBytes) {
// individual file or total size exceeded
$tmpFile['error'] = UPLOAD_ERR_INI_SIZE; // @codeCoverageIgnore
} else {
} else {
// Create a temporary file
$tempResult = $this->createTempFile($value);
$tmpName = tempnam(sys_get_temp_dir(), 'flight_tmp_');
$tmpFile['tmp_name'] = $tempResult['tmp_name'];
if ($tmpName === false) {
$tmpFile['error'] = $tempResult['error'];
$tmpFile['error'] = UPLOAD_ERR_CANT_WRITE;
}
} else {
// Write the value to a temporary file
$bytes = file_put_contents($tmpName, $value);
if ($bytes === false) {
// Aggregate into synthetic files array
$tmpFile['error'] = UPLOAD_ERR_CANT_WRITE;
foreach ($tmpFile as $metaKey => $metaVal) {
} else {
if (!isset($file[$keyName][$metaKey])) {
// delete the temporary file before ended script
$file[$keyName][$metaKey] = $metaVal;
register_shutdown_function(function () use ($tmpName): void {
continue;
if (file_exists($tmpName)) {
}
unlink($tmpName);
if (!is_array($file[$keyName][$metaKey])) {
}
$file[$keyName][$metaKey] = [$file[$keyName][$metaKey]];
});
$tmpFile['tmp_name'] = $tmpName;
}
}
}
$file[$keyName][$metaKey][] = $metaVal;
}
}
}
foreach ($tmpFile as $key => $value) {
$this->data->setData($data);
if (isset($file[$keyName][$key])) {
$this->files->setData($file);
if (!is_array($file[$keyName][$key])) {
}
$file[$keyName][$key] = [$file[$keyName][$key]];
/**
* Parses request body headers from multipart form data
*
* This method extracts and processes headers from a multipart form data section,
* typically used for file uploads or complex form submissions. It parses the
* header string and returns an associative array of header name-value pairs.
*
* @param string $header The raw header string from a multipart form data section
*
* @return array< string , mixed > An associative array containing parsed header name-value pairs
*/
protected function parseRequestBodyHeadersFromMultipartFormData(string $header): array
{
$headers = [];
foreach (preg_split('/\R/', $header) as $headerLine) {
if (strpos($headerLine, ':') === false) {
continue;
}
[$headerKey, $headerValue] = explode(':', $headerLine, 2);
$headerKey = strtolower(trim($headerKey));
$headerValue = trim($headerValue);
if (strpos($headerValue, ';') !== false) {
$headers[$headerKey] = [];
foreach (explode(';', $headerValue) as $hvPart) {
preg_match_all('/(\w+)="?([^";]+)"?/', $hvPart, $matches, PREG_SET_ORDER);
foreach ($matches as $m) {
$subKey = strtolower($m[1]);
$headers[$headerKey][$subKey] = $m[2];
}
}
$file[$keyName][$key][] = $value;
} else {
$file[$keyName][$key] = $value;
}
}
} else {
$headers[$headerKey] = $headerValue;
}
}
}
}
return $headers;
$this->data->setData($data);
$this->files->setData($file);
}
}
/**
/**
* Get the maximum file size that can be uploaded.
* Get the maximum file size that can be uploaded.
*
* @return int The maximum file size in bytes.
* @return int The maximum file size in bytes.
*/
*/
protected function getUploadMaxFileSize(): int {
public function getUploadMaxFileSize(): int
{
$value = ini_get('upload_max_filesize');
$value = ini_get('upload_max_filesize');
return self::parsePhpSize($value);
}
/**
* Parse a PHP shorthand size string (like "1K", "1.5M") into bytes.
* Returns 0 on unknown or unsupported unit (keeps existing behavior).
*
* @param string $size
*
* @return int
*/
public static function parsePhpSize(string $size): int
{
$unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $size));
$value = (int) preg_replace('/[^\d.]/', '', $size);
$unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $value));
// No unit => follow existing behavior and return value directly if > 1024 (1K)
$value = preg_replace('/[^\d.]/', '', $value);
if ($unit === '' & & $value >= 1024) {
return $value;
}
switch ($unit) {
switch ($unit) {
case 'p': // PentaByte
case 't':
case 'pb':
case 'tb':
$value *= 1024;
$value *= 1024; // Fall through
case 't': // Terabyte
case 'g':
$value *= 1024;
case 'gb':
case 'g': // Gigabyte
$value *= 1024; // Fall through
case 'm':
case 'mb':
$value *= 1024; // Fall through
case 'k':
$value *= 1024;
$value *= 1024;
case 'm': // Megabyte
$value *= 1024;
case 'k': // Kilobyte
$value *= 1024;
case 'b': // Byte
break;
break;
default:
default:
return 0;
return 0;
}
}
return (int)$value;
return $value;
}
/**
* Derive post_max_size in bytes. Returns 0 when unlimited or unparsable.
*/
private static function derivePostMaxSizeBytes(): int
{
$postMax = (string) ini_get('post_max_size');
$bytes = self::parsePhpSize($postMax);
return $bytes; // 0 means unlimited
}
/**
* Create a temporary file for uploaded content using tmpfile().
* Returns array with tmp_name and error code.
*
* @param string $content
*
* @return array< string , string | int >
*/
private function createTempFile(string $content): array
{
$fp = tmpfile();
if ($fp === false) {
return ['tmp_name' => '', 'error' => UPLOAD_ERR_CANT_WRITE]; // @codeCoverageIgnore
}
$bytes = fwrite($fp, $content);
if ($bytes === false) {
fclose($fp); // @codeCoverageIgnore
return ['tmp_name' => '', 'error' => UPLOAD_ERR_CANT_WRITE]; // @codeCoverageIgnore
}
$meta = stream_get_meta_data($fp);
$tmpName = isset($meta['uri']) ? $meta['uri'] : '';
$this->tmpFileHandles[] = $fp; // retain handle for lifecycle
return ['tmp_name' => $tmpName, 'error' => UPLOAD_ERR_OK];
}
}
}
}