diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php index e6ae0fed3..1319c8886 100644 --- a/app/Config/filesystems.php +++ b/app/Config/filesystems.php @@ -58,6 +58,7 @@ return [ 'endpoint' => env('STORAGE_S3_ENDPOINT', null), 'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null, 'throw' => true, + 'stream_reads' => false, ], ], diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index 20032f525..f29aaa2e4 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -2,18 +2,15 @@ namespace BookStack\Http; -use BookStack\Util\WebSafeMimeSniffer; use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\StreamedResponse; class DownloadResponseFactory { - protected Request $request; - - public function __construct(Request $request) - { - $this->request = $request; + public function __construct( + protected Request $request + ) { } /** @@ -21,26 +18,21 @@ class DownloadResponseFactory */ public function directly(string $content, string $fileName): Response { - return response()->make($content, 200, $this->getHeaders($fileName)); + return response()->make($content, 200, $this->getHeaders($fileName, strlen($content))); } /** * Create a response that forces a download, from a given stream of content. */ - public function streamedDirectly($stream, string $fileName): StreamedResponse + public function streamedDirectly($stream, string $fileName, int $fileSize): StreamedResponse { - return response()->stream(function () use ($stream) { - - // End & flush the output buffer, if we're in one, otherwise we still use memory. - // Output buffer may or may not exist depending on PHP `output_buffering` setting. - // Ignore in testing since output buffers are used to gather a response. - if (!empty(ob_get_status()) && !app()->runningUnitTests()) { - ob_end_clean(); - } - - fpassthru($stream); - fclose($stream); - }, 200, $this->getHeaders($fileName)); + $rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request); + $headers = array_merge($this->getHeaders($fileName, $fileSize), $rangeStream->getResponseHeaders()); + return response()->stream( + fn() => $rangeStream->outputAndClose(), + $rangeStream->getResponseStatus(), + $headers, + ); } /** @@ -48,28 +40,30 @@ class DownloadResponseFactory * correct for the file, in a way so the browser can show the content in browser, * for a given content stream. */ - public function streamedInline($stream, string $fileName): StreamedResponse + public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse { - $sniffContent = fread($stream, 2000); - $mime = (new WebSafeMimeSniffer())->sniff($sniffContent); + $rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request); + $mime = $rangeStream->sniffMime(); + $headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders()); - return response()->stream(function () use ($sniffContent, $stream) { - echo $sniffContent; - fpassthru($stream); - fclose($stream); - }, 200, $this->getHeaders($fileName, $mime)); + return response()->stream( + fn() => $rangeStream->outputAndClose(), + $rangeStream->getResponseStatus(), + $headers, + ); } /** * Get the common headers to provide for a download response. */ - protected function getHeaders(string $fileName, string $mime = 'application/octet-stream'): array + protected function getHeaders(string $fileName, int $fileSize, string $mime = 'application/octet-stream'): array { $disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline'; $downloadName = str_replace('"', '', $fileName); return [ 'Content-Type' => $mime, + 'Content-Length' => $fileSize, 'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"", 'X-Content-Type-Options' => 'nosniff', ]; diff --git a/app/Http/RangeSupportedStream.php b/app/Http/RangeSupportedStream.php new file mode 100644 index 000000000..300f4488c --- /dev/null +++ b/app/Http/RangeSupportedStream.php @@ -0,0 +1,134 @@ +responseLength = $this->fileSize; + $this->parseRequest($request); + } + + /** + * Sniff a mime type from the stream. + */ + public function sniffMime(): string + { + $offset = min(2000, $this->fileSize); + $this->sniffContent = fread($this->stream, $offset); + + return (new WebSafeMimeSniffer())->sniff($this->sniffContent); + } + + /** + * Output the current stream to stdout before closing out the stream. + */ + public function outputAndClose(): void + { + // End & flush the output buffer, if we're in one, otherwise we still use memory. + // Output buffer may or may not exist depending on PHP `output_buffering` setting. + // Ignore in testing since output buffers are used to gather a response. + if (!empty(ob_get_status()) && !app()->runningUnitTests()) { + ob_end_clean(); + } + + $outStream = fopen('php://output', 'w'); + $sniffLength = strlen($this->sniffContent); + $bytesToWrite = $this->responseLength; + + if ($sniffLength > 0 && $this->responseOffset < $sniffLength) { + $sniffEnd = min($sniffLength, $bytesToWrite + $this->responseOffset); + $sniffOutLength = $sniffEnd - $this->responseOffset; + $sniffOutput = substr($this->sniffContent, $this->responseOffset, $sniffOutLength); + fwrite($outStream, $sniffOutput); + $bytesToWrite -= $sniffOutLength; + } else if ($this->responseOffset !== 0) { + fseek($this->stream, $this->responseOffset); + } + + stream_copy_to_stream($this->stream, $outStream, $bytesToWrite); + + fclose($this->stream); + fclose($outStream); + } + + public function getResponseHeaders(): array + { + return $this->responseHeaders; + } + + public function getResponseStatus(): int + { + return $this->responseStatus; + } + + protected function parseRequest(Request $request): void + { + $this->responseHeaders['Accept-Ranges'] = $request->isMethodSafe() ? 'bytes' : 'none'; + + $range = $this->getRangeFromRequest($request); + if ($range) { + [$start, $end] = $range; + if ($start < 0 || $start > $end) { + $this->responseStatus = 416; + $this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize); + } elseif ($end - $start < $this->fileSize - 1) { + $this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1; + $this->responseOffset = $start; + $this->responseStatus = 206; + $this->responseHeaders['Content-Range'] = sprintf('bytes %s-%s/%s', $start, $end, $this->fileSize); + $this->responseHeaders['Content-Length'] = $end - $start + 1; + } + } + + if ($request->isMethod('HEAD')) { + $this->responseLength = 0; + } + } + + protected function getRangeFromRequest(Request $request): ?array + { + $range = $request->headers->get('Range'); + if (!$range || !$request->isMethod('GET') || !str_starts_with($range, 'bytes=')) { + return null; + } + + if ($request->headers->has('If-Range')) { + return null; + } + + [$start, $end] = explode('-', substr($range, 6), 2) + [0]; + + $end = ('' === $end) ? $this->fileSize - 1 : (int) $end; + + if ('' === $start) { + $start = $this->fileSize - $end; + $end = $this->fileSize - 1; + } else { + $start = (int) $start; + } + + $end = min($end, $this->fileSize - 1); + return [$start, $end]; + } +} diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 4fd6d4cdc..57d7cb334 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -77,7 +77,22 @@ class Attachment extends Model } /** - * Generate a HTML link to this attachment. + * Get the representation of this attachment in a format suitable for the page editors. + * Detects and adapts video content to use an inline video embed. + */ + public function editorContent(): array + { + $videoExtensions = ['mp4', 'webm', 'mkv', 'ogg', 'avi']; + if (in_array(strtolower($this->extension), $videoExtensions)) { + $html = ''; + return ['text/html' => $html, 'text/plain' => $html]; + } + + return ['text/html' => $this->htmlLink(), 'text/plain' => $this->markdownLink()]; + } + + /** + * Generate the HTML link to this attachment. */ public function htmlLink(): string { @@ -85,7 +100,7 @@ class Attachment extends Model } /** - * Generate a markdown link to this attachment. + * Generate a MarkDown link to this attachment. */ public function markdownLink(): string { diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index ddabec09f..72f78e347 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -66,8 +66,6 @@ class AttachmentService /** * Stream an attachment from storage. * - * @throws FileNotFoundException - * * @return resource|null */ public function streamAttachmentFromStorage(Attachment $attachment) @@ -75,6 +73,14 @@ class AttachmentService return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path)); } + /** + * Read the file size of an attachment from storage, in bytes. + */ + public function getAttachmentFileSize(Attachment $attachment): int + { + return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path)); + } + /** * Store a new attachment upon user upload. * diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index 92f23465d..e61c10338 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -226,12 +226,13 @@ class AttachmentController extends Controller $fileName = $attachment->getFileName(); $attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment); + $attachmentSize = $this->attachmentService->getAttachmentFileSize($attachment); if ($request->get('open') === 'true') { - return $this->download()->streamedInline($attachmentStream, $fileName); + return $this->download()->streamedInline($attachmentStream, $fileName, $attachmentSize); } - return $this->download()->streamedDirectly($attachmentStream, $fileName); + return $this->download()->streamedDirectly($attachmentStream, $fileName, $attachmentSize); } /** diff --git a/resources/views/attachments/manager-list.blade.php b/resources/views/attachments/manager-list.blade.php index 342b46dca..0e841a042 100644 --- a/resources/views/attachments/manager-list.blade.php +++ b/resources/views/attachments/manager-list.blade.php @@ -4,7 +4,7 @@
@icon('grip')
diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index bd03c339c..2e1a7b339 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -316,4 +316,105 @@ class AttachmentTest extends TestCase $this->assertFileExists(storage_path($attachment->path)); $this->files->deleteAllAttachmentFiles(); } + + public function test_file_get_range_access() + { + $page = $this->entities->page(); + $this->asAdmin(); + $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'my_text.txt', 'abc123456', 'text/plain'); + + // Download access + $resp = $this->get($attachment->getUrl(), ['Range' => 'bytes=3-5']); + $resp->assertStatus(206); + $resp->assertStreamedContent('123'); + $resp->assertHeader('Content-Length', '3'); + $resp->assertHeader('Content-Range', 'bytes 3-5/9'); + + // Inline access + $resp = $this->get($attachment->getUrl(true), ['Range' => 'bytes=5-7']); + $resp->assertStatus(206); + $resp->assertStreamedContent('345'); + $resp->assertHeader('Content-Length', '3'); + $resp->assertHeader('Content-Range', 'bytes 5-7/9'); + + $this->files->deleteAllAttachmentFiles(); + } + + public function test_file_head_range_returns_no_content() + { + $page = $this->entities->page(); + $this->asAdmin(); + $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'my_text.txt', 'abc123456', 'text/plain'); + + $resp = $this->head($attachment->getUrl(), ['Range' => 'bytes=0-9']); + $resp->assertStreamedContent(''); + $resp->assertHeader('Content-Length', '9'); + $resp->assertStatus(200); + + $this->files->deleteAllAttachmentFiles(); + } + + public function test_file_head_range_edge_cases() + { + $page = $this->entities->page(); + $this->asAdmin(); + + // Mime-type "sniffing" happens on first 2k bytes, hence this content (2005 bytes) + $content = '01234' . str_repeat('a', 1990) . '0123456789'; + $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'my_text.txt', $content, 'text/plain'); + + // Test for both inline and download attachment serving + foreach ([true, false] as $isInline) { + // No end range + $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=5-']); + $resp->assertStreamedContent(substr($content, 5)); + $resp->assertHeader('Content-Length', '2000'); + $resp->assertHeader('Content-Range', 'bytes 5-2004/2005'); + $resp->assertStatus(206); + + // End only range + $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=-10']); + $resp->assertStreamedContent('0123456789'); + $resp->assertHeader('Content-Length', '10'); + $resp->assertHeader('Content-Range', 'bytes 1995-2004/2005'); + $resp->assertStatus(206); + + // Range across sniff point + $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=1997-2002']); + $resp->assertStreamedContent('234567'); + $resp->assertHeader('Content-Length', '6'); + $resp->assertHeader('Content-Range', 'bytes 1997-2002/2005'); + $resp->assertStatus(206); + + // Range up to sniff point + $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-1997']); + $resp->assertHeader('Content-Length', '1998'); + $resp->assertHeader('Content-Range', 'bytes 0-1997/2005'); + $resp->assertStreamedContent(substr($content, 0, 1998)); + $resp->assertStatus(206); + + // Range beyond sniff point + $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=2001-2003']); + $resp->assertStreamedContent('678'); + $resp->assertHeader('Content-Length', '3'); + $resp->assertHeader('Content-Range', 'bytes 2001-2003/2005'); + $resp->assertStatus(206); + + // Range beyond content + $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-2010']); + $resp->assertStreamedContent($content); + $resp->assertHeader('Content-Length', '2005'); + $resp->assertHeaderMissing('Content-Range'); + $resp->assertStatus(200); + + // Range start before end + $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=50-10']); + $resp->assertStreamedContent($content); + $resp->assertHeader('Content-Length', '2005'); + $resp->assertHeader('Content-Range', 'bytes */2005'); + $resp->assertStatus(416); + } + + $this->files->deleteAllAttachmentFiles(); + } }