Skip to content

Commit d5a91d0

Browse files
authored
Merge pull request #4758 from BookStackApp/range_request_support
Range request support
2 parents a4fd825 + 2dc454d commit d5a91d0

File tree

8 files changed

+288
-36
lines changed

8 files changed

+288
-36
lines changed

app/Config/filesystems.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
'endpoint' => env('STORAGE_S3_ENDPOINT', null),
5959
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
6060
'throw' => true,
61+
'stream_reads' => false,
6162
],
6263

6364
],

app/Http/DownloadResponseFactory.php

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,74 +2,68 @@
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;
98

109
class DownloadResponseFactory
1110
{
12-
protected Request $request;
13-
14-
public function __construct(Request $request)
15-
{
16-
$this->request = $request;
11+
public function __construct(
12+
protected Request $request
13+
) {
1714
}
1815

1916
/**
2017
* Create a response that directly forces a download in the browser.
2118
*/
2219
public function directly(string $content, string $fileName): Response
2320
{
24-
return response()->make($content, 200, $this->getHeaders($fileName));
21+
return response()->make($content, 200, $this->getHeaders($fileName, strlen($content)));
2522
}
2623

2724
/**
2825
* Create a response that forces a download, from a given stream of content.
2926
*/
30-
public function streamedDirectly($stream, string $fileName): StreamedResponse
27+
public function streamedDirectly($stream, string $fileName, int $fileSize): StreamedResponse
3128
{
32-
return response()->stream(function () use ($stream) {
33-
34-
// End & flush the output buffer, if we're in one, otherwise we still use memory.
35-
// Output buffer may or may not exist depending on PHP `output_buffering` setting.
36-
// Ignore in testing since output buffers are used to gather a response.
37-
if (!empty(ob_get_status()) && !app()->runningUnitTests()) {
38-
ob_end_clean();
39-
}
40-
41-
fpassthru($stream);
42-
fclose($stream);
43-
}, 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+
);
4436
}
4537

4638
/**
4739
* Create a file download response that provides the file with a content-type
4840
* correct for the file, in a way so the browser can show the content in browser,
4941
* for a given content stream.
5042
*/
51-
public function streamedInline($stream, string $fileName): StreamedResponse
43+
public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse
5244
{
53-
$sniffContent = fread($stream, 2000);
54-
$mime = (new WebSafeMimeSniffer())->sniff($sniffContent);
45+
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
46+
$mime = $rangeStream->sniffMime();
47+
$headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders());
5548

56-
return response()->stream(function () use ($sniffContent, $stream) {
57-
echo $sniffContent;
58-
fpassthru($stream);
59-
fclose($stream);
60-
}, 200, $this->getHeaders($fileName, $mime));
49+
return response()->stream(
50+
fn() => $rangeStream->outputAndClose(),
51+
$rangeStream->getResponseStatus(),
52+
$headers,
53+
);
6154
}
6255

6356
/**
6457
* Get the common headers to provide for a download response.
6558
*/
66-
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
6760
{
6861
$disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline';
6962
$downloadName = str_replace('"', '', $fileName);
7063

7164
return [
7265
'Content-Type' => $mime,
66+
'Content-Length' => $fileSize,
7367
'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"",
7468
'X-Content-Type-Options' => 'nosniff',
7569
];

app/Http/RangeSupportedStream.php

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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
36+
{
37+
$offset = min(2000, $this->fileSize);
38+
$this->sniffContent = fread($this->stream, $offset);
39+
40+
return (new WebSafeMimeSniffer())->sniff($this->sniffContent);
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+
} elseif ($end - $start < $this->fileSize - 1) {
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+
}

app/Uploads/Attachment.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,30 @@ public function getUrl($openInline = false): string
7777
}
7878

7979
/**
80-
* Generate a HTML link to this attachment.
80+
* Get the representation of this attachment in a format suitable for the page editors.
81+
* Detects and adapts video content to use an inline video embed.
82+
*/
83+
public function editorContent(): array
84+
{
85+
$videoExtensions = ['mp4', 'webm', 'mkv', 'ogg', 'avi'];
86+
if (in_array(strtolower($this->extension), $videoExtensions)) {
87+
$html = '<video src="' . e($this->getUrl(true)) . '" controls width="480" height="270"></video>';
88+
return ['text/html' => $html, 'text/plain' => $html];
89+
}
90+
91+
return ['text/html' => $this->htmlLink(), 'text/plain' => $this->markdownLink()];
92+
}
93+
94+
/**
95+
* Generate the HTML link to this attachment.
8196
*/
8297
public function htmlLink(): string
8398
{
8499
return '<a target="_blank" href="' . e($this->getUrl()) . '">' . e($this->name) . '</a>';
85100
}
86101

87102
/**
88-
* Generate a markdown link to this attachment.
103+
* Generate a MarkDown link to this attachment.
89104
*/
90105
public function markdownLink(): string
91106
{

app/Uploads/AttachmentService.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,21 @@ protected function adjustPathForStorageDisk(string $path): string
6666
/**
6767
* Stream an attachment from storage.
6868
*
69-
* @throws FileNotFoundException
70-
*
7169
* @return resource|null
7270
*/
7371
public function streamAttachmentFromStorage(Attachment $attachment)
7472
{
7573
return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path));
7674
}
7775

76+
/**
77+
* Read the file size of an attachment from storage, in bytes.
78+
*/
79+
public function getAttachmentFileSize(Attachment $attachment): int
80+
{
81+
return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path));
82+
}
83+
7884
/**
7985
* Store a new attachment upon user upload.
8086
*

app/Uploads/Controllers/AttachmentController.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,13 @@ public function get(Request $request, string $attachmentId)
226226

227227
$fileName = $attachment->getFileName();
228228
$attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment);
229+
$attachmentSize = $this->attachmentService->getAttachmentFileSize($attachment);
229230

230231
if ($request->get('open') === 'true') {
231-
return $this->download()->streamedInline($attachmentStream, $fileName);
232+
return $this->download()->streamedInline($attachmentStream, $fileName, $attachmentSize);
232233
}
233234

234-
return $this->download()->streamedDirectly($attachmentStream, $fileName);
235+
return $this->download()->streamedDirectly($attachmentStream, $fileName, $attachmentSize);
235236
}
236237

237238
/**

resources/views/attachments/manager-list.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<div component="ajax-delete-row"
55
option:ajax-delete-row:url="{{ url('/attachments/' . $attachment->id) }}"
66
data-id="{{ $attachment->id }}"
7-
data-drag-content="{{ json_encode(['text/html' => $attachment->htmlLink(), 'text/plain' => $attachment->markdownLink()]) }}"
7+
data-drag-content="{{ json_encode($attachment->editorContent()) }}"
88
class="card drag-card">
99
<div class="handle">@icon('grip')</div>
1010
<div class="py-s">

0 commit comments

Comments
 (0)