<?php
/*
* This file is part of the MultiPartParser package.
*
* (c) Romain Cambien <romain@cambien.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Riverline\MultiPartParser;
/**
* Class StreamedPart
*/
class StreamedPart
{
/**
* @var resource
*/
private $stream;
/**
* @var array
*/
private $headers;
/**
* @var int
*/
private $bodyOffset;
/**
* @var StreamedPart[]
*/
private $parts = array();
/**
* The length of the EOL character.
*
* @var int
*/
private $EOLCharacterLength;
/**
* StreamParser constructor.
*
* @param resource $stream
* @param int $EOLCharacterLength
*/
public function __construct($stream, $EOLCharacterLength = 2)
{
if (false === is_resource($stream)) {
throw new \InvalidArgumentException('Input is not a stream');
}
if (false === is_integer($EOLCharacterLength)) {
throw new \InvalidArgumentException('EOL Length is not an integer');
}
$this->stream = $stream;
$this->EOLCharacterLength = $EOLCharacterLength;
// Reset the stream
rewind($this->stream);
// Parse headers
$endOfHeaders = false;
$bufferSize = 8192;
$headerLines = [];
$buffer = '';
while (false !== ($line = fgets($this->stream, $bufferSize))) {
// Append to buffer
$buffer .= rtrim($line, "\r\n");
if (strlen($line) === $bufferSize-1) {
// EOL not reached, continue
continue;
}
if ('' === $buffer) {
// Empty line cause by double new line, we reached the end of the headers section
$endOfHeaders = true;
break;
}
// Detect horizontal whitescapes before header
$trimmed = ltrim($buffer);
if (strlen($buffer) > strlen($trimmed)) {
// Multi lines header, append to previous line
$headerLines[count($headerLines)-1] .= "\x20".$trimmed;
} else {
$headerLines[] = $buffer;
}
// Reset buffer
$buffer = '';
}
if (false === $endOfHeaders) {
throw new \InvalidArgumentException('Content is not valid');
}
$this->headers = [];
foreach ($headerLines as $line) {
$lineSplit = explode(':', $line, 2);
if (2 === count($lineSplit)) {
list($key, $value) = $lineSplit;
// Decode value
$value = mb_decode_mimeheader(trim($value));
} else {
// Bogus header
$key = $lineSplit[0];
$value = '';
}
// Case-insensitive key
$key = strtolower($key);
if (false === key_exists($key, $this->headers)) {
$this->headers[$key] = $value;
} else {
// Already got an header with this key, convert to array
if (false === is_array($this->headers[$key])) {
$this->headers[$key] = (array) $this->headers[$key];
}
$this->headers[$key][] = $value;
}
}
$this->bodyOffset = ftell($stream);
// Is MultiPart ?
if ($this->isMultiPart()) {
// MultiPart !
$boundary = self::getHeaderOption($this->getHeader('Content-Type'), 'boundary');
if (null === $boundary) {
throw new \InvalidArgumentException("Can't find boundary in content type");
}
$separator = '--'.$boundary;
$partOffset = 0;
$endOfBody = false;
while ($line = fgets($this->stream, $bufferSize)) {
$trimmed = rtrim($line, "\r\n");
// Search the separator
if ($trimmed === $separator || $trimmed === $separator.'--') {
if ($partOffset > 0) {
$currentOffset = ftell($this->stream);
// Get end of line length (should be 2)
$eofLength = strlen($line) - strlen($trimmed);
$partLength = $currentOffset - $partOffset - strlen($trimmed) - (2 * $eofLength);
// if we are at the end of a part, and there is no trailing new line ($eofLength == 0)
// means that we are also at the end of the stream.
// we do not know if $eofLength is 1 or two, so we'll use the EOLCharacterLength value
// which is 2 by default.
if ($eofLength === 0 && feof($this->stream)) {
$partLength = $currentOffset - $partOffset - strlen($line) - $this->EOLCharacterLength;
}
// Copy part in a new stream
$partStream = fopen('php://temp', 'rw');
stream_copy_to_stream($this->stream, $partStream, $partLength, $partOffset);
$this->parts[] = new self($partStream, $this->EOLCharacterLength);
// Reset current stream offset
fseek($this->stream, $currentOffset);
}
if ($trimmed === $separator.'--') {
// We reach the end separator
$endOfBody = true;
break;
}
// Update the part offset
$partOffset = ftell($this->stream);
}
}
if (0 === count($this->parts)
|| false === $endOfBody
) {
throw new \LogicException("Can't find multi-part content");
}
}
}
/**
* @return bool
*/
public function isMultiPart()
{
return ('multipart' === mb_strstr(
self::getHeaderValue($this->getHeader('Content-Type')),
'/',
true
));
}
/**
* @return string
*
* @throws \LogicException if is multipart
*/
public function getBody()
{
if ($this->isMultiPart()) {
throw new \LogicException("MultiPart content, there aren't body");
}
$body = stream_get_contents($this->stream, -1, $this->bodyOffset);
// Decode
$encoding = strtolower((string) $this->getHeader('Content-Transfer-Encoding'));
switch ($encoding) {
case 'base64':
$body = base64_decode($body);
break;
case 'quoted-printable':
$body = quoted_printable_decode($body);
break;
}
// Convert to UTF-8 ( Not if binary or 7bit ( aka Ascii ) )
if (false === in_array($encoding, array('binary', '7bit'))) {
// Charset
$contentType = $this->getHeader('Content-Type');
$charset = self::getHeaderOption($contentType, 'charset');
if (null === $charset) {
// Try to detect
$charset = mb_detect_encoding($body) ?: 'utf-8';
}
// Only convert if not UTF-8
if ('utf-8' !== strtolower($charset)) {
$body = mb_convert_encoding($body, 'utf-8', $charset);
}
}
return $body;
}
/**
* @return array
*/
public function getHeaders()
{
return $this->headers;
}
/**
* @param string $key
*
* @param mixed $default
*
* @return mixed
*/
public function getHeader($key, $default = null)
{
// Case-insensitive key
$key = strtolower($key);
if (false === isset($this->headers[$key])) {
return $default;
}
return $this->headers[$key];
}
/**
* @param string $header
*
* @return string
*/
public static function getHeaderValue($header)
{
list($value) = self::parseHeaderContent($header);
return $value;
}
/**
* @param string $header
*
* @return array
*/
public static function getHeaderOptions($header)
{
list(, $options) = self::parseHeaderContent($header);
return $options;
}
/**
* @param string $header
* @param string $key
*
* @param mixed $default
*
* @return mixed
*/
public static function getHeaderOption($header, $key, $default = null)
{
$options = self::getHeaderOptions($header);
if (false === isset($options[$key])) {
return $default;
}
return $options[$key];
}
/**
* @return string
*/
public function getMimeType()
{
// Find Content-Disposition
$contentType = $this->getHeader('Content-Type');
return self::getHeaderValue($contentType) ?: 'application/octet-stream';
}
/**
* @return string|null
*/
public function getName()
{
// Find Content-Disposition
$contentDisposition = $this->getHeader('Content-Disposition');
return self::getHeaderOption($contentDisposition, 'name');
}
/**
* @return string|null
*/
public function getFileName()
{
// Find Content-Disposition
$contentDisposition = $this->getHeader('Content-Disposition');
return self::getHeaderOption($contentDisposition, 'filename');
}
/**
* @return bool
*/
public function isFile()
{
return (false === is_null($this->getFileName()));
}
/**
* @return StreamedPart[]
*
* @throws \LogicException if is not multipart
*/
public function getParts()
{
if (false === $this->isMultiPart()) {
throw new \LogicException("Not MultiPart content, there aren't any parts");
}
return $this->parts;
}
/**
* @param string $name
*
* @return Part[]
*
* @throws \LogicException if is not multipart
*/
public function getPartsByName($name)
{
$parts = array();
foreach ($this->getParts() as $part) {
if ($part->getName() === $name) {
$parts[] = $part;
}
}
return $parts;
}
/**
* @param string $content
*
* @return array
*/
private static function parseHeaderContent($content)
{
$parts = explode(';', (string) $content);
$headerValue = array_shift($parts);
$options = array();
// Parse options
foreach ($parts as $part) {
if (false === empty($part)) {
$partSplit = explode('=', $part, 2);
if (2 === count($partSplit)) {
list ($key, $value) = $partSplit;
if ('*' === substr($key, -1)) {
// RFC 5987
$key = substr($key, 0, -1);
if (preg_match(
"/(?P<charset>[\w!#$%&+^_`{}~-]+)'(?P<language>[\w-]*)'(?P<value>.*)$/",
$value,
$matches
)) {
$value = mb_convert_encoding(
rawurldecode($matches['value']),
'utf-8',
$matches['charset']
);
}
}
$options[trim($key)] = trim($value, ' "');
} else {
// Bogus option
$options[$partSplit[0]] = '';
}
}
}
return array($headerValue, $options);
}
}