diff --git a/CHANGELOG.md b/CHANGELOG.md index e2934c88..5e66aa75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ All notable changes to this project will be documented in this file, in reverse - [#75](https://github.com/laminas/laminas-mail/pull/75) fixes how `Laminas\Mail\Header\ListParser::parse()` parses the string with quotes. +- [#31](https://github.com/laminas/laminas-mail/pull/31) Properly encode `content-disposition` header. + - [#88](https://github.com/laminas/laminas-mail/pull/88) fixes recognising encoding of `Subject` and `GenericHeader` headers. ## 2.10.0 - 2018-06-07 diff --git a/src/Header/ContentDisposition.php b/src/Header/ContentDisposition.php new file mode 100644 index 00000000..5594afb8 --- /dev/null +++ b/src/Header/ContentDisposition.php @@ -0,0 +1,296 @@ +setDisposition($parts[0]); + + if (isset($parts[1])) { + $values = ListParser::parse(trim($parts[1]), [';', '=']); + $length = count($values); + $continuedValues = []; + + for ($i = 0; $i < $length; $i += 2) { + $value = $values[$i + 1]; + $value = trim($value, "'\" \t\n\r\0\x0B"); + $name = trim($values[$i], "'\" \t\n\r\0\x0B"); + + if (strpos($name, '*')) { + list($name, $count) = explode('*', $name); + if (! isset($continuedValues[$name])) { + $continuedValues[$name] = []; + } + $continuedValues[$name][$count] = $value; + } else { + $header->setParameter($name, $value); + } + } + + foreach ($continuedValues as $name => $values) { + $value = ''; + for ($i = 0; $i < count($values); $i++) { + if (! isset($values[$i])) { + throw new Exception\InvalidArgumentException( + 'Invalid header line for Content-Disposition string - incomplete continuation' + ); + } + $value .= $values[$i]; + } + $header->setParameter($name, $value); + } + } + + return $header; + } + + /** + * @inheritDoc + */ + public function getFieldName() + { + return 'Content-Disposition'; + } + + /** + * @inheritDoc + */ + public function getFieldValue($format = HeaderInterface::FORMAT_RAW) + { + $result = $this->disposition; + if (empty($this->parameters)) { + return $result; + } + + foreach ($this->parameters as $attribute => $value) { + $valueIsEncoded = false; + if (HeaderInterface::FORMAT_ENCODED === $format && ! Mime::isPrintable($value)) { + $value = $this->getEncodedValue($value); + $valueIsEncoded = true; + } + + $line = sprintf('%s="%s"', $attribute, $value); + + if (strlen($line) < self::MAX_PARAMETER_LENGTH) { + $lines = explode(Headers::FOLDING, $result); + + if (count($lines) === 1) { + $existingLineLength = strlen('Content-Disposition: ' . $result); + } else { + $existingLineLength = 1 + strlen($lines[count($lines) - 1]); + } + + if ((2 + $existingLineLength + strlen($line)) <= self::MAX_PARAMETER_LENGTH) { + $result .= '; ' . $line; + } else { + $result .= ';' . Headers::FOLDING . $line; + } + } else { + // Use 'continuation' per RFC 2231 + $maxValueLength = strlen($value); + do { + $maxValueLength = ceil(0.6 * $maxValueLength); + } while ($maxValueLength > self::MAX_PARAMETER_LENGTH); + + if ($valueIsEncoded) { + $encodedLength = strlen($value); + $value = HeaderWrap::mimeDecodeValue($value); + $decodedLength = strlen($value); + $maxValueLength -= ($encodedLength - $decodedLength); + } + + $valueParts = str_split($value, $maxValueLength); + $i = 0; + foreach ($valueParts as $valuePart) { + $attributePart = $attribute . '*' . $i++; + if ($valueIsEncoded) { + $valuePart = $this->getEncodedValue($valuePart); + } + $result .= sprintf(';%s%s="%s"', Headers::FOLDING, $attributePart, $valuePart); + } + } + } + + return $result; + } + + /** + * @param string $value + * @return string + */ + protected function getEncodedValue($value) + { + $configuredEncoding = $this->encoding; + $this->encoding = 'UTF-8'; + $value = HeaderWrap::wrap($value, $this); + $this->encoding = $configuredEncoding; + return $value; + } + + /** + * @inheritDoc + */ + public function setEncoding($encoding) + { + $this->encoding = $encoding; + return $this; + } + + /** + * @inheritDoc + */ + public function getEncoding() + { + return $this->encoding; + } + + /** + * @inheritDoc + */ + public function toString() + { + return 'Content-Disposition: ' . $this->getFieldValue(HeaderInterface::FORMAT_ENCODED); + } + + /** + * Set the content disposition + * Expected values include 'inline', 'attachment' + * + * @param string $disposition + * @return ContentDisposition + */ + public function setDisposition($disposition) + { + $this->disposition = strtolower($disposition); + return $this; + } + + /** + * Retrieve the content disposition + * + * @return string + */ + public function getDisposition() + { + return $this->disposition; + } + + /** + * Add a parameter pair + * + * @param string $name + * @param string $value + * @return ContentDisposition + */ + public function setParameter($name, $value) + { + $name = strtolower($name); + + if (! HeaderValue::isValid($name)) { + throw new Exception\InvalidArgumentException( + 'Invalid content-disposition parameter name detected' + ); + } + // '5' here is for the quotes & equal sign in `name="value"`, + // and the space & semicolon for line folding + if ((strlen($name) + 5) >= self::MAX_PARAMETER_LENGTH) { + throw new Exception\InvalidArgumentException( + 'Invalid content-disposition parameter name detected (too long)' + ); + } + + $this->parameters[$name] = $value; + return $this; + } + + /** + * Get all parameters + * + * @return array + */ + public function getParameters() + { + return $this->parameters; + } + + /** + * Get a parameter by name + * + * @param string $name + * @return null|string + */ + public function getParameter($name) + { + $name = strtolower($name); + if (isset($this->parameters[$name])) { + return $this->parameters[$name]; + } + return null; + } + + /** + * Remove a named parameter + * + * @param string $name + * @return bool + */ + public function removeParameter($name) + { + $name = strtolower($name); + if (isset($this->parameters[$name])) { + unset($this->parameters[$name]); + return true; + } + return false; + } +} diff --git a/src/Header/HeaderLoader.php b/src/Header/HeaderLoader.php index 8a71735b..822abec9 100644 --- a/src/Header/HeaderLoader.php +++ b/src/Header/HeaderLoader.php @@ -21,6 +21,9 @@ class HeaderLoader extends PluginClassLoader protected $plugins = [ 'bcc' => 'Laminas\Mail\Header\Bcc', 'cc' => 'Laminas\Mail\Header\Cc', + 'contentdisposition' => 'Laminas\Mail\Header\ContentDisposition', + 'content_disposition' => 'Laminas\Mail\Header\ContentDisposition', + 'content-disposition' => 'Laminas\Mail\Header\ContentDisposition', 'contenttype' => 'Laminas\Mail\Header\ContentType', 'content_type' => 'Laminas\Mail\Header\ContentType', 'content-type' => 'Laminas\Mail\Header\ContentType', diff --git a/test/Header/ContentDispositionTest.php b/test/Header/ContentDispositionTest.php new file mode 100644 index 00000000..e62f2774 --- /dev/null +++ b/test/Header/ContentDispositionTest.php @@ -0,0 +1,249 @@ + + */ +class ContentDispositionTest extends TestCase +{ + public function testImplementsHeaderInterface() + { + $header = new ContentDisposition(); + + $this->assertInstanceOf(UnstructuredInterface::class, $header); + $this->assertInstanceOf(HeaderInterface::class, $header); + } + + public function testTrailingSemiColonFromString() + { + $contentTypeHeader = ContentDisposition::fromString( + 'Content-Disposition: attachment; filename="test-case.txt";' + ); + $params = $contentTypeHeader->getParameters(); + $this->assertEquals(['filename' => 'test-case.txt'], $params); + } + + public static function getLiteralData() + { + return [ + [ + ['filename' => 'foo; bar.txt'], + 'attachment; filename="foo; bar.txt"' + ], + [ + ['filename' => 'foo&bar.txt'], + 'attachment; filename="foo&bar.txt"' + ], + [ + [], + 'inline' + ], + ]; + } + + /** + * @dataProvider getLiteralData + */ + public function testHandlesLiterals($expected, $header) + { + $header = ContentDisposition::fromString('Content-Disposition: ' . $header); + $this->assertEquals($expected, $header->getParameters()); + } + + /** + * @dataProvider setDispositionProvider + */ + public function testFromString($disposition, $parameters, $fieldValue, $expectedToString) + { + $header = ContentDisposition::fromString($expectedToString); + + $this->assertInstanceOf(ContentDisposition::class, $header); + $this->assertEquals('Content-Disposition', $header->getFieldName(), 'getFieldName() value not match'); + $this->assertEquals($disposition, $header->getDisposition(), 'getDisposition() value not match'); + $this->assertEquals($fieldValue, $header->getFieldValue(), 'getFieldValue() value not match'); + $this->assertEquals($parameters, $header->getParameters(), 'getParameters() value not match'); + $this->assertEquals($expectedToString, $header->toString(), 'toString() value not match'); + } + + /** + * @dataProvider setDispositionProvider + */ + public function testSetDisposition($disposition, $parameters, $fieldValue, $expectedToString) + { + $header = new ContentDisposition(); + + $header->setDisposition($disposition); + foreach ($parameters as $name => $value) { + $header->setParameter($name, $value); + } + + $this->assertEquals('Content-Disposition', $header->getFieldName(), 'getFieldName() value not match'); + $this->assertEquals($disposition, $header->getDisposition(), 'getDisposition() value not match'); + $this->assertEquals($fieldValue, $header->getFieldValue(), 'getFieldValue() value not match'); + $this->assertEquals($parameters, $header->getParameters(), 'getParameters() value not match'); + $this->assertEquals($expectedToString, $header->toString(), 'toString() value not match'); + } + + public function testGetSetEncoding() + { + $header = new ContentDisposition(); + + // default value + $this->assertEquals('ASCII', $header->getEncoding()); + + $header->setEncoding('UTF-8'); + $this->assertEquals('UTF-8', $header->getEncoding()); + + $header->setEncoding('ASCII'); + $this->assertEquals('ASCII', $header->getEncoding()); + } + + /** + * @dataProvider invalidHeaderLinesProvider + */ + public function testFromStringThrowException($headerLine, $expectedException, $exceptionMessage) + { + $this->expectException($expectedException); + $this->expectExceptionMessage($exceptionMessage); + ContentDisposition::fromString($headerLine); + } + + public function testFromStringHandlesContinuations() + { + $header = ContentDisposition::fromString("Content-Disposition: attachment;\r\n level=1"); + $this->assertEquals('attachment', $header->getDisposition()); + $this->assertEquals(['level' => '1'], $header->getParameters()); + } + + /** + * @dataProvider invalidParametersProvider + */ + public function testSetParameterThrowException($paramName, $paramValue, $expectedException, $exceptionMessage) + { + $header = new ContentDisposition(); + $header->setDisposition('attachment'); + + $this->expectException($expectedException); + $this->expectExceptionMessage($exceptionMessage); + $header->setParameter($paramName, $paramValue); + } + + /** + * @dataProvider getParameterProvider + */ + public function testGetParameter($fromString, $paramName, $paramValue) + { + $header = ContentDisposition::fromString($fromString); + $this->assertEquals($paramValue, $header->getParameter($paramName)); + } + + public function testRemoveParameter() + { + $header = ContentDisposition::fromString('Content-Disposition: inline'); + + $this->assertEquals(false, $header->removeParameter('no-such-parameter')); + + $header->setParameter('name', 'value'); + $this->assertEquals(true, $header->removeParameter('name')); + } + + public function setDispositionProvider() + { + // @codingStandardsIgnoreStart + $foldingFieldValue = "attachment;\r\n filename=\"this-test-filename-is-long-enough-to-flow-to-two-lines.txt\""; + $foldingHeaderLine = "Content-Disposition: $foldingFieldValue"; + $continuationFieldValue = "attachment;\r\n filename*0=\"this-file-name-is-so-long-that-it-does-not-even\";\r\n filename*1=\"-fit-on-a-whole-line-by-itself-so-we-need-to-sp\";\r\n filename*2=\"lit-it-with-value-continuation.txt\""; + $continuationHeaderLine = "Content-Disposition: $continuationFieldValue"; + + $encodedHeaderLine = 'Content-Disposition: attachment; filename="=?UTF-8?Q?=C3=93?="'; + $encodedFieldValue = 'attachment; filename="Ó"'; + + return [ + // Description => [$disposition, $parameters, $fieldValue, toString()] + 'inline with no parameters' => ['inline', [], 'inline', 'Content-Disposition: inline'], + 'parameter on one line' => ['inline', ['level' => '1'], 'inline; level="1"' , 'Content-Disposition: inline; level="1"'], + 'parameter use header folding' => [ + 'attachment', + ['filename' => 'this-test-filename-is-long-enough-to-flow-to-two-lines.txt'], + $foldingFieldValue, + $foldingHeaderLine, + ], + 'encoded characters' => ['attachment', ['filename' => 'Ó'], $encodedFieldValue, $encodedHeaderLine], + 'value continuation' => [ + 'attachment', + ['filename' => 'this-file-name-is-so-long-that-it-does-not-even-fit-on-a-whole-line-by-itself-so-we-need-to-split-it-with-value-continuation.txt'], + $continuationFieldValue, + $continuationHeaderLine, + ], + 'multiple simple parameters' => ['inline', ['one' => 1, 'two' => 2], 'inline; one="1"; two="2"', 'Content-Disposition: inline; one="1"; two="2"'], + 'UTF-8 multi-line' => ['attachment', ['filename' => 'nōtes-from-our-mēēting.rtf', 'meeting-chair' => 'Simon', 'attendees' => 'Alice, Bob, Charlie', 'appologies' => 'Mallory'], "attachment; filename=\"nōtes-from-our-mēēting.rtf\";\r\n meeting-chair=\"Simon\"; attendees=\"Alice, Bob, Charlie\";\r\n appologies=\"Mallory\"", "Content-Disposition: attachment;\r\n filename=\"=?UTF-8?Q?n=C5=8Dtes-from-our-m=C4=93=C4=93ting.rtf?=\";\r\n meeting-chair=\"Simon\"; attendees=\"Alice, Bob, Charlie\";\r\n appologies=\"Mallory\""], + 'UTF-8 continuation' => [ + 'attachment', + ['filename' => 'this-file-name-is-so-long-that-it-does-not-even-fit-on-a-whole-line-by-itself-so-we-need-to-split-it-with-value-continuation.also-UTF-8-characters-hērē.txt'], + "attachment;\r\n filename*0=\"this-file-name-is-so-long-that-it-does-not-even-fit-on-a-\";\r\n filename*1=\"whole-line-by-itself-so-we-need-to-split-it-with-value-co\";\r\n filename*2=\"ntinuation.also-UTF-8-characters-hērē.txt\"", + "Content-Disposition: attachment;\r\n filename*0=\"=?UTF-8?Q?this-file-name-is-so-long-that-it-does-not-ev?=\";\r\n filename*1=\"=?UTF-8?Q?en-fit-on-a-whole-line-by-itself-so-we-need-t?=\";\r\n filename*2=\"=?UTF-8?Q?o-split-it-with-value-continuation.also-UTF-8?=\";\r\n filename*3=\"=?UTF-8?Q?-characters-h=C4=93r=C4=93.txt?=\"", + ], + ]; + // @codingStandardsIgnoreEnd + } + + public function invalidParametersProvider() + { + $invalidArgumentException = InvalidArgumentException::class; + + // @codingStandardsIgnoreStart + return [ + // Description => [param name, param value, expected exception, exception message contain] + 'invalid name' => ["b\r\na\rr\n", 'baz', $invalidArgumentException, 'parameter name'], + 'name too long' => ['this-parameter-name-is-so-long-that-it-leaves-no-room-for-any-value-to-be-set', 'too long', $invalidArgumentException, 'too long'], + ]; + // @codingStandardsIgnoreEnd + } + + public function invalidHeaderLinesProvider() + { + $invalidArgumentException = InvalidArgumentException::class; + + // @codingStandardsIgnoreStart + return [ + // Description => [header line, expected exception, exception message contain] + 'wrong-header' => ['Subject: important email', $invalidArgumentException, 'header line'], + 'invalid name' => ['Content-Disposition' . chr(32) . ': inline', $invalidArgumentException, 'header name'], + 'newline' => ["Content-Disposition: inline;\nlevel=1", $invalidArgumentException, 'header value'], + 'cr-lf' => ["Content-Disposition: inline\r\n;level=1", $invalidArgumentException, 'header value'], + 'multiline' => ["Content-Disposition: inline;\r\nlevel=1\r\nq=0.1", $invalidArgumentException, 'header value'], + 'incomplete sequence' => ["Content-Disposition: attachment;\r\n filename*0=\"first-part\";\r\n filename*2=\"third-part\"", $invalidArgumentException, 'incomplete continuation'] + ]; + // @codingStandardsIgnoreEnd + } + + public function getParameterProvider() + { + // @codingStandardsIgnoreStart + return [ + // Description => [from string, parameter name, parameter Value] + 'no such parameter' => ['Content-Disposition: inline', 'no-such-parameter', null], + 'filename' => ['Content-Disposition: attachment; filename="success.txt"', 'filename', 'success.txt'], + 'continued-value' => [ + "Content-Disposition: attachment;\r\n filename*0=\"this-file-name-is-so-long-that-it-does-not-even\";\r\n filename*1=\"-fit-on-a-whole-line-by-itself-so-we-need-to-sp\";\r\n filename*2=\"lit-it-with-value-continuation.txt\"", + 'filename', + 'this-file-name-is-so-long-that-it-does-not-even-fit-on-a-whole-line-by-itself-so-we-need-to-split-it-with-value-continuation.txt', + ] + ]; + // @codingStandardsIgnoreEnd + } +}