friendship ended with social-app. php is my new best friend
1<?php 2/** 3 * Class ResponseEmitterAbstract 4 * 5 * @created 22.10.2022 6 * @author smiley <smiley@chillerlan.net> 7 * @copyright 2022 smiley 8 * @license MIT 9 */ 10declare(strict_types=1); 11 12namespace chillerlan\HTTP\Utils\Emitter; 13 14use Psr\Http\Message\{ResponseInterface, StreamInterface}; 15use InvalidArgumentException, RuntimeException; 16use function connection_status, flush, in_array, intval, preg_match, sprintf, strlen, strtolower, trim; 17use const CONNECTION_NORMAL; 18 19/** 20 * @see https://datatracker.ietf.org/doc/html/rfc2616 21 * @see https://datatracker.ietf.org/doc/html/rfc9110 22 */ 23abstract class ResponseEmitterAbstract implements ResponseEmitterInterface{ 24 25 protected ResponseInterface $response; 26 protected StreamInterface $body; 27 protected bool $hasCustomLength; 28 protected bool $hasContentRange; 29 protected int $rangeStart = 0; 30 protected int $rangeLength = 0; 31 protected int $bufferSize = 65536; 32 33 /** 34 * ResponseEmitter constructor 35 */ 36 public function __construct(ResponseInterface $response, int $bufferSize = 65536){ 37 $this->response = $response; 38 $this->bufferSize = $bufferSize; 39 40 if($this->bufferSize < 1){ 41 throw new InvalidArgumentException('Buffer length must be greater than zero.'); // @codeCoverageIgnore 42 } 43 44 $this->body = $this->response->getBody(); 45 $this->hasContentRange = $this->response->getStatusCode() === 206 && $this->response->hasHeader('Content-Range'); 46 47 $this->setContentLengthHeader(); 48 49 if($this->body->isSeekable()){ 50 $this->body->rewind(); 51 } 52 53 } 54 55 /** 56 * Checks whether the response has (or is supposed to have) a body 57 * 58 * @see https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx 59 * @see https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content 60 * @see https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content 61 * @see https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified 62 */ 63 protected function hasBody():bool{ 64 $status = $this->response->getStatusCode(); 65 // these response codes never return a body 66 if($status < 200 || in_array($status, [204, 205, 304], true)){ 67 return false; 68 } 69 70 return $this->body->isReadable() && $this->body->getSize() > 0; 71 } 72 73 /** 74 * Returns a full status line for the given response, e.g. "HTTP/1.1 200 OK" 75 */ 76 protected function getStatusLine():string{ 77 78 $status = sprintf( 79 'HTTP/%s %d %s', 80 $this->response->getProtocolVersion(), 81 $this->response->getStatusCode(), 82 $this->response->getReasonPhrase(), 83 ); 84 85 // the reason phrase may be empty, so we make sure there's no extra trailing spaces in the status line 86 return trim($status); 87 } 88 89 /** 90 * Sets/adjusts the Content-Length header 91 * 92 * (technically we could do this in a PSR-15 middleware but this class is supposed to work as standalone as well) 93 * 94 * @see https://datatracker.ietf.org/doc/html/rfc9110#name-content-length 95 */ 96 protected function setContentLengthHeader():void{ 97 $this->hasCustomLength = false; 98 99 // remove the content-length header if body is not present 100 if(!$this->hasBody()){ 101 $this->response = $this->response->withoutHeader('Content-Length'); 102 103 return; 104 } 105 106 // response has a content-range header set 107 if($this->hasContentRange){ 108 $parsed = $this->parseContentRange(); 109 110 if($parsed === null){ 111 $this->hasContentRange = false; 112 113 // content range is invalid, we'll remove the header send the full response with a code 200 instead 114 // @see https://datatracker.ietf.org/doc/html/rfc9110#status.416 (note) 115 $this->response = $this->response 116 ->withStatus(200, 'OK') 117 ->withoutHeader('Content-Range') 118 ->withHeader('Content-Length', (string)$this->body->getSize()) 119 ; 120 121 return; 122 } 123 124 [$this->rangeStart, $end, $total, $this->rangeLength] = $parsed; 125 126 $this->response = $this->response 127 // adjust the content-range header to include the full response size 128 ->withHeader('Content-Range', sprintf('bytes %s-%s/%s', $this->rangeStart, $end, $total)) 129 // add the content-length header with the partially fulfilled size 130 ->withHeader('Content-Length', (string)$this->rangeLength) 131 ; 132 133 return; 134 } 135 136 // add the header if it's missing 137 if(!$this->response->hasHeader('Content-Length')){ 138 $this->response = $this->response->withHeader('Content-Length', (string)$this->body->getSize()); 139 140 return; 141 } 142 143 // a header was present 144 $contentLength = (int)$this->response->getHeaderLine('Content-Length'); 145 // we don't touch the custom value that has been set for whatever reason 146 if($contentLength < $this->body->getSize()){ 147 $this->hasCustomLength = true; 148 $this->rangeLength = $contentLength; 149 } 150 151 } 152 153 /** 154 * @see https://datatracker.ietf.org/doc/html/rfc9110#name-content-range 155 * 156 * @return array{0: int, 1: int, 2: int|null, 3: int}|null 157 */ 158 protected function parseContentRange():array|null{ 159 $contentRange = $this->response->getHeaderLine('Content-Range'); 160 if(preg_match('/(?P<unit>[a-z]+)\s+(?P<start>\d+)-(?P<end>\d+)\/(?P<total>\d+|\*)/i', $contentRange, $matches)){ 161 // we only accept the "bytes" unit here 162 if(strtolower($matches['unit']) !== 'bytes'){ 163 return null; 164 } 165 166 $start = intval($matches['start']); 167 $end = intval($matches['end']); 168 $total = ($matches['total'] === '*') ? $this->body->getSize() : intval($matches['total']); 169 $length = ($end - $start + 1); 170 171 if($end < $start){ 172 return null; 173 } 174 175 // we're being generous and adjust if the end is greater than the total size 176 if($end > $total){ 177 $length = ($total - $start); 178 } 179 180 return [$start, $end, $total, $length]; 181 } 182 183 return null; 184 } 185 186 /** 187 * emits the given buffer 188 * 189 * @codeCoverageIgnore (overridden in test) 190 */ 191 protected function emitBuffer(string $buffer):void{ 192 echo $buffer; 193 } 194 195 /** 196 * emits the body of the given response with respect to the parameters given in content-range and content-length headers 197 */ 198 protected function emitBody():void{ 199 200 if(!$this->hasBody()){ 201 return; 202 } 203 204 // a length smaller than the total body size was specified 205 if($this->hasCustomLength === true){ 206 $this->emitBodyRange(0, $this->rangeLength); 207 208 return; 209 } 210 211 // a content-range header was set 212 if($this->hasContentRange === true){ 213 $this->emitBodyRange($this->rangeStart, $this->rangeLength); 214 215 return; 216 } 217 218 // dump the whole body 219 while(!$this->body->eof()){ 220 $this->emitBuffer($this->body->read($this->bufferSize)); 221 222 if(connection_status() !== CONNECTION_NORMAL){ 223 break; // @codeCoverageIgnore 224 } 225 } 226 227 } 228 229 /** 230 * emits a part of the body 231 */ 232 protected function emitBodyRange(int $start, int $length):void{ 233 flush(); 234 235 if(!$this->body->isSeekable()){ 236 throw new RuntimeException('body must be seekable'); // @codeCoverageIgnore 237 } 238 239 $this->body->seek($start); 240 241 while($length >= $this->bufferSize && !$this->body->eof()){ 242 $contents = $this->body->read($this->bufferSize); 243 $length -= strlen($contents); 244 245 $this->emitBuffer($contents); 246 247 if(connection_status() !== CONNECTION_NORMAL){ 248 break; // @codeCoverageIgnore 249 } 250 } 251 252 if($length > 0 && !$this->body->eof()){ 253 $this->emitBuffer($this->body->read($length)); 254 } 255 256 } 257 258}