Add new optional beta email parser thats based on ImapEngine instead of Webklex

This commit is contained in:
johnnyq
2026-02-26 16:11:49 -05:00
parent 1ba19cc249
commit 9cb1ff7330
682 changed files with 101834 additions and 8 deletions

View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: zbateson

View File

@@ -0,0 +1,37 @@
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
php: [8.3, 8.2, 8.1, 8.0]
stability: [prefer-stable]
name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
coverage: none
- name: Setup problem matchers
run: |
echo "::add-matcher::${{ runner.tool_cache }}/php.json"
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Install dependencies
run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction
- name: Execute tests
run: ./vendor/bin/phpunit -c tests/phpunit.xml

View File

@@ -0,0 +1,15 @@
<?php
/*
* This document has been generated with
* https://mlocati.github.io/php-cs-fixer-configurator/#version:3.0.0-rc.1|configurator
* you can change this configuration by importing this file.
*
*/
$config = include 'vendor/zbateson/mb-wrapper/PhpCsFixer.php';
return $config->setFinder(PhpCsFixer\Finder::create()
->exclude('vendor')
->in(__DIR__.'\src')
->in(__DIR__.'\tests')
);

View File

@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2017, Zaahid Bateson
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,5 @@
<?php
// see vendor/zbateson/mb-wrapper/PhpCsFixer.php master version

View File

