1 | <?php |
||
2 | |||
3 | declare(strict_types=1); |
||
4 | |||
5 | namespace Stadly\FileWaiter; |
||
6 | |||
7 | use GuzzleHttp\Psr7\AppendStream; |
||
8 | use GuzzleHttp\Psr7\LimitStream; |
||
9 | use GuzzleHttp\Psr7\Utils; |
||
10 | use Psr\Http\Message\RequestInterface; |
||
11 | use Psr\Http\Message\ResponseFactoryInterface; |
||
12 | use Psr\Http\Message\ResponseInterface; |
||
13 | use Psr\Http\Message\ServerRequestInterface; |
||
14 | use Psr\Http\Message\StreamInterface; |
||
15 | use Psr\Http\Server\RequestHandlerInterface; |
||
16 | use Stadly\FileWaiter\Exception\StreamCouldNotBeOpened; |
||
17 | use Stadly\Http\Exception\InvalidHeader; |
||
18 | use Stadly\Http\Header\Request\IfMatch; |
||
19 | use Stadly\Http\Header\Request\IfModifiedSince; |
||
20 | use Stadly\Http\Header\Request\IfNoneMatch; |
||
21 | use Stadly\Http\Header\Request\IfRange; |
||
22 | use Stadly\Http\Header\Request\IfUnmodifiedSince; |
||
23 | use Stadly\Http\Header\Request\Range; |
||
24 | use Stadly\Http\Header\Value\Date; |
||
25 | use Stadly\Http\Header\Value\Range\ByteRange; |
||
26 | use Stadly\Http\Header\Value\Range\ByteRangeSet; |
||
27 | |||
28 | /** |
||
29 | * Class for handling file orders. |
||
30 | */ |
||
31 | final class Waiter implements RequestHandlerInterface |
||
32 | { |
||
33 | /** |
||
34 | * @var File The file to serve. |
||
35 | */ |
||
36 | private $file; |
||
37 | |||
38 | /** |
||
39 | * @var ResponseFactoryInterface Factory for creating responses. |
||
40 | */ |
||
41 | private $responseFactory; |
||
42 | |||
43 | /** |
||
44 | * @param File $file The file to serve. |
||
45 | * @param ResponseFactoryInterface $responseFactory Factory for creating responses. |
||
46 | */ |
||
47 | 1 | public function __construct(File $file, ResponseFactoryInterface $responseFactory) |
|
48 | { |
||
49 | 1 | $this->file = $file; |
|
50 | 1 | $this->responseFactory = $responseFactory; |
|
51 | } |
||
52 | |||
53 | /** |
||
54 | * Serve the file according to the request. |
||
55 | * Generates a response with the appropriate HTTP response code, headers and body. |
||
56 | * |
||
57 | * @param ServerRequestInterface $request Request to handle. |
||
58 | * @return ResponseInterface Response populated with data according to the request. |
||
59 | * @throws StreamCouldNotBeOpened If the file stream could not be opened. |
||
60 | */ |
||
61 | 81 | public function handle(ServerRequestInterface $request): ResponseInterface |
|
62 | { |
||
63 | 81 | $response = $this->responseFactory->createResponse(); |
|
64 | 81 | $response = $this->populateHeaders($response); |
|
65 | |||
66 | 81 | if (!$this->checkIfMatch($request)) { |
|
67 | 2 | return $response->withStatus(412); |
|
68 | } |
||
69 | |||
70 | 79 | if (!$this->checkIfUnmodifiedSince($request)) { |
|
71 | 2 | return $response->withStatus(412); |
|
72 | } |
||
73 | |||
74 | 77 | if (!$this->checkIfNoneMatch($request)) { |
|
75 | 6 | switch ($request->getMethod()) { |
|
76 | 6 | case 'GET': |
|
77 | 2 | case 'HEAD': |
|
78 | // 304 (Not modified) if request method is GET or HEAD |
||
79 | 5 | return $response->withStatus(304); |
|
80 | default: |
||
81 | // 412 (Precondition Failed) otherwise. |
||
82 | 1 | return $response->withStatus(412); |
|
83 | } |
||
84 | } |
||
85 | |||
86 | 71 | if (!$this->checkIfModifiedSince($request)) { |
|
87 | 4 | return $response->withStatus(304); |
|
88 | } |
||
89 | |||
90 | 67 | $rangeSet = $this->getRangeSet($request); |
|
91 | 67 | if ($rangeSet !== null) { |
|
92 | 40 | return $this->serveRangeSet($request, $response, $rangeSet); |
|
93 | } |
||
94 | |||
95 | 27 | return $this->serveFile($response); |
|
96 | } |
||
97 | |||
98 | /** |
||
99 | * Populate the response with the HTTP headers common for all responses. |
||
100 | * |
||
101 | * @param ResponseInterface $response Response to populate. |
||
102 | * @return ResponseInterface Response populated with common headers. |
||
103 | */ |
||
104 | 81 | private function populateHeaders(ResponseInterface $response): ResponseInterface |
|
105 | { |
||
106 | 81 | $response = $response->withHeader('Accept-Ranges', 'bytes'); |
|
107 | 81 | $response = $response->withHeader('Date', (string)Date::fromTimestamp(time())); |
|
108 | 81 | if ($this->file->getEntityTag() !== null) { |
|
109 | 16 | $response = $response->withHeader('ETag', (string)$this->file->getEntityTag()); |
|
110 | } |
||
111 | 81 | if ($this->file->getLastModifiedDate() !== null) { |
|
112 | 20 | $response = $response->withHeader('Last-Modified', (string)$this->file->getLastModifiedDate()); |
|
113 | } |
||
114 | |||
115 | 81 | return $response; |
|
116 | } |
||
117 | |||
118 | /** |
||
119 | * @param RequestInterface $request The request. |
||
120 | * @return ByteRangeSet|null Set of byte ranges to serve. |
||
121 | */ |
||
122 | 67 | private function getRangeSet(RequestInterface $request): ?ByteRangeSet |
|
123 | { |
||
124 | // Ignore if request method is other than GET. |
||
125 | 67 | if ($request->getMethod() !== 'GET') { |
|
126 | 2 | return null; |
|
127 | } |
||
128 | |||
129 | 65 | if (!$request->hasHeader('Range')) { |
|
130 | 17 | return null; |
|
131 | } |
||
132 | |||
133 | 48 | if (!$this->checkIfRange($request)) { |
|
134 | 6 | return null; |
|
135 | } |
||
136 | |||
137 | try { |
||
138 | 42 | $range = Range::fromValue($request->getHeaderLine('Range')); |
|
139 | 1 | } catch (InvalidHeader $exception) { |
|
140 | // Ignore invalid Range header. |
||
141 | 1 | return null; |
|
142 | } |
||
143 | |||
144 | // Only byte ranges are supported. Other range types are ignored. |
||
145 | 41 | if ($range->getRangeSet() instanceof ByteRangeSet) { |
|
146 | 40 | return $range->getRangeSet(); |
|
147 | } |
||
148 | |||
149 | 1 | return null; |
|
150 | } |
||
151 | |||
152 | /** |
||
153 | * Serve the file, without taking preconditions and ranges into consideration. |
||
154 | * Set Content-Type and Content-Length headers, and populate with file contents. |
||
155 | * |
||
156 | * @param ResponseInterface $response Preliminary response. |
||
157 | * @return ResponseInterface Populated response. |
||
158 | * @throws StreamCouldNotBeOpened If the file stream could not be opened. |
||
159 | */ |
||
160 | 41 | private function serveFile(ResponseInterface $response): ResponseInterface |
|
161 | { |
||
162 | 41 | $response = $response->withStatus(200); |
|
163 | |||
164 | 41 | if ($this->file->getFileSize() !== null) { |
|
165 | 24 | $response = $response->withHeader('Content-Length', (string)$this->file->getFileSize()); |
|
166 | } |
||
167 | |||
168 | 41 | if ($this->file->getMediaType() !== null) { |
|
169 | 1 | $response = $response->withHeader('Content-Type', (string)$this->file->getMediaType()); |
|
170 | } |
||
171 | |||
172 | 41 | return $response->withBody($this->file->getFileStream()); |
|
173 | } |
||
174 | |||
175 | /** |
||
176 | * Serve any satisfiable byte ranges from a set of ranges. |
||
177 | * Set HTTP response code and headers, and populate with file contents. |
||
178 | * |
||
179 | * @param RequestInterface $request The request. |
||
180 | * @param ResponseInterface $response Preliminary response. |
||
181 | * @param ByteRangeSet $rangeSet Set of ranges to serve. |
||
182 | * @return ResponseInterface Populated response. |
||
183 | * @throws StreamCouldNotBeOpened If the file stream could not be opened. |
||
184 | */ |
||
185 | 40 | private function serveRangeSet( |
|
186 | RequestInterface $request, |
||
187 | ResponseInterface $response, |
||
188 | ByteRangeSet $rangeSet |
||
189 | ): ResponseInterface { |
||
190 | 40 | $fileSize = $this->file->getFileSize(); |
|
191 | |||
192 | 40 | $ranges = []; |
|
193 | 40 | foreach ($rangeSet as $range) { |
|
194 | 40 | if ($range->coversFile($fileSize)) { |
|
195 | 14 | return $this->serveFile($response); |
|
196 | } |
||
197 | 26 | if ($range->isSatisfiable($fileSize)) { |
|
198 | 20 | $ranges[] = $range; |
|
199 | } |
||
200 | } |
||
201 | |||
202 | 26 | switch (count($ranges)) { |
|
203 | 26 | case 0: |
|
204 | // A Content-Range header indicating the file size cannot be sent when the file size is unknown, |
||
205 | // even though this SHOULD be done when generating a 416 response to a byte-range request. |
||
206 | 6 | if ($fileSize !== null) { |
|
207 | 4 | $response = $response->withHeader('Content-Range', 'bytes */' . $fileSize); |
|
208 | } |
||
209 | 6 | return $response->withStatus(416); |
|
210 | |||
211 | 20 | case 1: |
|
212 | 15 | return $this->serveRange($request, $response, ...$ranges); |
|
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||
213 | |||
214 | default: |
||
215 | 5 | return $this->serveRanges($request, $response, ...$ranges); |
|
216 | } |
||
217 | } |
||
218 | |||
219 | /** |
||
220 | * Serve a single byte range. |
||
221 | * Set HTTP response code and headers, and populate with file contents. |
||
222 | * |
||
223 | * @param RequestInterface $request The request. |
||
224 | * @param ResponseInterface $response Preliminary response. |
||
225 | * @param ByteRange $range Range to serve. |
||
226 | * @return ResponseInterface Populated response. |
||
227 | * @throws StreamCouldNotBeOpened If the file stream could not be opened. |
||
228 | */ |
||
229 | 15 | private function serveRange( |
|
230 | RequestInterface $request, |
||
231 | ResponseInterface $response, |
||
232 | ByteRange $range |
||
233 | ): ResponseInterface { |
||
234 | 15 | $response = $response->withStatus(206); |
|
235 | 15 | if ($request->hasHeader('If-Range')) { |
|
236 | 4 | $response = $response->withoutHeader('Content-Type'); |
|
237 | 4 | $response = $response->withoutHeader('Content-Encoding'); |
|
238 | 4 | $response = $response->withoutHeader('Content-Language'); |
|
239 | } |
||
240 | |||
241 | 15 | if ($this->file->getMediaType() !== null) { |
|
242 | 1 | $response = $response->withHeader('Content-Type', (string)$this->file->getMediaType()); |
|
243 | } |
||
244 | |||
245 | 15 | $response = $response->withHeader( |
|
246 | 15 | 'Content-Range', |
|
247 | 15 | sprintf( |
|
248 | 15 | 'bytes %d-%d/%s', |
|
249 | 15 | $range->getFirstBytePos($this->file->getFileSize()), |
|
250 | 15 | $range->getLastBytePos($this->file->getFileSize()), |
|
251 | 15 | $this->file->getFileSize() ?? '*' |
|
252 | 15 | ) |
|
253 | 15 | ); |
|
254 | |||
255 | 15 | $response = $response->withHeader('Content-Length', (string)$range->getLength($this->file->getFileSize())); |
|
256 | |||
257 | 15 | return $response->withBody($this->getRangeStream($range)); |
|
258 | } |
||
259 | |||
260 | /** |
||
261 | * Serve multiple byte ranges. |
||
262 | * Set HTTP response code and headers, and populate with file contents. |
||
263 | * |
||
264 | * @param RequestInterface $request The request. |
||
265 | * @param ResponseInterface $response Preliminary response. |
||
266 | * @param ByteRange ...$ranges Ranges to serve. |
||
267 | * @return ResponseInterface Populated response. |
||
268 | * @throws StreamCouldNotBeOpened If the file stream could not be opened. |
||
269 | */ |
||
270 | 5 | private function serveRanges( |
|
271 | RequestInterface $request, |
||
272 | ResponseInterface $response, |
||
273 | ByteRange ...$ranges |
||
274 | ): ResponseInterface { |
||
275 | 5 | $boundary = md5(uniqid(/*prefix*/(string)rand(), /*more_entropy*/true)); |
|
276 | |||
277 | 5 | $fileStream = new AppendStream(); |
|
278 | 5 | foreach ($ranges as $range) { |
|
279 | 5 | $fileStream->addStream(Utils::streamFor($this->getRangeHeader($range, $boundary))); |
|
280 | 5 | $fileStream->addStream($this->getRangeStream($range)); |
|
281 | } |
||
282 | 5 | $fileStream->addStream(Utils::streamFor("\r\n--" . $boundary . "--\r\n")); |
|
283 | |||
284 | 5 | $response = $response->withStatus(206); |
|
285 | 5 | $response = $response->withHeader('Content-Type', 'multipart/byteranges; boundary=' . $boundary); |
|
286 | 5 | $response = $response->withHeader('Content-Length', (string)$fileStream->getSize()); |
|
287 | 5 | $response = $response->withoutHeader('Content-Range'); |
|
288 | 5 | if ($request->hasHeader('If-Range')) { |
|
289 | 1 | $response = $response->withoutHeader('Content-Encoding'); |
|
290 | 1 | $response = $response->withoutHeader('Content-Language'); |
|
291 | } |
||
292 | |||
293 | 5 | return $response->withBody($fileStream); |
|
294 | } |
||
295 | |||
296 | /** |
||
297 | * Get header for range to be used when serving multiple ranges. |
||
298 | * |
||
299 | * @param ByteRange $range Range to serve. |
||
300 | * @param string $boundary Boundary used to separate the parts of the multi-part message. |
||
301 | * @return string Header for the range. |
||
302 | */ |
||
303 | 5 | private function getRangeHeader(ByteRange $range, string $boundary): string |
|
304 | { |
||
305 | 5 | $header = "\r\n--" . $boundary . "\r\n"; |
|
306 | 5 | if ($this->file->getMediaType() !== null) { |
|
307 | 1 | $header .= 'Content-Type: ' . $this->file->getMediaType() . "\r\n"; |
|
308 | } |
||
309 | 5 | $header .= sprintf( |
|
310 | 5 | "Content-Range: bytes %d-%d/%s\r\n\r\n", |
|
311 | 5 | $range->getFirstBytePos($this->file->getFileSize()), |
|
312 | 5 | $range->getLastBytePos($this->file->getFileSize()), |
|
313 | 5 | $this->file->getFileSize() ?? '*' |
|
314 | 5 | ); |
|
315 | |||
316 | 5 | return $header; |
|
317 | } |
||
318 | |||
319 | /** |
||
320 | * Serve a byte range. |
||
321 | * |
||
322 | * @param ByteRange $range Range to serve. |
||
323 | * @return StreamInterface Stream serving the range. |
||
324 | * @throws StreamCouldNotBeOpened If the file stream could not be opened. |
||
325 | */ |
||
326 | 20 | private function getRangeStream(ByteRange $range): StreamInterface |
|
327 | { |
||
328 | 20 | $fileStream = $this->file->getFileStream(); |
|
329 | 20 | return new LimitStream( |
|
330 | 20 | $fileStream, |
|
331 | 20 | $range->getLength($this->file->getFileSize()), |
|
332 | 20 | $range->getFirstBytePos($this->file->getFileSize()) |
|
333 | 20 | ); |
|
334 | } |
||
335 | |||
336 | /** |
||
337 | * @param RequestInterface $request The request. |
||
338 | * @return bool Whether the if-match-condition is satisfied, invalid or not set. |
||
339 | */ |
||
340 | 81 | private function checkIfMatch(RequestInterface $request): bool |
|
341 | { |
||
342 | 81 | if (!$request->hasHeader('If-Match')) { |
|
343 | 74 | return true; |
|
344 | } |
||
345 | |||
346 | try { |
||
347 | 7 | $ifMatch = IfMatch::fromValue($request->getHeaderLine('If-Match')); |
|
348 | 1 | } catch (InvalidHeader $exception) { |
|
349 | // Ignore invalid If-Match header. |
||
350 | 1 | return true; |
|
351 | } |
||
352 | |||
353 | 6 | return $ifMatch->evaluate($this->file->getEntityTag()); |
|
354 | } |
||
355 | |||
356 | /** |
||
357 | * @param RequestInterface $request The request. |
||
358 | * @return bool Whether the if-unmodified-since-condition is satisfied, invalid or not set. |
||
359 | */ |
||
360 | 79 | private function checkIfUnmodifiedSince(RequestInterface $request): bool |
|
361 | { |
||
362 | // Ignore if the if-match-condition is set. |
||
363 | 79 | if ($request->hasHeader('If-Match')) { |
|
364 | 5 | return true; |
|
365 | } |
||
366 | |||
367 | 74 | if (!$request->hasHeader('If-Unmodified-Since')) { |
|
368 | 69 | return true; |
|
369 | } |
||
370 | |||
371 | try { |
||
372 | 5 | $ifUnmodifiedSince = IfUnmodifiedSince::fromValue($request->getHeaderLine('If-Unmodified-Since')); |
|
373 | 1 | } catch (InvalidHeader $exception) { |
|
374 | // Ignore invalid If-Unmodified-Since header. |
||
375 | 1 | return true; |
|
376 | } |
||
377 | |||
378 | 4 | return $ifUnmodifiedSince->evaluate($this->file->getLastModifiedDate()); |
|
379 | } |
||
380 | |||
381 | /** |
||
382 | * @param RequestInterface $request The request. |
||
383 | * @return bool Whether the if-none-match-condition is satisfied, invalid or not set. |
||
384 | */ |
||
385 | 77 | private function checkIfNoneMatch(RequestInterface $request): bool |
|
386 | { |
||
387 | 77 | if (!$request->hasHeader('If-None-Match')) { |
|
388 | 67 | return true; |
|
389 | } |
||
390 | |||
391 | try { |
||
392 | 10 | $ifNoneMatch = IfNoneMatch::fromValue($request->getHeaderLine('If-None-Match')); |
|
393 | 1 | } catch (InvalidHeader $exception) { |
|
394 | // Ignore invalid If-None-Match header. |
||
395 | 1 | return true; |
|
396 | } |
||
397 | |||
398 | 9 | return $ifNoneMatch->evaluate($this->file->getEntityTag()); |
|
399 | } |
||
400 | |||
401 | /** |
||
402 | * @param RequestInterface $request The request. |
||
403 | * @return bool Whether the if-modified-since-condition is satisfied, invalid or not set. |
||
404 | */ |
||
405 | 71 | private function checkIfModifiedSince(RequestInterface $request): bool |
|
406 | { |
||
407 | // Ignore if the if-none-match-condition is set. |
||
408 | 71 | if ($request->hasHeader('If-None-Match')) { |
|
409 | 4 | return true; |
|
410 | } |
||
411 | |||
412 | // Ignore if request method is other than GET or HEAD. |
||
413 | 67 | if ($request->getMethod() !== 'GET' && $request->getMethod() !== 'HEAD') { |
|
414 | 1 | return true; |
|
415 | } |
||
416 | |||
417 | 66 | if (!$request->hasHeader('If-Modified-Since')) { |
|
418 | 59 | return true; |
|
419 | } |
||
420 | |||
421 | try { |
||
422 | 7 | $ifModifiedSince = IfModifiedSince::fromValue($request->getHeaderLine('If-Modified-Since')); |
|
423 | 1 | } catch (InvalidHeader $exception) { |
|
424 | // Ignore invalid If-Modified-Since header. |
||
425 | 1 | return true; |
|
426 | } |
||
427 | |||
428 | 6 | return $ifModifiedSince->evaluate($this->file->getLastModifiedDate()); |
|
429 | } |
||
430 | |||
431 | /** |
||
432 | * @param RequestInterface $request The request. |
||
433 | * @return bool Whether the if-range-condition is satisfied, invalid or not set. |
||
434 | */ |
||
435 | 48 | private function checkIfRange(RequestInterface $request): bool |
|
436 | { |
||
437 | 48 | if (!$request->hasHeader('If-Range')) { |
|
438 | 37 | return true; |
|
439 | } |
||
440 | |||
441 | try { |
||
442 | 11 | $ifRange = IfRange::fromValue($request->getHeaderLine('If-Range')); |
|
443 | 1 | } catch (InvalidHeader $exception) { |
|
444 | // Ignore invalid If-Range header. |
||
445 | 1 | return true; |
|
446 | } |
||
447 | |||
448 | 10 | $entityTag = $this->file->getEntityTag(); |
|
449 | 10 | $lastModifiedDate = $this->file->getLastModifiedDate(); |
|
450 | 10 | return $ifRange->evaluate($entityTag) || $ifRange->evaluate($lastModifiedDate); |
|
451 | } |
||
452 | } |
||
453 |