Skip to content

Commit d947625

Browse files
committed
Range requests: Added basic HTTP range support
1 parent b4d9029 commit d947625

File tree

2 files changed

+103
-20
lines changed

2 files changed

+103
-20
lines changed

app/Http/DownloadResponseFactory.php

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace BookStack\Http;
44

5-
use BookStack\Util\WebSafeMimeSniffer;
65
use Illuminate\Http\Request;
76
use Illuminate\Http\Response;
87
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -19,18 +18,21 @@ public function __construct(
1918
*/
2019
public function directly(string $content, string $fileName): Response
2120
{
22-
return response()->make($content, 200, $this->getHeaders($fileName));
21+
return response()->make($content, 200, $this->getHeaders($fileName, strlen($content)));
2322
}
2423

2524
/**
2625
* Create a response that forces a download, from a given stream of content.
2726
*/
2827
public function streamedDirectly($stream, string $fileName, int $fileSize): StreamedResponse
2928
{
30-
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request->headers);
31-
return response()->stream(function () use ($rangeStream) {
32-
$rangeStream->outputAndClose();
33-
}, 200, $this->getHeaders($fileName));
29+
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
30+
$headers = array_merge($this->getHeaders($fileName, $fileSize), $rangeStream->getResponseHeaders());
31+
return response()->stream(
32+
fn() => $rangeStream->outputAndClose(),
33+
$rangeStream->getResponseStatus(),
34+
$headers,
35+
);
3436
}
3537

3638
/**
@@ -40,24 +42,28 @@ public function streamedDirectly($stream, string $fileName, int $fileSize): Stre
4042
*/
4143
public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse
4244
{
43-
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request->headers);
45+
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
4446
$mime = $rangeStream->sniffMime();
47+
$headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders());
4548

46-
return response()->stream(function () use ($rangeStream) {
47-
$rangeStream->outputAndClose();
48-
}, 200, $this->getHeaders($fileName, $mime));
49+
return response()->stream(
50+
fn() => $rangeStream->outputAndClose(),
51+
$rangeStream->getResponseStatus(),
52+
$headers,
53+
);
4954
}
5055

5156
/**
5257
* Get the common headers to provide for a download response.
5358
*/
54-
protected function getHeaders(string $fileName, string $mime = 'application/octet-stream'): array
59+
protected function getHeaders(string $fileName, int $fileSize, string $mime = 'application/octet-stream'): array
5560
{
5661
$disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline';
5762
$downloadName = str_replace('"', '', $fileName);
5863

5964
return [
6065
'Content-Type' => $mime,
66+
'Content-Length' => $fileSize,
6167
'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"",
6268
'X-Content-Type-Options' => 'nosniff',
6369
];

app/Http/RangeSupportedStream.php

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,30 @@
33
namespace BookStack\Http;
44

55
use BookStack\Util\WebSafeMimeSniffer;
6-
use Symfony\Component\HttpFoundation\HeaderBag;
6+
use Illuminate\Http\Request;
77

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+
*/
814
class RangeSupportedStream
915
{
1016
protected string $sniffContent;
17+
protected array $responseHeaders;
18+
protected int $responseStatus = 200;
19+
20+
protected int $responseLength = 0;
21+
protected int $responseOffset = 0;
1122

1223
public function __construct(
1324
protected $stream,
1425
protected int $fileSize,
15-
protected HeaderBag $requestHeaders,
26+
Request $request,
1627
) {
28+
$this->responseLength = $this->fileSize;
29+
$this->parseRequest($request);
1730
}
1831

1932
/**
@@ -40,18 +53,82 @@ public function outputAndClose(): void
4053
}
4154

4255
$outStream = fopen('php://output', 'w');
43-
$offset = 0;
56+
$sniffOffset = strlen($this->sniffContent);
4457

45-
if (!empty($this->sniffContent)) {
46-
fwrite($outStream, $this->sniffContent);
47-
$offset = strlen($this->sniffContent);
58+
if (!empty($this->sniffContent) && $this->responseOffset < $sniffOffset) {
59+
$sniffOutput = substr($this->sniffContent, $this->responseOffset, min($sniffOffset, $this->responseLength));
60+
fwrite($outStream, $sniffOutput);
61+
} else if ($this->responseOffset !== 0) {
62+
fseek($this->stream, $this->responseOffset);
4863
}
4964

50-
$toWrite = $this->fileSize - $offset;
51-
stream_copy_to_stream($this->stream, $outStream, $toWrite);
52-
fpassthru($this->stream);
65+
stream_copy_to_stream($this->stream, $outStream, $this->responseLength);
5366

5467
fclose($this->stream);
5568
fclose($outStream);
5669
}
70+
71+
public function getResponseHeaders(): array
72+
{
73+
return $this->responseHeaders;
74+
}
75+
76+
public function getResponseStatus(): int
77+
{
78+
return $this->responseStatus;
79+
}
80+
81+
protected function parseRequest(Request $request): void
82+
{
83+
$this->responseHeaders['Accept-Ranges'] = $request->isMethodSafe() ? 'bytes' : 'none';
84+
85+
$range = $this->getRangeFromRequest($request);
86+
if ($range) {
87+
[$start, $end] = $range;
88+
if ($start < 0 || $start > $end) {
89+
$this->responseStatus = 416;
90+
$this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize);
91+
} elseif ($end - $start < $this->fileSize - 1) {
92+
$this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1;
93+
$this->responseOffset = $start;
94+
$this->responseStatus = 206;
95+
$this->responseHeaders['Content-Range'] = sprintf('bytes %s-%s/%s', $start, $end, $this->fileSize);
96+
$this->responseHeaders['Content-Length'] = $end - $start + 1;
97+
}
98+
}
99+
100+
if ($request->isMethod('HEAD')) {
101+
$this->responseLength = 0;
102+
}
103+
}
104+
105+
protected function getRangeFromRequest(Request $request): ?array
106+
{
107+
$range = $request->headers->get('Range');
108+
if (!$range || !$request->isMethod('GET') || !str_starts_with($range, 'bytes=')) {
109+
return null;
110+
}
111+
112+
if ($request->headers->has('If-Range')) {
113+
return null;
114+
}
115+
116+
[$start, $end] = explode('-', substr($range, 6), 2) + [0];
117+
118+
$end = ('' === $end) ? $this->fileSize - 1 : (int) $end;
119+
120+
if ('' === $start) {
121+
$start = $this->fileSize - $end;
122+
$end = $this->fileSize - 1;
123+
} else {
124+
$start = (int) $start;
125+
}
126+
127+
if ($start > $end) {
128+
return null;
129+
}
130+
131+
$end = min($end, $this->fileSize - 1);
132+
return [$start, $end];
133+
}
57134
}

0 commit comments

Comments
 (0)