]> BookStack Code Mirror - bookstack/blob - app/Http/RangeSupportedStream.php
Themes: Added testing and better mime sniffing for public serving
[bookstack] / app / Http / RangeSupportedStream.php
1 <?php
2
3 namespace BookStack\Http;
4
5 use BookStack\Util\WebSafeMimeSniffer;
6 use Illuminate\Http\Request;
7
8 /**
9  * Helper wrapper for range-based stream response handling.
10  * Much of this used symfony/http-foundation as a reference during build.
11  * URL: https://github.com/symfony/http-foundation/blob/v6.0.20/BinaryFileResponse.php
12  * License: MIT license, Copyright (c) Fabien Potencier.
13  */
14 class RangeSupportedStream
15 {
16     protected string $sniffContent = '';
17     protected array $responseHeaders = [];
18     protected int $responseStatus = 200;
19
20     protected int $responseLength = 0;
21     protected int $responseOffset = 0;
22
23     public function __construct(
24         protected $stream,
25         protected int $fileSize,
26         Request $request,
27     ) {
28         $this->responseLength = $this->fileSize;
29         $this->parseRequest($request);
30     }
31
32     /**
33      * Sniff a mime type from the stream.
34      */
35     public function sniffMime(string $extension = ''): string
36     {
37         $offset = min(2000, $this->fileSize);
38         $this->sniffContent = fread($this->stream, $offset);
39
40         return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension);
41     }
42
43     /**
44      * Output the current stream to stdout before closing out the stream.
45      */
46     public function outputAndClose(): void
47     {
48         // End & flush the output buffer, if we're in one, otherwise we still use memory.
49         // Output buffer may or may not exist depending on PHP `output_buffering` setting.
50         // Ignore in testing since output buffers are used to gather a response.
51         if (!empty(ob_get_status()) && !app()->runningUnitTests()) {
52             ob_end_clean();
53         }
54
55         $outStream = fopen('php://output', 'w');
56         $sniffLength = strlen($this->sniffContent);
57         $bytesToWrite = $this->responseLength;
58
59         if ($sniffLength > 0 && $this->responseOffset < $sniffLength) {
60             $sniffEnd = min($sniffLength, $bytesToWrite + $this->responseOffset);
61             $sniffOutLength = $sniffEnd - $this->responseOffset;
62             $sniffOutput = substr($this->sniffContent, $this->responseOffset, $sniffOutLength);
63             fwrite($outStream, $sniffOutput);
64             $bytesToWrite -= $sniffOutLength;
65         } else if ($this->responseOffset !== 0) {
66             fseek($this->stream, $this->responseOffset);
67         }
68
69         stream_copy_to_stream($this->stream, $outStream, $bytesToWrite);
70
71         fclose($this->stream);
72         fclose($outStream);
73     }
74
75     public function getResponseHeaders(): array
76     {
77         return $this->responseHeaders;
78     }
79
80     public function getResponseStatus(): int
81     {
82         return $this->responseStatus;
83     }
84
85     protected function parseRequest(Request $request): void
86     {
87         $this->responseHeaders['Accept-Ranges'] = $request->isMethodSafe() ? 'bytes' : 'none';
88
89         $range = $this->getRangeFromRequest($request);
90         if ($range) {
91             [$start, $end] = $range;
92             if ($start < 0 || $start > $end) {
93                 $this->responseStatus = 416;
94                 $this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize);
95             } else {
96                 $this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1;
97                 $this->responseOffset = $start;
98                 $this->responseStatus = 206;
99                 $this->responseHeaders['Content-Range'] = sprintf('bytes %s-%s/%s', $start, $end, $this->fileSize);
100                 $this->responseHeaders['Content-Length'] = $end - $start + 1;
101             }
102         }
103
104         if ($request->isMethod('HEAD')) {
105             $this->responseLength = 0;
106         }
107     }
108
109     protected function getRangeFromRequest(Request $request): ?array
110     {
111         $range = $request->headers->get('Range');
112         if (!$range || !$request->isMethod('GET') || !str_starts_with($range, 'bytes=')) {
113             return null;
114         }
115
116         if ($request->headers->has('If-Range')) {
117             return null;
118         }
119
120         [$start, $end] = explode('-', substr($range, 6), 2) + [0];
121
122         $end = ('' === $end) ? $this->fileSize - 1 : (int) $end;
123
124         if ('' === $start) {
125             $start = $this->fileSize - $end;
126             $end = $this->fileSize - 1;
127         } else {
128             $start = (int) $start;
129         }
130
131         $end = min($end, $this->fileSize - 1);
132         return [$start, $end];
133     }
134 }