@@ -0,0 +1,78 @@
# zbateson/stream-decorators
Psr7 stream decorators for character set conversion and common mail format content encodings.
[![Tests](https://github.com/zbateson/stream-decorators/actions/workflows/tests.yml/badge.svg)](https://github.com/zbateson/stream-decorators/actions/workflows/tests.yml)
[![Code Coverage](https://scrutinizer-ci.com/g/zbateson/stream-decorators/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/zbateson/stream-decorators/?branch=master)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/zbateson/stream-decorators/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/zbateson/stream-decorators/?branch=master)
[![Total Downloads](https://poser.pugx.org/zbateson/stream-decorators/downloads)](//packagist.org/packages/zbateson/stream-decorators)
[![Latest Stable Version](https://poser.pugx.org/zbateson/stream-decorators/v)](//packagist.org/packages/zbateson/stream-decorators)
The goals of this project are to be:
* Well written
* Standards-compliant but forgiving
* Tested where possible
To include it for use in your project, please install via composer:
```
composer require zbateson/stream-decorators
```
## Php 7 Support Dropped
As of stream-decorators 2.0, support for php 7 has been dropped.
## Requirements
stream-decorators requires PHP 8.0 or newer. Tested on 8.0, 8.1, 8.2 and 8.3.
## New in 2.0 and 2.1
Support for guzzlehttp/psr7 1.9 dropped, min supported version is 2.0.
zbateson/mb-wrapper has been updated to 2.0 as well, which throws an UnsupportedCharsetException converting from/to an unsupported charset, which changes the behaviour of CharsetStream.
Two new classes are introduced in 2.1, DecoratedCachingStream and a TellZeroStream.
## Usage
```php
$stream = GuzzleHttp\Psr7\Utils::streamFor($handle);
$b64Stream = new ZBateson\StreamDecorators\Base64Stream($stream);
$charsetStream = new ZBateson\StreamDecorators\CharsetStream($b64Stream, 'UTF-32', 'UTF-8');
while (($line = GuzzleHttp\Psr7\Utils::readLine()) !== false) {
echo $line, "\r\n";
}
```
Note that CharsetStream, depending on the target encoding, may return multiple bytes when a single 'char' is read. If using php's 'fread', this will result in a warning:
'read x bytes more data than requested (xxxx read, xxxx max) - excess data will be lost
This is because the parameter to 'fread' is bytes, and so when CharsetStream returns, say, 4 bytes representing a single UTF-32 character, fread will truncate to the first byte when requesting '1' byte. It is recommended to **not** convert to a stream handle (with StreamWrapper) for this reason when using CharsetStream.
The library consists of the following Psr\Http\Message\StreamInterface implementations:
* ZBateson\StreamDecorators\Base64Stream - decodes on read and encodes on write to base64.
* ZBateson\StreamDecorators\CharsetStream - encodes from $streamCharset to $stringCharset on read, and vice-versa on write.
* ZBateson\StreamDecorators\ChunkSplitStream - splits written characters into lines of $lineLength long (stream implementation of php's chunk_split).
* ZBateson\StreamDecorators\DecoratedCachingStream - a caching stream that writes to a decorated stream, and reads from the cached undecorated stream, so for instance a stream could be passed, and decorated with a Base64Stream, and when read, the returned bytes would be base64 encoded.
* ZBateson\StreamDecorators\NonClosingStream - overrides close() and detach(), and simply unsets the attached stream without closing it.
* ZBateson\StreamDecorators\PregReplaceFilterStream - calls preg_replace on with passed arguments on every read() call.
* ZBateson\StreamDecorators\QuotedPrintableStream - decodes on read and encodes on write to quoted-printable.
* ZBateson\StreamDecorators\SeekingLimitStream - similar to GuzzleHttp's LimitStream, but maintains an internal current read position, seeking to it when read() is called, and seeking back to the wrapped stream's position after reading.
* ZBateson\StreamDecorators\TellZeroStream - tell() always returns '0' -- used by DecoratedCachingStream to wrap a BufferStream in a CachingStream. CachingStream calls tell() on its wrapped stream, and BufferStream throws an exception, so TellZeroStream is used to wrap the internal BufferStream to mitigate that.
* ZBateson\StreamDecorators\UUStream - decodes on read, encodes on write to uu-encoded.
QuotedPrintableStream, Base64Stream and UUStream's constructors take a single argument of a StreamInterface.
CharsetStreams's constructor also takes $streamCharset and $stringCharset as arguments respectively, ChunkSplitStream
optionally takes a $lineLength argument (defaults to 76) and a $lineEnding argument (defaults to CRLF).
PregReplaceFilterStream takes a $pattern argument and a $replacement argument. SeekingLimitStream takes optional
$limit and $offset parameters, similar to GuzzleHttp's LimitStream.
## License
BSD licensed - please see [license agreement](https://github.com/zbateson/stream-decorators/blob/master/LICENSE).

View File

@@ -0,0 +1,31 @@
{
"name": "zbateson/stream-decorators",
"description": "PHP psr7 stream decorators for mime message part streams",
"keywords": ["psr7", "stream", "decorators", "mail", "mime", "base64", "quoted-printable", "uuencode", "charset"],
"license": "BSD-2-Clause",
"authors": [
{
"name": "Zaahid Bateson"
}
],
"require": {
"php": ">=8.0",
"guzzlehttp/psr7": "^2.5",
"zbateson/mb-wrapper": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^9.6|^10.0",
"friendsofphp/php-cs-fixer": "*",
"phpstan/phpstan": "*"
},
"autoload": {
"psr-4": {
"ZBateson\\StreamDecorators\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"ZBateson\\StreamDecorators\\": "tests/StreamDecorators"
}
}
}

View File

@@ -0,0 +1,8 @@
parameters:
level: 6
errorFormat: raw
editorUrl: '%%file%% %%line%% %%column%%: %%error%%'
paths:
- src
- tests

View File

@@ -0,0 +1,222 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorators project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use GuzzleHttp\Psr7\BufferStream;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
use RuntimeException;
/**
* GuzzleHttp\Psr7 stream decoder extension for base64 streams.
*
* Note that it's expected the underlying stream will only contain valid base64
* characters (normally the stream should be wrapped in a
* PregReplaceFilterStream to filter out non-base64 characters for reading).
*
* ```
* $f = fopen(...);
* $stream = new Base64Stream(new PregReplaceFilterStream(
* Psr7\Utils::streamFor($f), '/[^a-zA-Z0-9\/\+=]/', ''
* ));
* //...
* ```
*
* For writing, a ChunkSplitStream could come in handy so the output is split
* into lines:
*
* ```
* $f = fopen(...);
* $stream = new Base64Stream(new ChunkSplitStream(new PregReplaceFilterStream(
* Psr7\Utils::streamFor($f), '/[^a-zA-Z0-9\/\+=]/', ''
* )));
* //...
* ```
*
* @author Zaahid Bateson
*/
class Base64Stream implements StreamInterface
{
use StreamDecoratorTrait;
/**
* @var BufferStream buffered bytes
*/
private BufferStream $buffer;
/**
* @var string remainder of write operation if the bytes didn't align to 3
* bytes
*/
private string $remainder = '';
/**
* @var int current number of read/written bytes (for tell())
*/
private int $position = 0;
/**
* @var StreamInterface $stream
*/
private StreamInterface $stream;
public function __construct(StreamInterface $stream)
{
$this->stream = $stream;
$this->buffer = new BufferStream();
}
/**
* Returns the current position of the file read/write pointer
*/
public function tell() : int
{
return $this->position;
}
/**
* Returns null, getSize isn't supported
*
* @return null
*/
public function getSize() : ?int
{
return null;
}
/**
* Not implemented (yet).
*
* Seek position can be calculated.
*
* @param int $offset
* @param int $whence
* @throws RuntimeException
*/
public function seek($offset, $whence = SEEK_SET) : void
{
throw new RuntimeException('Cannot seek a Base64Stream');
}
/**
* Overridden to return false
*/
public function isSeekable() : bool
{
return false;
}
/**
* Returns true if the end of stream has been reached.
*/
public function eof() : bool
{
return ($this->buffer->eof() && $this->stream->eof());
}
/**
* Fills the internal byte buffer after reading and decoding data from the
* underlying stream.
*
* Note that it's expected the underlying stream will only contain valid
* base64 characters (normally the stream should be wrapped in a
* PregReplaceFilterStream to filter out non-base64 characters).
*/
private function fillBuffer(int $length) : void
{
$fill = 8192;
while ($this->buffer->getSize() < $length) {
$read = $this->stream->read($fill);
if ($read === '') {
break;
}
$this->buffer->write(\base64_decode($read));
}
}
/**
* Attempts to read $length bytes after decoding them, and returns them.
*
* Note that reading and writing to the same stream may result in wrongly
* encoded data and is not supported.
*
* @param int $length
*/
public function read($length) : string
{
// let Guzzle decide what to do.
if ($length <= 0 || $this->eof()) {
return $this->stream->read($length);
}
$this->fillBuffer($length);
$ret = $this->buffer->read($length);
$this->position += \strlen($ret);
return $ret;
}
/**
* Writes the passed string to the underlying stream after encoding it to
* base64.
*
* Base64Stream::close or detach must be called. Failing to do so may
* result in 1-2 bytes missing from the end of the stream if there's a
* remainder. Note that the default Stream destructor calls close as well.
*
* Note that reading and writing to the same stream may result in wrongly
* encoded data and is not supported.
*
* @param string $string
* @return int the number of bytes written
*/
public function write($string) : int
{
$bytes = $this->remainder . $string;
$len = \strlen($bytes);
if (($len % 3) !== 0) {
$this->remainder = \substr($bytes, -($len % 3));
$bytes = \substr($bytes, 0, $len - ($len % 3));
} else {
$this->remainder = '';
}
$this->stream->write(\base64_encode($bytes));
$written = \strlen($string);
$this->position += $len;
return $written;
}
/**
* Writes out any remaining bytes at the end of the stream and closes.
*/
private function beforeClose() : void
{
if ($this->isWritable() && $this->remainder !== '') {
$this->stream->write(\base64_encode($this->remainder));
$this->remainder = '';
}
}
/**
* @inheritDoc
*/
public function close() : void
{
$this->beforeClose();
$this->stream->close();
}
/**
* @inheritDoc
*/
public function detach()
{
$this->beforeClose();
$this->stream->detach();
return null;
}
}

View File

@@ -0,0 +1,179 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorator project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
use RuntimeException;
use ZBateson\MbWrapper\MbWrapper;
/**
* GuzzleHttp\Psr7 stream decoder extension for charset conversion.
*
* @author Zaahid Bateson
*/
class CharsetStream implements StreamInterface
{
use StreamDecoratorTrait;
/**
* @var MbWrapper the charset converter
*/
protected MbWrapper $converter;
/**
* @var string charset of the source stream
*/
protected string $streamCharset = 'ISO-8859-1';
/**
* @var string charset of strings passed in write operations, and returned
* in read operations.
*/
protected string $stringCharset = 'UTF-8';
/**
* @var int current read/write position
*/
private int $position = 0;
/**
* @var int number of $stringCharset characters in $buffer
*/
private int $bufferLength = 0;
/**
* @var string a buffer of characters read in the original $streamCharset
* encoding
*/
private string $buffer = '';
/**
* @var StreamInterface $stream
*/
private StreamInterface $stream;
/**
* @param StreamInterface $stream Stream to decorate
* @param string $streamCharset The underlying stream's charset
* @param string $stringCharset The charset to encode strings to (or
* expected for write)
*/
public function __construct(StreamInterface $stream, string $streamCharset = 'ISO-8859-1', string $stringCharset = 'UTF-8')
{
$this->stream = $stream;
$this->converter = new MbWrapper();
$this->streamCharset = $streamCharset;
$this->stringCharset = $stringCharset;
}
/**
* Overridden to return the position in the target encoding.
*/
public function tell() : int
{
return $this->position;
}
/**
* Returns null, getSize isn't supported
*
* @return null
*/
public function getSize() : ?int
{
return null;
}
/**
* Not supported.
*
* @param int $offset
* @param int $whence
* @throws RuntimeException
*/
public function seek($offset, $whence = SEEK_SET) : void
{
throw new RuntimeException('Cannot seek a CharsetStream');
}
/**
* Overridden to return false
*/
public function isSeekable() : bool
{
return false;
}
/**
* Reads a minimum of $length characters from the underlying stream in its
* encoding into $this->buffer.
*
* Aligning to 4 bytes seemed to solve an issue reading from UTF-16LE
* streams and pass testReadUtf16LeToEof, although the buffered string
* should've solved that on its own.
*/
private function readRawCharsIntoBuffer(int $length) : void
{
$n = (int) \ceil(($length + 32) / 4.0) * 4;
while ($this->bufferLength < $n) {
$raw = $this->stream->read($n + 512);
if ($raw === '') {
return;
}
$this->buffer .= $raw;
$this->bufferLength = $this->converter->getLength($this->buffer, $this->streamCharset);
}
}
/**
* Returns true if the end of stream has been reached.
*/
public function eof() : bool
{
return ($this->bufferLength === 0 && $this->stream->eof());
}
/**
* Reads up to $length decoded chars from the underlying stream and returns
* them after converting to the target string charset.
*
* @param int $length
*/
public function read($length) : string
{
// let Guzzle decide what to do.
if ($length <= 0 || $this->eof()) {
return $this->stream->read($length);
}
$this->readRawCharsIntoBuffer($length);
$numChars = \min([$this->bufferLength, $length]);
$chars = $this->converter->getSubstr($this->buffer, $this->streamCharset, 0, $numChars);
$this->position += $numChars;
$this->buffer = $this->converter->getSubstr($this->buffer, $this->streamCharset, $numChars);
$this->bufferLength -= $numChars;
return $this->converter->convert($chars, $this->streamCharset, $this->stringCharset);
}
/**
* Writes the passed string to the underlying stream after converting it to
* the target stream encoding.
*
* @param string $string
* @return int the number of bytes written
*/
public function write($string) : int
{
$converted = $this->converter->convert($string, $this->stringCharset, $this->streamCharset);
$written = $this->converter->getLength($converted, $this->streamCharset);
$this->position += $written;
return $this->stream->write($converted);
}
}

View File

@@ -0,0 +1,122 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorators project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
/**
* Inserts line ending characters after the set number of characters have been
* written to the underlying stream.
*
* @author Zaahid Bateson
*/
class ChunkSplitStream implements StreamInterface
{
use StreamDecoratorTrait;
/**
* @var int Number of bytes written, and importantly, if non-zero, writes a
* final $lineEnding on close (and so maintained instead of using
* tell() directly)
*/
private int $position;
/**
* @var int The number of characters in a line before inserting $lineEnding.
*/
private int $lineLength;
/**
* @var string The line ending characters to insert.
*/
private string $lineEnding;
/**
* @var int The strlen() of $lineEnding
*/
private int $lineEndingLength;
/**
* @var StreamInterface $stream
*/
private StreamInterface $stream;
public function __construct(StreamInterface $stream, int $lineLength = 76, string $lineEnding = "\r\n")
{
$this->stream = $stream;
$this->position = 0;
$this->lineLength = $lineLength;
$this->lineEnding = $lineEnding;
$this->lineEndingLength = \strlen($this->lineEnding);
}
/**
* Inserts the line ending character after each line length characters in
* the passed string, making sure previously written bytes are taken into
* account.
*/
private function getChunkedString(string $string) : string
{
$firstLine = '';
if ($this->tell() !== 0) {
$next = $this->lineLength - ($this->position % ($this->lineLength + $this->lineEndingLength));
if (\strlen($string) > $next) {
$firstLine = \substr($string, 0, $next) . $this->lineEnding;
$string = \substr($string, $next);
}
}
// chunk_split always ends with the passed line ending
$chunked = $firstLine . \chunk_split($string, $this->lineLength, $this->lineEnding);
return \substr($chunked, 0, \strlen($chunked) - $this->lineEndingLength);
}
/**
* Writes the passed string to the underlying stream, ensuring line endings
* are inserted every "line length" characters in the string.
*
* @param string $string
* @return int number of bytes written
*/
public function write($string) : int
{
$chunked = $this->getChunkedString($string);
$this->position += \strlen($chunked);
return $this->stream->write($chunked);
}
/**
* Inserts a final line ending character.
*/
private function beforeClose() : void
{
if ($this->position !== 0) {
$this->stream->write($this->lineEnding);
}
}
/**
* @inheritDoc
*/
public function close() : void
{
$this->beforeClose();
$this->stream->close();
}
/**
* @inheritDoc
*/
public function detach()
{
$this->beforeClose();
$this->stream->detach();
return null;
}
}

View File

@@ -0,0 +1,175 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorators project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use Psr\Http\Message\StreamInterface;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use GuzzleHttp\Psr7\BufferStream;
use GuzzleHttp\Psr7\CachingStream;
use GuzzleHttp\Psr7\FnStream;
use GuzzleHttp\Psr7\Utils;
/**
* A version of Guzzle's CachingStream that will read bytes from one stream,
* write them into another decorated stream, and read them back from a 3rd,
* undecorated, buffered stream where the bytes are written to.
*
* A read operation is basically:
*
* Read from A, write to B (which decorates C), read and return from C (which is
* backed by a BufferedStream).
*
* Note that the DecoratedCachingStream doesn't support write operations.
*/
class DecoratedCachingStream implements StreamInterface
{
use StreamDecoratorTrait;
/**
* @var StreamInterface the stream to read from and fill writeStream with
*/
private StreamInterface $readStream;
/**
* @var StreamInterface the underlying undecorated stream to read from,
* where $writeStream is being written to
*/
private StreamInterface $stream;
/**
* @var StreamInterface decorated $stream that will be written to for
* caching that wraps $stream. Once filled, the stream is closed so it
* supports a Base64Stream which writes bytes at the end.
*/
private ?StreamInterface $writeStream;
/**
* @var int Minimum buffer read length. At least this many bytes will be
* read and cached into $writeStream on each call to read from
* $readStream
*/
private int $minBytesCache;
/**
* @param StreamInterface $stream Stream to cache. The cursor is assumed to
* be at the beginning of the stream.
* @param callable(StreamInterface) : StreamInterface $decorator takes the
* passed StreamInterface and decorates it, and returns the decorated
* StreamInterface
*/
public function __construct(
StreamInterface $stream,
callable $decorator,
int $minBytesCache = 16384
) {
$this->readStream = $stream;
$bufferStream = new TellZeroStream(new BufferStream());
$this->stream = new CachingStream($bufferStream);
$this->writeStream = $decorator(new NonClosingStream($bufferStream));
$this->minBytesCache = $minBytesCache;
}
public function getSize(): ?int
{
// the decorated stream could be a different size
$this->cacheEntireStream();
return $this->stream->getSize();
}
public function rewind(): void
{
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
{
if ($whence === SEEK_SET) {
$byte = $offset;
} elseif ($whence === SEEK_CUR) {
$byte = $offset + $this->tell();
} elseif ($whence === SEEK_END) {
$size = $this->getSize();
$byte = $size + $offset;
} else {
throw new \InvalidArgumentException('Invalid whence');
}
$diff = $byte - $this->stream->getSize();
if ($diff > 0) {
// Read the remoteStream until we have read in at least the amount
// of bytes requested, or we reach the end of the file.
while ($diff > 0 && !$this->readStream->eof()) {
$this->read($diff);
$diff = $byte - $this->stream->getSize();
}
} else {
// We can just do a normal seek since we've already seen this byte.
$this->stream->seek($byte);
}
}
private function cacheBytes(int $size) : void {
if (!$this->readStream->eof()) {
$data = $this->readStream->read(max($this->minBytesCache, $size));
$this->writeStream->write($data);
if ($this->readStream->eof()) {
// needed because Base64Stream writes bytes on closing
$this->writeStream->close();
$this->writeStream = null;
}
}
}
public function read($length): string
{
$data = $this->stream->read($length);
$remaining = $length - strlen($data);
if ($remaining > 0) {
$this->cacheBytes($remaining);
$data .= $this->stream->read($remaining);
}
return $data;
}
public function isWritable(): bool
{
return false;
}
public function write($string): int
{
throw new \RuntimeException('Cannot write to a DecoratedCachingStream');
}
public function eof(): bool
{
return $this->stream->eof() && $this->readStream->eof();
}
/**
* Close both the remote stream and buffer stream
*/
public function close(): void
{
$this->readStream->close();
$this->stream->close();
if ($this->writeStream !== null) {
$this->writeStream->close();
}
}
private function cacheEntireStream(): int
{
// as-is from CachingStream
$target = new FnStream(['write' => 'strlen']);
Utils::copyToStream($this, $target);
return $this->tell();
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorators project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
/**
* Doesn't close the underlying stream when 'close' is called on it. Instead,
* calling close simply removes any reference to the underlying stream. Note
* that GuzzleHttp\Psr7\Stream calls close in __destruct, so a reference to the
* Stream needs to be kept. For example:
*
* ```
* $f = fopen('php://temp', 'r+');
* $test = new NonClosingStream(Psr7\Utils::streamFor('test'));
* // work
* $test->close();
* rewind($f); // error, $f is a closed resource
* ```
*
* Instead, this would work:
*
* ```
* $stream = Psr7\Utils::streamFor(fopen('php://temp', 'r+'));
* $test = new NonClosingStream($stream);
* // work
* $test->close();
* $stream->rewind(); // works
* ```
*
* @author Zaahid Bateson
*/
class NonClosingStream implements StreamInterface
{
use StreamDecoratorTrait;
/**
* @var ?StreamInterface $stream
* @phpstan-ignore-next-line
*/
private ?StreamInterface $stream;
/**
* @inheritDoc
*/
public function close() : void
{
$this->stream = null; // @phpstan-ignore-line
}
/**
* Overridden to detach the underlying stream without closing it.
*
* @inheritDoc
*/
public function detach()
{
$this->stream = null; // @phpstan-ignore-line
return null;
}
}

View File

@@ -0,0 +1,110 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorators project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use GuzzleHttp\Psr7\BufferStream;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
use RuntimeException;
/**
* Calls preg_replace on each read operation with the passed pattern and
* replacement string. Should only really be used to find single characters,
* since a pattern intended to match more may be split across multiple read()
* operations.
*
* @author Zaahid Bateson
*/
class PregReplaceFilterStream implements StreamInterface
{
use StreamDecoratorTrait;
/**
* @var string The regex pattern
*/
private string $pattern;
/**
* @var string The replacement
*/
private string $replacement;
/**
* @var BufferStream Buffered stream of input from the underlying stream
*/
private BufferStream $buffer;
/**
* @var StreamInterface $stream
*/
private StreamInterface $stream;
public function __construct(StreamInterface $stream, string $pattern, string $replacement)
{
$this->stream = $stream;
$this->pattern = $pattern;
$this->replacement = $replacement;
$this->buffer = new BufferStream();
}
/**
* Returns true if the end of stream has been reached.
*/
public function eof() : bool
{
return ($this->buffer->eof() && $this->stream->eof());
}
/**
* Not supported by PregReplaceFilterStream
*
* @param int $offset
* @param int $whence
* @throws RuntimeException
*/
public function seek($offset, $whence = SEEK_SET) : void
{
throw new RuntimeException('Cannot seek a PregReplaceFilterStream');
}
/**
* Overridden to return false
*/
public function isSeekable() : bool
{
return false;
}
/**
* Fills the BufferStream with at least 8192 characters of input for future
* read operations.
*/
private function fillBuffer(int $length) : void
{
$fill = (int) \max([$length, 8192]);
while ($this->buffer->getSize() < $length) {
$read = $this->stream->read($fill);
if ($read === '') {
break;
}
$this->buffer->write(\preg_replace($this->pattern, $this->replacement, $read));
}
}
/**
* Reads from the underlying stream, filters it and returns up to $length
* bytes.
*
* @param int $length
*/
public function read($length) : string
{
$this->fillBuffer($length);
return $this->buffer->read($length);
}
}

View File

@@ -0,0 +1,222 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorators project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
use RuntimeException;
/**
* GuzzleHttp\Psr7 stream decoder decorator for quoted printable streams.
*
* @author Zaahid Bateson
*/
class QuotedPrintableStream implements StreamInterface
{
use StreamDecoratorTrait;
/**
* @var int current read/write position
*/
private int $position = 0;
/**
* @var string Last line of written text (used to maintain good line-breaks)
*/
private string $lastLine = '';
/**
* @var StreamInterface $stream
* @phpstan-ignore-next-line
*/
private StreamInterface $stream;
/**
* Overridden to return the position in the target encoding.
*/
public function tell() : int
{
return $this->position;
}
/**
* Returns null, getSize isn't supported
*
* @return null
*/
public function getSize() : ?int
{
return null;
}
/**
* Not supported.
*
* @param int $offset
* @param int $whence
* @throws RuntimeException
*/
public function seek($offset, $whence = SEEK_SET) : void
{
throw new RuntimeException('Cannot seek a QuotedPrintableStream');
}
/**
* Overridden to return false
*/
public function isSeekable() : bool
{
return false;
}
/**
* Reads $length chars from the underlying stream, prepending the past $pre
* to it first.
*
* If the characters read (including the prepended $pre) contain invalid
* quoted-printable characters, the underlying stream is rewound by the
* total number of characters ($length + strlen($pre)).
*
* The quoted-printable encoded characters are returned. If the characters
* read are invalid, '3D' is returned indicating an '=' character.
*/
private function readEncodedChars(int $length, string $pre = '') : string
{
$str = $pre . $this->stream->read($length);
$len = \strlen($str);
if ($len > 0 && !\preg_match('/^[0-9a-f]{2}$|^[\r\n]{1,2}.?$/is', $str) && $this->stream->isSeekable()) {
$this->stream->seek(-$len, SEEK_CUR);
return '3D'; // '=' character
}
return $str;
}
/**
* Decodes the passed $block of text.
*
* If the last or before last character is an '=' char, indicating the
* beginning of a quoted-printable encoded char, 1 or 2 additional bytes are
* read from the underlying stream respectively.
*
* @return string The decoded string
*/
private function decodeBlock(string $block) : string
{
if (\substr($block, -1) === '=') {
$block .= $this->readEncodedChars(2);
} elseif (\substr($block, -2, 1) === '=') {
$first = \substr($block, -1);
$block = \substr($block, 0, -1);
$block .= $this->readEncodedChars(1, $first);
}
return \quoted_printable_decode($block);
}
/**
* Reads up to $length characters, appends them to the passed $str string,
* and returns the total number of characters read.
*
* -1 is returned if there are no more bytes to read.
*/
private function readRawDecodeAndAppend(int $length, string &$str) : int
{
$block = $this->stream->read($length);
if ($block === '') {
return -1;
}
$decoded = $this->decodeBlock($block);
$count = \strlen($decoded);
$str .= $decoded;
return $count;
}
/**
* Reads up to $length decoded bytes from the underlying quoted-printable
* encoded stream and returns them.
*
* @param int $length
*/
public function read($length) : string
{
// let Guzzle decide what to do.
if ($length <= 0 || $this->eof()) {
return $this->stream->read($length);
}
$count = 0;
$bytes = '';
while ($count < $length) {
$nRead = $this->readRawDecodeAndAppend($length - $count, $bytes);
if ($nRead === -1) {
break;
}
$this->position += $nRead;
$count += $nRead;
}
return $bytes;
}
/**
* Writes the passed string to the underlying stream after encoding it as
* quoted-printable.
*
* Note that reading and writing to the same stream without rewinding is not
* supported.
*
* @param string $string
*
* @return int the number of bytes written
*/
public function write($string) : int
{
$encodedLine = \quoted_printable_encode($this->lastLine);
$lineAndString = \rtrim(\quoted_printable_encode($this->lastLine . $string), "\r\n");
$write = \substr($lineAndString, \strlen($encodedLine));
$this->stream->write($write);
$written = \strlen($string);
$this->position += $written;
$lpos = \strrpos($lineAndString, "\n");
$lastLine = $lineAndString;
if ($lpos !== false) {
$lastLine = \substr($lineAndString, $lpos + 1);
}
$this->lastLine = \quoted_printable_decode($lastLine);
return $written;
}
/**
* Writes out a final CRLF if the current line isn't empty.
*/
private function beforeClose() : void
{
if ($this->isWritable() && $this->lastLine !== '') {
$this->stream->write("\r\n");
$this->lastLine = '';
}
}
/**
* @inheritDoc
*/
public function close() : void
{
$this->beforeClose();
$this->stream->close();
}
/**
* @inheritDoc
*/
public function detach()
{
$this->beforeClose();
$this->stream->detach();
return null;
}
}

View File

@@ -0,0 +1,192 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorators project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
/**
* Maintains an internal 'read' position, and seeks to it before reading, then
* seeks back to the original position of the underlying stream after reading if
* the attached stream supports seeking.
*
* Although copied form LimitStream, it's not inherited from it since $offset
* and $limit are set to private on LimitStream, and most other functions are
* re-implemented anyway. This also decouples the implementation from upstream
* changes.
*/
class SeekingLimitStream implements StreamInterface
{
use StreamDecoratorTrait;
/** @var int Offset to start reading from */
private int $offset;
/** @var int Limit the number of bytes that can be read */
private int $limit;
/**
* @var int Number of bytes written, and importantly, if non-zero, writes a
* final $lineEnding on close (and so maintained instead of using
* tell() directly)
*/
private int $position = 0;
/**
* @var StreamInterface $stream
*/
private StreamInterface $stream;
/**
* @param StreamInterface $stream Stream to wrap
* @param int $limit Total number of bytes to allow to be read
* from the stream. Pass -1 for no limit.
* @param int $offset Position to seek to before reading (only
* works on seekable streams).
*/
public function __construct(StreamInterface $stream, int $limit = -1, int $offset = 0)
{
$this->stream = $stream;
$this->setLimit($limit);
$this->setOffset($offset);
}
/**
* Returns the current relative read position of this stream subset.
*/
public function tell() : int
{
return $this->position;
}
/**
* Returns the size of the limited subset of data, or null if the wrapped
* stream returns null for getSize.
*/
public function getSize() : ?int
{
$size = $this->stream->getSize();
if ($size === null) {
// this shouldn't happen on a seekable stream I don't think...
$pos = $this->stream->tell();
$this->stream->seek(0, SEEK_END);
$size = $this->stream->tell();
$this->stream->seek($pos);
}
if ($this->limit === -1) {
return $size - $this->offset;
}
return \min([$this->limit, $size - $this->offset]);
}
/**
* Returns true if the current read position is at the end of the limited
* stream
*/
public function eof() : bool
{
$size = $this->limit;
if ($size === -1) {
$size = $this->getSize();
}
return ($this->position >= $size);
}
/**
* Ensures the seek position specified is within the stream's bounds, and
* sets the internal position pointer (doesn't actually seek).
*/
private function doSeek(int $pos) : void
{
if ($this->limit !== -1) {
$pos = \min([$pos, $this->limit]);
}
$this->position = \max([0, $pos]);
}
/**
* Seeks to the passed position within the confines of the limited stream's
* bounds.
*
* For SeekingLimitStream, no actual seek is performed on the underlying
* wrapped stream. Instead, an internal pointer is set, and the stream is
* 'seeked' on read operations
*
* @param int $offset
* @param int $whence
*/
public function seek($offset, $whence = SEEK_SET) : void
{
$pos = $offset;
switch ($whence) {
case SEEK_CUR:
$pos = $this->position + $offset;
break;
case SEEK_END:
$pos = $this->limit + $offset;
break;
default:
break;
}
$this->doSeek($pos);
}
/**
* Sets the offset to start reading from the wrapped stream.
*/
public function setOffset(int $offset) : void
{
$this->offset = $offset;
$this->position = 0;
}
/**
* Sets the length of the stream to the passed $limit.
*/
public function setLimit(int $limit) : void
{
$this->limit = $limit;
}
/**
* Seeks to the current position and reads up to $length bytes, or less if
* it would result in reading past $this->limit
*/
public function seekAndRead(int $length) : string
{
$this->stream->seek($this->offset + $this->position);
if ($this->limit !== -1) {
$length = \min($length, $this->limit - $this->position);
if ($length <= 0) {
return '';
}
}
return $this->stream->read($length);
}
/**
* Reads from the underlying stream after seeking to the position within the
* bounds set for this limited stream. After reading, the wrapped stream is
* 'seeked' back to its position prior to the call to read().
*
* @param int $length
*/
public function read($length) : string
{
$pos = $this->stream->tell();
$ret = $this->seekAndRead($length);
$this->position += \strlen($ret);
$this->stream->seek($pos);
if ($this->limit !== -1 && $this->position > $this->limit) {
$ret = \substr($ret, 0, -($this->position - $this->limit));
$this->position = $this->limit;
}
return $ret;
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorators project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use Psr\Http\Message\StreamInterface;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
/**
* Calling tell() always returns 0. Used by DecoratedCachingStream so a
* CachingStream can use a BufferedStream, because BufferedStream throws an
* exception in tell().
*/
class TellZeroStream implements StreamInterface
{
use StreamDecoratorTrait;
/**
* @var StreamInterface
*/
private StreamInterface $stream;
public function tell() : int
{
return 0;
}
}

View File

@@ -0,0 +1,315 @@
<?php
/**
* This file is part of the ZBateson\StreamDecorators project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\StreamDecorators;
use GuzzleHttp\Psr7\BufferStream;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
use RuntimeException;
/**
* GuzzleHttp\Psr7 stream decoder extension for UU-Encoded streams.
*
* The size of the underlying stream and the position of bytes can't be
* determined because the number of encoded bytes is indeterminate without
* reading the entire stream.
*
* @author Zaahid Bateson
*/
class UUStream implements StreamInterface
{
use StreamDecoratorTrait;
/**
* @var string name of the UUEncoded file
*/
protected $filename = null;
/**
* @var BufferStream of read and decoded bytes
*/
private $buffer;
/**
* @var string remainder of write operation if the bytes didn't align to 3
* bytes
*/
private $remainder = '';
/**
* @var int read/write position
*/
private $position = 0;
/**
* @var bool set to true when 'write' is called
*/
private $isWriting = false;
/**
* @var StreamInterface $stream
*/
private $stream;
/**
* @param StreamInterface $stream Stream to decorate
* @param string $filename optional file name
*/
public function __construct(StreamInterface $stream, ?string $filename = null)
{
$this->stream = $stream;
$this->filename = $filename;
$this->buffer = new BufferStream();
}
/**
* Overridden to return the position in the target encoding.
*/
public function tell() : int
{
return $this->position;
}
/**
* Returns null, getSize isn't supported
*
* @return null
*/
public function getSize() : ?int
{
return null;
}
/**
* Not supported.
*
* @param int $offset
* @param int $whence
* @throws RuntimeException
*/
public function seek($offset, $whence = SEEK_SET) : void
{
throw new RuntimeException('Cannot seek a UUStream');
}
/**
* Overridden to return false
*/
public function isSeekable() : bool
{
return false;
}
/**
* Finds the next end-of-line character to ensure a line isn't broken up
* while buffering.
*/
private function readToEndOfLine(int $length) : string
{
$str = $this->stream->read($length);
if ($str === '') {
return $str;
}
while (\substr($str, -1) !== "\n") {
$chr = $this->stream->read(1);
if ($chr === '') {
break;
}
$str .= $chr;
}
return $str;
}
/**
* Removes invalid characters from a uuencoded string, and 'BEGIN' and 'END'
* line headers and footers from the passed string before returning it.
*/
private function filterAndDecode(string $str) : string
{
$ret = \str_replace("\r", '', $str);
$ret = \preg_replace('/[^\x21-\xf5`\n]/', '`', $ret);
if ($this->position === 0) {
$matches = [];
if (\preg_match('/^\s*begin\s+[^\s+]\s+([^\r\n]+)\s*$/im', $ret, $matches)) {
$this->filename = $matches[1];
}
$ret = \preg_replace('/^\s*begin[^\r\n]+\s*$/im', '', $ret);
} else {
$ret = \preg_replace('/^\s*end\s*$/im', '', $ret);
}
return \convert_uudecode(\trim($ret));
}
/**
* Buffers bytes into $this->buffer, removing uuencoding headers and footers
* and decoding them.
*/
private function fillBuffer(int $length) : void
{
// 5040 = 63 * 80, seems to be good balance for buffering in benchmarks
// testing with a simple 'if ($length < x)' and calculating a better
// size reduces speeds by up to 4x
while ($this->buffer->getSize() < $length) {
$read = $this->readToEndOfLine(5040);
if ($read === '') {
break;
}
$this->buffer->write($this->filterAndDecode($read));
}
}
/**
* Returns true if the end of stream has been reached.
*/
public function eof() : bool
{
return ($this->buffer->eof() && $this->stream->eof());
}
/**
* Attempts to read $length bytes after decoding them, and returns them.
*
* @param int $length
*/
public function read($length) : string
{
// let Guzzle decide what to do.
if ($length <= 0 || $this->eof()) {
return $this->stream->read($length);
}
$this->fillBuffer($length);
$read = $this->buffer->read($length);
$this->position += \strlen($read);
return $read;
}
/**
* Writes the 'begin' UU header line.
*/
private function writeUUHeader() : void
{
$filename = (empty($this->filename)) ? 'null' : $this->filename;
$this->stream->write("begin 666 $filename");
}
/**
* Writes the '`' and 'end' UU footer lines.
*/
private function writeUUFooter() : void
{
$this->stream->write("\r\n`\r\nend\r\n");
}
/**
* Writes the passed bytes to the underlying stream after encoding them.
*/
private function writeEncoded(string $bytes) : void
{
$encoded = \preg_replace('/\r\n|\r|\n/', "\r\n", \rtrim(\convert_uuencode($bytes)));
// removes ending '`' line
$this->stream->write("\r\n" . \rtrim(\substr($encoded, 0, -1)));
}
/**
* Prepends any existing remainder to the passed string, then checks if the
* string fits into a uuencoded line, and removes and keeps any remainder
* from the string to write. Full lines ready for writing are returned.
*/
private function handleRemainder(string $string) : string
{
$write = $this->remainder . $string;
$nRem = \strlen($write) % 45;
$this->remainder = '';
if ($nRem !== 0) {
$this->remainder = \substr($write, -$nRem);
$write = \substr($write, 0, -$nRem);
}
return $write;
}
/**
* Writes the passed string to the underlying stream after encoding it.
*
* Note that reading and writing to the same stream without rewinding is not
* supported.
*
* Also note that some bytes may not be written until close or detach are
* called. This happens if written data doesn't align to a complete
* uuencoded 'line' of 45 bytes. In addition, the UU footer is only written
* when closing or detaching as well.
*
* @param string $string
* @return int the number of bytes written
*/
public function write($string) : int
{
$this->isWriting = true;
if ($this->position === 0) {
$this->writeUUHeader();
}
$write = $this->handleRemainder($string);
if ($write !== '') {
$this->writeEncoded($write);
}
$written = \strlen($string);
$this->position += $written;
return $written;
}
/**
* Returns the filename set in the UUEncoded header (or null)
*/
public function getFilename() : string
{
return $this->filename;
}
/**
* Sets the UUEncoded header file name written in the 'begin' header line.
*/
public function setFilename(string $filename) : void
{
$this->filename = $filename;
}
/**
* Writes out any remaining bytes and the UU footer.
*/
private function beforeClose() : void
{
if (!$this->isWriting) {
return;
}
if ($this->remainder !== '') {
$this->writeEncoded($this->remainder);
}
$this->remainder = '';
$this->isWriting = false;
$this->writeUUFooter();
}
/**
* @inheritDoc
*/
public function close() : void
{
$this->beforeClose();
$this->stream->close();
}
/**
* @inheritDoc
*/
public function detach()
{
$this->beforeClose();
$this->stream->detach();
return null;
}
}