mirror of https://github.com/flightphp/core
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
837 lines
25 KiB
837 lines
25 KiB
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace flight\net;
|
|
|
|
use flight\util\Collection;
|
|
|
|
/**
|
|
* The Request class represents an HTTP request. Data from
|
|
* all the super globals $_GET, $_POST, $_COOKIE, and $_FILES
|
|
* are stored and accessible via the Request object.
|
|
*
|
|
* @license MIT, http://flightphp.com/license
|
|
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
|
|
*
|
|
* The default request properties are:
|
|
*
|
|
* - **url** - The URL being requested
|
|
* - **base** - The parent subdirectory of the URL
|
|
* - **method** - The request method (GET, POST, PUT, DELETE)
|
|
* - **referrer** - The referrer URL
|
|
* - **ip** - IP address of the client
|
|
* - **ajax** - Whether the request is an AJAX request
|
|
* - **scheme** - The server protocol (http, https)
|
|
* - **user_agent** - Browser information
|
|
* - **type** - The content type
|
|
* - **length** - The content length
|
|
* - **query** - Query string parameters
|
|
* - **data** - Post parameters
|
|
* - **cookies** - Cookie parameters
|
|
* - **files** - Uploaded files
|
|
* - **secure** - Connection is secure
|
|
* - **accept** - HTTP accept parameters
|
|
* - **proxy_ip** - Proxy IP address of the client
|
|
* - **host** - The hostname from the request.
|
|
* - **servername** - The server's hostname. See `$_SERVER['SERVER_NAME']`.
|
|
*/
|
|
class Request
|
|
{
|
|
/**
|
|
* URL being requested
|
|
*/
|
|
public string $url;
|
|
|
|
/**
|
|
* Parent subdirectory of the URL
|
|
*/
|
|
public string $base;
|
|
|
|
/**
|
|
* Request method (GET, POST, PUT, DELETE)
|
|
*/
|
|
public string $method;
|
|
|
|
/**
|
|
* Referrer URL
|
|
*/
|
|
public string $referrer;
|
|
|
|
/**
|
|
* IP address of the client
|
|
*/
|
|
public string $ip;
|
|
|
|
/**
|
|
* Whether the request is an AJAX request
|
|
*/
|
|
public bool $ajax;
|
|
|
|
/**
|
|
* Server protocol (http, https)
|
|
*/
|
|
public string $scheme;
|
|
|
|
/**
|
|
* Browser information
|
|
*/
|
|
public string $user_agent;
|
|
|
|
/**
|
|
* Content type
|
|
*/
|
|
public string $type;
|
|
|
|
/**
|
|
* Content length
|
|
*/
|
|
public int $length;
|
|
|
|
/**
|
|
* Query string parameters
|
|
*/
|
|
public Collection $query;
|
|
|
|
/**
|
|
* Post parameters
|
|
*/
|
|
public Collection $data;
|
|
|
|
/**
|
|
* Cookie parameters
|
|
*/
|
|
public Collection $cookies;
|
|
|
|
/**
|
|
* Uploaded files
|
|
*/
|
|
public Collection $files;
|
|
|
|
/**
|
|
* Whether the connection is secure
|
|
*/
|
|
public bool $secure;
|
|
|
|
/**
|
|
* HTTP accept parameters
|
|
*/
|
|
public string $accept;
|
|
|
|
/**
|
|
* Proxy IP address of the client
|
|
*/
|
|
public string $proxy_ip;
|
|
|
|
/**
|
|
* HTTP host name
|
|
*/
|
|
public string $host;
|
|
|
|
/**
|
|
* Server name
|
|
*
|
|
* CAUTION: Note: Under Apache 2, UseCanonicalName = On and ServerName must be set.
|
|
* Otherwise, this value reflects the hostname supplied by the client, which can be spoofed.
|
|
* It is not safe to rely on this value in security-dependent contexts.
|
|
*/
|
|
public string $servername;
|
|
|
|
/**
|
|
* Stream path for where to pull the request body from
|
|
*/
|
|
private string $stream_path = 'php://input';
|
|
|
|
/**
|
|
* Raw HTTP request 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.
|
|
*
|
|
* @param array<string, mixed> $config Request configuration
|
|
*/
|
|
public function __construct(array $config = [])
|
|
{
|
|
// Default properties
|
|
if (empty($config) === true) {
|
|
$scheme = $this->getScheme();
|
|
$url = $this->getVar('REQUEST_URI', '/');
|
|
if (strpos($url, '@') !== false) {
|
|
$url = str_replace('@', '%40', $url);
|
|
}
|
|
$base = $this->getVar('SCRIPT_NAME', '');
|
|
if (strpos($base, ' ') !== false || strpos($base, '\\') !== false) {
|
|
$base = str_replace(['\\', ' '], ['/', '%20'], $base);
|
|
}
|
|
$base = dirname($base);
|
|
$config = [
|
|
'url' => $url,
|
|
'base' => $base,
|
|
'method' => $this->getMethod(),
|
|
'referrer' => $this->getVar('HTTP_REFERER'),
|
|
'ip' => $this->getVar('REMOTE_ADDR'),
|
|
'ajax' => $this->getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest',
|
|
'scheme' => $scheme,
|
|
'user_agent' => $this->getVar('HTTP_USER_AGENT'),
|
|
'type' => $this->getVar('CONTENT_TYPE'),
|
|
'length' => intval($this->getVar('CONTENT_LENGTH', 0)),
|
|
'query' => new Collection($_GET),
|
|
'data' => new Collection($_POST),
|
|
'cookies' => new Collection($_COOKIE),
|
|
'files' => new Collection($_FILES),
|
|
'secure' => $scheme === 'https',
|
|
'accept' => $this->getVar('HTTP_ACCEPT'),
|
|
'proxy_ip' => $this->getProxyIpAddress(),
|
|
'host' => $this->getVar('HTTP_HOST'),
|
|
'servername' => $this->getVar('SERVER_NAME', ''),
|
|
];
|
|
}
|
|
|
|
$this->init($config);
|
|
}
|
|
|
|
/**
|
|
* Initialize request properties.
|
|
*
|
|
* @param array<string, mixed> $properties Array of request properties
|
|
*
|
|
* @return self
|
|
*/
|
|
public function init(array $properties = []): self
|
|
{
|
|
// Set all the defined properties
|
|
foreach ($properties as $name => $value) {
|
|
$this->{$name} = $value;
|
|
}
|
|
|
|
// Get the requested URL without the base directory
|
|
// This rewrites the url in case the public url and base directories match
|
|
// (such as installing on a subdirectory in a web server)
|
|
// @see testInitUrlSameAsBaseDirectory
|
|
if ($this->base !== '/' && $this->base !== '' && strpos($this->url, $this->base) === 0) {
|
|
$this->url = substr($this->url, strlen($this->base));
|
|
}
|
|
|
|
// Default url
|
|
if (empty($this->url) === true) {
|
|
$this->url = '/';
|
|
} else {
|
|
// Merge URL query parameters with $_GET
|
|
$_GET = array_merge($_GET, self::parseQuery($this->url));
|
|
|
|
$this->query->setData($_GET);
|
|
}
|
|
|
|
// Check for JSON input
|
|
if (strpos($this->type, 'application/json') === 0) {
|
|
$body = $this->getBody();
|
|
if ($body !== '') {
|
|
$data = json_decode($body, true);
|
|
if (is_array($data) === true) {
|
|
$this->data->setData($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) {
|
|
$this->parseRequestBodyForHttpMethods();
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Gets the body of the request.
|
|
*
|
|
* @return string Raw HTTP request body
|
|
*/
|
|
public function getBody(): string
|
|
{
|
|
$body = $this->body;
|
|
|
|
if ($body !== '') {
|
|
return $body;
|
|
}
|
|
|
|
$method = $this->method ?? $this->getMethod();
|
|
|
|
if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true) === true) {
|
|
$body = file_get_contents($this->stream_path);
|
|
}
|
|
|
|
$this->body = $body;
|
|
|
|
return $body;
|
|
}
|
|
|
|
/**
|
|
* Gets the request method.
|
|
*/
|
|
public static function getMethod(): string
|
|
{
|
|
$method = self::getVar('REQUEST_METHOD', 'GET');
|
|
|
|
if (self::getVar('HTTP_X_HTTP_METHOD_OVERRIDE') !== '') {
|
|
$method = self::getVar('HTTP_X_HTTP_METHOD_OVERRIDE');
|
|
} elseif (isset($_REQUEST['_method']) === true) {
|
|
$method = $_REQUEST['_method'];
|
|
}
|
|
|
|
return strtoupper($method);
|
|
}
|
|
|
|
/**
|
|
* Gets the real remote IP address.
|
|
*
|
|
* @return string IP address
|
|
*/
|
|
public static function getProxyIpAddress(): string
|
|
{
|
|
$forwarded = [
|
|
'HTTP_CLIENT_IP',
|
|
'HTTP_X_FORWARDED_FOR',
|
|
'HTTP_X_FORWARDED',
|
|
'HTTP_X_CLUSTER_CLIENT_IP',
|
|
'HTTP_FORWARDED_FOR',
|
|
'HTTP_FORWARDED',
|
|
];
|
|
|
|
$flags = \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE;
|
|
|
|
foreach ($forwarded as $key) {
|
|
$serverVar = self::getVar($key);
|
|
if ($serverVar !== '') {
|
|
sscanf($serverVar, '%[^,]', $ip);
|
|
if (filter_var($ip, \FILTER_VALIDATE_IP, $flags) !== false) {
|
|
return $ip;
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Gets a variable from $_SERVER using $default if not provided.
|
|
*
|
|
* @param string $var Variable name
|
|
* @param mixed $default Default value to substitute
|
|
*
|
|
* @return mixed Server variable value
|
|
*/
|
|
public static function getVar(string $var, $default = '')
|
|
{
|
|
return $_SERVER[$var] ?? $default;
|
|
}
|
|
|
|
/**
|
|
* This will pull a header from the request.
|
|
*
|
|
* @param string $header Header name. Can be caps, lowercase, or mixed.
|
|
* @param string $default Default value if the header does not exist
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function getHeader(string $header, $default = ''): string
|
|
{
|
|
$header = 'HTTP_' . strtoupper(str_replace('-', '_', $header));
|
|
return self::getVar($header, $default);
|
|
}
|
|
|
|
/**
|
|
* Gets all the request headers
|
|
*
|
|
* @return array<string, string|int>
|
|
*/
|
|
public static function getHeaders(): array
|
|
{
|
|
$headers = [];
|
|
foreach ($_SERVER as $key => $value) {
|
|
if (strpos($key, 'HTTP_') === 0) {
|
|
// converts headers like HTTP_CUSTOM_HEADER to Custom-Header
|
|
$key = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5)))));
|
|
$headers[$key] = $value;
|
|
}
|
|
}
|
|
return $headers;
|
|
}
|
|
|
|
/**
|
|
* Alias of Request->getHeader(). Gets a single header.
|
|
*
|
|
* @param string $header Header name. Can be caps, lowercase, or mixed.
|
|
* @param string $default Default value if the header does not exist
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function header(string $header, $default = '')
|
|
{
|
|
return self::getHeader($header, $default);
|
|
}
|
|
|
|
/**
|
|
* Alias of Request->getHeaders(). Gets all the request headers
|
|
*
|
|
* @return array<string, string|int>
|
|
*/
|
|
public static function headers(): array
|
|
{
|
|
return self::getHeaders();
|
|
}
|
|
|
|
/**
|
|
* Gets the full request URL.
|
|
*
|
|
* @return string URL
|
|
*/
|
|
public function getFullUrl(): string
|
|
{
|
|
return $this->scheme . '://' . $this->host . $this->url;
|
|
}
|
|
|
|
/**
|
|
* Grabs the scheme and host. Does not end with a /
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getBaseUrl(): string
|
|
{
|
|
return $this->scheme . '://' . $this->host;
|
|
}
|
|
|
|
/**
|
|
* Parse query parameters from a URL.
|
|
*
|
|
* @param string $url URL string
|
|
*
|
|
* @return array<string, int|string|array<int|string, int|string>>
|
|
*/
|
|
public static function parseQuery(string $url): array
|
|
{
|
|
$queryPos = strpos($url, '?');
|
|
if ($queryPos === false) {
|
|
return [];
|
|
}
|
|
$query = substr($url, $queryPos + 1);
|
|
if ($query === '') {
|
|
return [];
|
|
}
|
|
$params = [];
|
|
parse_str($query, $params);
|
|
return $params;
|
|
}
|
|
|
|
/**
|
|
* Gets the URL Scheme
|
|
*
|
|
* @return string 'http'|'https'
|
|
*/
|
|
public static function getScheme(): string
|
|
{
|
|
if (
|
|
(strtolower(self::getVar('HTTPS')) === 'on')
|
|
||
|
|
(self::getVar('HTTP_X_FORWARDED_PROTO') === 'https')
|
|
||
|
|
(self::getVar('HTTP_FRONT_END_HTTPS') === 'on')
|
|
||
|
|
(self::getVar('REQUEST_SCHEME') === 'https')
|
|
) {
|
|
return 'https';
|
|
}
|
|
|
|
return 'http';
|
|
}
|
|
|
|
/**
|
|
* Negotiates the best content type from the Accept header.
|
|
*
|
|
* @param array<int, string> $supported List of supported content types.
|
|
*
|
|
* @return ?string The negotiated content type.
|
|
*/
|
|
public function negotiateContentType(array $supported): ?string
|
|
{
|
|
$accept = $this->header('Accept') ?? '';
|
|
if ($accept === '') {
|
|
return $supported[0];
|
|
}
|
|
foreach ($supported as $type) {
|
|
if (stripos($accept, $type) !== false) {
|
|
return $type;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Retrieves 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
|
|
{
|
|
$uploadedFiles = [];
|
|
$correctedFilesArray = $this->reArrayFiles($this->files);
|
|
foreach ($correctedFilesArray as $keyName => $files) {
|
|
// Check if original data was array format (files_name[] style)
|
|
$originalFile = $this->files->getData()[$keyName] ?? null;
|
|
$isArrayFormat = $originalFile && is_array($originalFile['name']);
|
|
|
|
foreach ($files as $file) {
|
|
$UploadedFile = new UploadedFile(
|
|
$file['name'],
|
|
$file['type'],
|
|
$file['size'],
|
|
$file['tmp_name'],
|
|
$file['error']
|
|
);
|
|
|
|
// Always use array format if original data was array, regardless of count
|
|
if ($isArrayFormat === true) {
|
|
$uploadedFiles[$keyName][] = $UploadedFile;
|
|
} else {
|
|
$uploadedFiles[$keyName] = $UploadedFile;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $uploadedFiles;
|
|
}
|
|
|
|
/**
|
|
* Re-arranges the files in the given files collection.
|
|
*
|
|
* @param Collection $filesCollection The collection of files to be re-arranged.
|
|
*
|
|
* @return array<string, array<int, array<string, mixed>>> The re-arranged files collection.
|
|
*/
|
|
protected function reArrayFiles(Collection $filesCollection): array
|
|
{
|
|
$fileArray = [];
|
|
foreach ($filesCollection as $fileKeyName => $file) {
|
|
$isMulti = is_array($file['name']) === true;
|
|
$fileCount = $isMulti === true ? count($file['name']) : 1;
|
|
$fileKeys = array_keys($file);
|
|
|
|
for ($i = 0; $i < $fileCount; $i++) {
|
|
foreach ($fileKeys as $key) {
|
|
if ($isMulti === true) {
|
|
$fileArray[$fileKeyName][$i][$key] = $file[$key][$i];
|
|
} else {
|
|
$fileArray[$fileKeyName][$i][$key] = $file[$key];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $fileArray;
|
|
}
|
|
|
|
/**
|
|
* Parse request body data for HTTP methods that don't natively support form data (PUT, DELETE, PATCH)
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function parseRequestBodyForHttpMethods(): void
|
|
{
|
|
$body = $this->getBody();
|
|
|
|
// Empty body
|
|
if ($body === '') {
|
|
return;
|
|
}
|
|
|
|
// Check Content-Type for multipart/form-data
|
|
$contentType = strtolower(trim($this->type));
|
|
$isMultipart = strpos($contentType, 'multipart/form-data') === 0;
|
|
$boundary = null;
|
|
|
|
if ($isMultipart === true) {
|
|
// Extract boundary more safely
|
|
if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) {
|
|
$boundary = $matches[2];
|
|
}
|
|
|
|
// If no boundary found, it's not valid multipart
|
|
if (empty($boundary)) {
|
|
$isMultipart = false;
|
|
}
|
|
|
|
$firstLine = strtok($body, "\r\n");
|
|
if ($firstLine === false || strpos($firstLine, '--' . $boundary) !== 0) {
|
|
// Does not start with the boundary marker; fall back
|
|
$isMultipart = false;
|
|
}
|
|
}
|
|
|
|
// Parse application/x-www-form-urlencoded
|
|
if ($isMultipart === false) {
|
|
parse_str($body, $data);
|
|
$this->data->setData($data);
|
|
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
|
|
$bodyParts = preg_split('/\R?-+' . preg_quote($boundary, '/') . '/s', $body);
|
|
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) {
|
|
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;
|
|
}
|
|
[$header, $value] = $split;
|
|
|
|
// Fast header sanity checks
|
|
if (stripos($header, 'content-disposition') === false) {
|
|
continue;
|
|
}
|
|
if (strlen($header) > 16384) { // 16KB header block guard
|
|
continue;
|
|
}
|
|
|
|
$value = ltrim($value, "\r\n");
|
|
|
|
// Parse headers (simple approach, fail-fast on anomalies)
|
|
$headers = $this->parseRequestBodyHeadersFromMultipartFormData($header);
|
|
|
|
// Required disposition/name
|
|
if (isset($headers['content-disposition']['name']) === false) {
|
|
continue;
|
|
}
|
|
$keyName = str_replace('[]', '', (string) $headers['content-disposition']['name']);
|
|
if ($keyName === '') {
|
|
continue; // avoid empty keys
|
|
}
|
|
|
|
// Non-file field
|
|
if (isset($headers['content-disposition']['filename']) === false) {
|
|
if (isset($data[$keyName]) === false) {
|
|
$data[$keyName] = $value;
|
|
} else {
|
|
if (is_array($data[$keyName]) === false) {
|
|
$data[$keyName] = [$data[$keyName]];
|
|
}
|
|
$data[$keyName][] = $value;
|
|
}
|
|
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 = [
|
|
'name' => $sanitizedFilename,
|
|
'type' => $headers['content-type'] ?? 'application/octet-stream',
|
|
'size' => $size,
|
|
'tmp_name' => '',
|
|
'error' => UPLOAD_ERR_OK,
|
|
];
|
|
|
|
// Fail fast on size constraints
|
|
if ($size > $this->getUploadMaxFileSize() || $filesTotalBytes > $maxTotalBytes) {
|
|
// individual file or total size exceeded
|
|
$tmpFile['error'] = UPLOAD_ERR_INI_SIZE; // @codeCoverageIgnore
|
|
} else {
|
|
$tempResult = $this->createTempFile($value);
|
|
$tmpFile['tmp_name'] = $tempResult['tmp_name'];
|
|
$tmpFile['error'] = $tempResult['error'];
|
|
}
|
|
|
|
// Aggregate into synthetic files array
|
|
foreach ($tmpFile as $metaKey => $metaVal) {
|
|
if (!isset($file[$keyName][$metaKey])) {
|
|
$file[$keyName][$metaKey] = $metaVal;
|
|
continue;
|
|
}
|
|
if (!is_array($file[$keyName][$metaKey])) {
|
|
$file[$keyName][$metaKey] = [$file[$keyName][$metaKey]];
|
|
}
|
|
$file[$keyName][$metaKey][] = $metaVal;
|
|
}
|
|
}
|
|
|
|
$this->data->setData($data);
|
|
$this->files->setData($file);
|
|
}
|
|
|
|
/**
|
|
* 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];
|
|
}
|
|
}
|
|
} else {
|
|
$headers[$headerKey] = $headerValue;
|
|
}
|
|
}
|
|
return $headers;
|
|
}
|
|
|
|
/**
|
|
* Get the maximum file size that can be uploaded.
|
|
*
|
|
* @return int The maximum file size in bytes.
|
|
*/
|
|
public function getUploadMaxFileSize(): int
|
|
{
|
|
$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);
|
|
|
|
// No unit => follow existing behavior and return value directly if > 1024 (1K)
|
|
if ($unit === '' && $value >= 1024) {
|
|
return $value;
|
|
}
|
|
|
|
switch ($unit) {
|
|
case 't':
|
|
case 'tb':
|
|
$value *= 1024; // Fall through
|
|
case 'g':
|
|
case 'gb':
|
|
$value *= 1024; // Fall through
|
|
case 'm':
|
|
case 'mb':
|
|
$value *= 1024; // Fall through
|
|
case 'k':
|
|
$value *= 1024;
|
|
break;
|
|
default:
|
|
return 0;
|
|
}
|
|
|
|
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];
|
|
}
|
|
}
|