Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.29% |
153 / 164 |
|
76.47% |
13 / 17 |
CRAP | |
0.00% |
0 / 1 |
ResponseDownload | |
93.29% |
153 / 164 |
|
76.47% |
13 / 17 |
59.02 | |
0.00% |
0 / 1 |
setDownload | |
84.21% |
32 / 38 |
|
0.00% |
0 / 1 |
9.32 | |||
prepareRange | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
setAcceptRanges | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
parseByteRange | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
10 | |||
validBytePos | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
6 | |||
setSinglePart | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
sendSinglePart | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setMultiPart | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
sendMultiPart | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
getBoundaryLine | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMultiPartTopLine | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getContentRangeLine | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
readBuffer | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
5.05 | |||
flush | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
readFile | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
hasDownload | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
sendDownload | |
80.00% |
12 / 15 |
|
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 | */ |
10 | namespace Framework\HTTP; |
11 | |
12 | use InvalidArgumentException; |
13 | use JetBrains\PhpStorm\Pure; |
14 | use 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 | */ |
25 | trait 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 | } |