Waiter::getRangeSet()   A
last analyzed

Complexity

Conditions 6
Paths 6

Size

Total Lines 28
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 13
c 1
b 0
f 0
nc 6
nop 1
dl 0
loc 28
ccs 13
cts 13
cp 1
crap 6
rs 9.2222
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
$ranges is expanded, but the parameter $range of Stadly\FileWaiter\Waiter::serveRange() does not expect variable arguments. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

212
                return $this->serveRange($request, $response, /** @scrutinizer ignore-type */ ...$ranges);
Loading history...
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