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}