Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.29% covered (success)
93.29%
153 / 164
76.47% covered (warning)
76.47%
13 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResponseDownload
93.29% covered (success)
93.29%
153 / 164
76.47% covered (warning)
76.47%
13 / 17
59.02
0.00% covered (danger)
0.00%
0 / 1
 setDownload
84.21% covered (warning)
84.21%
32 / 38
0.00% covered (danger)
0.00%
0 / 1
9.32
 prepareRange
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 setAcceptRanges
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 parseByteRange
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 validBytePos
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 setSinglePart
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 sendSinglePart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMultiPart
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 sendMultiPart
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getBoundaryLine
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMultiPartTopLine
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getContentRangeLine
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 readBuffer
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 flush
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 readFile
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 hasDownload
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendDownload
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
5.20
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework HTTP Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\HTTP;
11
12use InvalidArgumentException;
13use JetBrains\PhpStorm\Pure;
14use RuntimeException;
15
16/**
17 * Trait ResponseDownload.
18 *
19 * @see https://datatracker.ietf.org/doc/html/rfc7233
20 *
21 * @property Request $request
22 *
23 * @package http
24 */
25trait ResponseDownload
26{
27    private string $filepath;
28    private int $filesize;
29    private bool $acceptRanges = true;
30    /**
31     * @var array<int,array<int,int>>|false
32     */
33    private array | false $byteRanges = [];
34    private string $sendType = 'normal';
35    private string $boundary;
36    /**
37     * @var resource
38     */
39    private $handle;
40    private int $delay = 0;
41    private int $readLength = 1024;
42
43    /**
44     * Sets a file to download/stream.
45     *
46     * @param string $filepath
47     * @param bool $inline Set Content-Disposition header as "inline". Browsers
48     * load the file in the window. Set true to allow video or audio streams
49     * @param bool $acceptRanges Set Accept-Ranges header to "bytes". Allow
50     * partial downloads, media players to move the time position forward and
51     * back and download managers to continue/download multi-parts
52     * @param int $delay Delay between flushs in microseconds
53     * @param int $readLength Bytes read by flush
54     * @param string|null $filename A custom filename
55     *
56     * @throws InvalidArgumentException If invalid file path
57     * @throws RuntimeException If can not get the file size or modification time
58     *
59     * @return static
60     */
61    public function setDownload(
62        string $filepath,
63        bool $inline = false,
64        bool $acceptRanges = true,
65        int $delay = 0,
66        int $readLength = 1024,
67        ?string $filename = null
68    ) : static {
69        $realpath = \realpath($filepath);
70        if ($realpath === false || ! \is_file($realpath)) {
71            throw new InvalidArgumentException('Invalid file path: ' . $filepath);
72        }
73        $this->filepath = $realpath;
74        $this->delay = $delay;
75        $this->readLength = $readLength;
76        $filesize = @\filesize($this->filepath);
77        if ($filesize === false) {
78            throw new RuntimeException(
79                "Could not get the file size of '{$this->filepath}'"
80            );
81        }
82        $this->filesize = $filesize;
83        $filemtime = \filemtime($this->filepath);
84        if ($filemtime === false) {
85            throw new RuntimeException(
86                "Could not get the file modification time of '{$this->filepath}'"
87            );
88        }
89        $this->setHeader(ResponseHeader::LAST_MODIFIED, \gmdate(\DATE_RFC7231, $filemtime));
90        $filename ??= \basename($filepath);
91        $filename = \htmlspecialchars($filename, \ENT_QUOTES | \ENT_HTML5);
92        $filename = \strtr($filename, ['/' => '_', '\\' => '_']);
93        $this->setHeader(
94            Header::CONTENT_DISPOSITION,
95            $inline ? 'inline' : \sprintf('attachment; filename="%s"', $filename)
96        );
97        $this->setAcceptRanges($acceptRanges);
98        if ($acceptRanges) {
99            $rangeLine = $this->request->getHeader(RequestHeader::RANGE);
100            if ($rangeLine) {
101                $this->prepareRange($rangeLine);
102                return $this;
103            }
104        }
105        $this->setHeader(Header::CONTENT_LENGTH, (string) $this->filesize);
106        $this->setHeader(
107            Header::CONTENT_TYPE,
108            \mime_content_type($this->filepath) ?: 'application/octet-stream'
109        );
110        $this->sendType = 'normal';
111        return $this;
112    }
113
114    private function prepareRange(string $rangeLine) : void
115    {
116        $this->byteRanges = $this->parseByteRange($rangeLine);
117        if ($this->byteRanges === false) {
118            // https://datatracker.ietf.org/doc/html/rfc7233#section-4.2
119            $this->setStatus(Status::RANGE_NOT_SATISFIABLE);
120            $this->setHeader(Header::CONTENT_RANGE, '*/' . $this->filesize);
121            return;
122        }
123        $this->setStatus(Status::PARTIAL_CONTENT);
124        if (\count($this->byteRanges) === 1) {
125            $this->setSinglePart(...$this->byteRanges[0]);
126            return;
127        }
128        $this->setMultiPart(...$this->byteRanges);
129    }
130
131    private function setAcceptRanges(bool $acceptRanges) : void
132    {
133        $this->acceptRanges = $acceptRanges;
134        $this->setHeader(
135            ResponseHeader::ACCEPT_RANGES,
136            $acceptRanges ? 'bytes' : 'none'
137        );
138    }
139
140    /**
141     * Parse the HTTP Range Header line.
142     *
143     * Returns arrays of two indexes, representing first-byte-pos and last-byte-pos.
144     * If return false, the Byte Ranges are invalid, so the Response must return
145     * a 416 (Range Not Satisfiable) status.
146     *
147     * @see https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
148     * @see https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
149     *
150     * @param string $line
151     *
152     * @return array<int,array<int,int>>|false
153     *
154     * @phpstan-ignore-next-line
155     */
156    #[Pure]
157    private function parseByteRange(string $line) : array | false
158    {
159        if ( ! \str_starts_with($line, 'bytes=')) {
160            return false;
161        }
162        $line = \substr($line, 6);
163        $ranges = \explode(',', $line, 100);
164        foreach ($ranges as &$range) {
165            $range = \array_pad(\explode('-', $range, 2), 2, null);
166            if ($range[0] === null || $range[1] === null) {
167                return false;
168            }
169            if ($range[0] === '') {
170                $range[1] = $this->validBytePos($range[1]);
171                if ($range[1] === false) {
172                    return false;
173                }
174                $range[0] = $this->filesize - $range[1];
175                $range[1] = $this->filesize - 1;
176                continue;
177            }
178            $range[0] = $this->validBytePos($range[0]);
179            if ($range[0] === false) {
180                return false;
181            }
182            if ($range[1] === '') {
183                $range[1] = $this->filesize - 1;
184                continue;
185            }
186            $range[1] = $this->validBytePos($range[1]);
187            if ($range[1] === false) {
188                return false;
189            }
190        }
191        // @phpstan-ignore-next-line
192        return $ranges;
193    }
194
195    /**
196     * @param string $pos
197     *
198     * @return false|int
199     */
200    #[Pure]
201    private function validBytePos(string $pos) : false | int
202    {
203        if ( ! \is_numeric($pos) || $pos < \PHP_INT_MIN || $pos > \PHP_INT_MAX) {
204            return false;
205        }
206        if ($pos < 0 || $pos >= $this->filesize) {
207            return false;
208        }
209        return (int) $pos;
210    }
211
212    private function setSinglePart(int $firstByte, int $lastByte) : void
213    {
214        $this->sendType = 'single';
215        $this->setHeader(
216            Header::CONTENT_LENGTH,
217            (string) ($lastByte - $firstByte + 1)
218        );
219        $this->setHeader(
220            Header::CONTENT_TYPE,
221            \mime_content_type($this->filepath) ?: 'application/octet-stream'
222        );
223        $this->setHeader(
224            Header::CONTENT_RANGE,
225            \sprintf('bytes %d-%d/%d', $firstByte, $lastByte, $this->filesize)
226        );
227    }
228
229    private function sendSinglePart() : void
230    {
231        // @phpstan-ignore-next-line
232        $this->readBuffer($this->byteRanges[0][0], $this->byteRanges[0][1]);
233        //$this->readFile();
234    }
235
236    /**
237     * @param array<int,int> ...$byteRanges
238     */
239    private function setMultiPart(array ...$byteRanges) : void
240    {
241        $this->sendType = 'multi';
242        $this->boundary = \md5($this->filepath);
243        $length = 0;
244        $topLength = \strlen($this->getMultiPartTopLine());
245        foreach ($byteRanges as $range) {
246            $length += $topLength;
247            $length += \strlen($this->getContentRangeLine($range[0], $range[1]));
248            $length += $range[1] - $range[0] + 1;
249        }
250        $length += \strlen($this->getBoundaryLine());
251        $this->setHeader(Header::CONTENT_LENGTH, (string) $length);
252        $this->setHeader(
253            Header::CONTENT_TYPE,
254            "multipart/x-byteranges; boundary={$this->boundary}"
255        );
256    }
257
258    private function sendMultiPart() : void
259    {
260        $topLine = $this->getMultiPartTopLine();
261        foreach ((array) $this->byteRanges as $range) {
262            echo $topLine;
263            echo $this->getContentRangeLine($range[0], $range[1]); // @phpstan-ignore-line
264            $this->readBuffer($range[0], $range[1]); // @phpstan-ignore-line
265        }
266        echo $this->getBoundaryLine();
267        if ($this->inToString) {
268            $this->appendBody('');
269        }
270    }
271
272    #[Pure]
273    private function getBoundaryLine() : string
274    {
275        return "\r\n--{$this->boundary}--\r\n";
276    }
277
278    #[Pure]
279    private function getMultiPartTopLine() : string
280    {
281        return $this->getBoundaryLine()
282            . "Content-Type: application/octet-stream\r\n";
283    }
284
285    #[Pure]
286    private function getContentRangeLine(int $fistByte, int $lastByte) : string
287    {
288        return \sprintf(
289            "Content-Range: bytes %d-%d/%d\r\n\r\n",
290            $fistByte,
291            $lastByte,
292            $this->filesize
293        );
294    }
295
296    private function readBuffer(int $firstByte, int $lastByte) : void
297    {
298        \fseek($this->handle, $firstByte);
299        $bytesLeft = $lastByte - $firstByte + 1;
300        while ($bytesLeft > 0 && ! \feof($this->handle)) {
301            $bytesRead = $bytesLeft > $this->readLength ? $this->readLength : $bytesLeft;
302            $bytesLeft -= $bytesRead;
303            $this->flush($bytesRead);
304            if (\connection_status() !== \CONNECTION_NORMAL) {
305                break;
306            }
307        }
308    }
309
310    private function flush(int $length) : void
311    {
312        echo \fread($this->handle, $length); // @phpstan-ignore-line
313        if ($this->inToString) {
314            $this->appendBody('');
315            return;
316        }
317        \ob_flush();
318        \flush();
319        if ($this->delay) {
320            \usleep($this->delay);
321        }
322    }
323
324    private function readFile() : void
325    {
326        while ( ! \feof($this->handle)) {
327            $this->flush($this->readLength);
328            if (\connection_status() !== \CONNECTION_NORMAL) {
329                break;
330            }
331        }
332    }
333
334    /**
335     * Tell if Response has a downloadable file.
336     *
337     * @return bool
338     */
339    #[Pure]
340    public function hasDownload() : bool
341    {
342        return isset($this->filepath);
343    }
344
345    protected function sendDownload() : void
346    {
347        $handle = \fopen($this->filepath, 'rb');
348        if ($handle === false) {
349            throw new RuntimeException(
350                "Could not open a resource for file '{$this->filepath}'"
351            );
352        }
353        $this->handle = $handle;
354        switch ($this->sendType) {
355            case 'multi':
356                $this->sendMultiPart();
357                break;
358            case 'single':
359                $this->sendSinglePart();
360                break;
361            default:
362                $this->readFile();
363        }
364        \fclose($this->handle);
365    }
366}